From 7ac0b235c931c7f0316cb6d177f93544ec499130 Mon Sep 17 00:00:00 2001 From: bupd Date: Tue, 10 Mar 2026 23:03:39 +0000 Subject: [PATCH 1/3] fix: bind sessionizer to prefix+k (ctrl+j, k) and update lazy-lock Co-Authored-By: Claude Opus 4.6 --- .config/nvim/lazy-lock.json | 26 +++++++++++++------------- .config/tmux/tmux.conf | 2 +- 2 files changed, 14 insertions(+), 14 deletions(-) diff --git a/.config/nvim/lazy-lock.json b/.config/nvim/lazy-lock.json index 79f6043..1e2b7ed 100644 --- a/.config/nvim/lazy-lock.json +++ b/.config/nvim/lazy-lock.json @@ -1,33 +1,33 @@ { "LuaSnip": { "branch": "master", "commit": "73813308abc2eaeff2bc0d3f2f79270c491be9d7" }, "NvChad": { "branch": "v2.5", "commit": "f107fabe11ac8013dc3435ecd5382bee872b1584" }, - "base46": { "branch": "v3.0", "commit": "db58475d3fd2a16f9b1467d6895e3c4c195ed7dd" }, + "base46": { "branch": "v3.0", "commit": "884b990dcdbe07520a0892da6ba3e8d202b46337" }, "brightburn.vim": { "branch": "master", "commit": "fc0d2fafc51e86d6065acd54b5e82e686019ff2f" }, "cloak.nvim": { "branch": "main", "commit": "648aca6d33ec011dc3166e7af3b38820d01a71e4" }, "cmd.nvim": { "branch": "main", "commit": "540174697858d244ae1794a37521c0c94e3098a0" }, - "cmp-async-path": { "branch": "main", "commit": "0ed1492f59e730c366d261a5ad822fa37e44c325" }, + "cmp-async-path": { "branch": "main", "commit": "f8af3f726e07f2e9d37672eaa9102581aefce149" }, "cmp-buffer": { "branch": "main", "commit": "b74fab3656eea9de20a9b8116afa3cfc4ec09657" }, - "cmp-nvim-lsp": { "branch": "main", "commit": "bd5a7d6db125d4654b50eeae9f5217f24bb22fd3" }, - "cmp-nvim-lua": { "branch": "main", "commit": "f12408bdb54c39c23e67cab726264c10db33ada8" }, + "cmp-nvim-lsp": { "branch": "main", "commit": "cbc7b02bb99fae35cb42f514762b89b5126651ef" }, + "cmp-nvim-lua": { "branch": "main", "commit": "e3a22cb071eb9d6508a156306b102c45cd2d573d" }, "cmp_luasnip": { "branch": "master", "commit": "98d9cb5c2c38532bd9bdb481067b20fea8f32e90" }, "conform.nvim": { "branch": "master", "commit": "9d6f881a4047a51c7709223dcf24e967633c6523" }, - "friendly-snippets": { "branch": "main", "commit": "572f5660cf05f8cd8834e096d7b4c921ba18e175" }, + "friendly-snippets": { "branch": "main", "commit": "6cd7280adead7f586db6fccbd15d2cac7e2188b9" }, "gitsigns.nvim": { "branch": "main", "commit": "1ee5c1fd068c81f9dd06483e639c2aa4587dc197" }, "golf": { "branch": "main", "commit": "abf1bc0c1c4a5482b4a4b36b950b49aaa0f39e69" }, "gopher.nvim": { "branch": "main", "commit": "295e21e637f9194a4d2bc34622d324a88b028141" }, "gruvbox": { "branch": "main", "commit": "5e0a460d8e0f7f669c158dedd5f9ae2bcac31437" }, "guihua.lua": { "branch": "master", "commit": "c49a0fb7346586a1b1431d7e407f943c4164d8cb" }, "harpoon": { "branch": "master", "commit": "1bc17e3e42ea3c46b33c0bbad6a880792692a1b3" }, - "indent-blankline.nvim": { "branch": "master", "commit": "005b56001b2cb30bfa61b7986bc50657816ba4ba" }, - "lazy.nvim": { "branch": "main", "commit": "a1380a8461ab115d69ac6a570a92611969e16c3a" }, + "indent-blankline.nvim": { "branch": "master", "commit": "d28a3f70721c79e3c5f6693057ae929f3d9c0a03" }, + "lazy.nvim": { "branch": "main", "commit": "85c7ff3711b730b4030d03144f6db6375044ae82" }, "lsp_signature.nvim": { "branch": "master", "commit": "62cadce83aaceed677ffe7a2d6a57141af7131ea" }, "mason-nvim-dap.nvim": { "branch": "main", "commit": "86389a3dd687cfaa647b6f44731e492970034baa" }, "mason.nvim": { "branch": "main", "commit": "ad7146aa61dcaeb54fa900144d768f040090bff0" }, "menu": { "branch": "main", "commit": "7a0a4a2896b715c066cfbe320bdc048091874cc6" }, "minty": { "branch": "main", "commit": "aafc9e8e0afe6bf57580858a2849578d8d8db9e0" }, "navigator.lua": { "branch": "master", "commit": "0ba1be08ba383f0f73ca467694afe0270f95fad5" }, - "nvim-autopairs": { "branch": "master", "commit": "23320e75953ac82e559c610bec5a90d9c6dfa743" }, - "nvim-cmp": { "branch": "main", "commit": "b5311ab3ed9c846b585c0c15b7559be131ec4be9" }, + "nvim-autopairs": { "branch": "master", "commit": "59bce2eef357189c3305e25bc6dd2d138c1683f5" }, + "nvim-cmp": { "branch": "main", "commit": "da88697d7f45d16852c6b2769dc52387d1ddc45f" }, "nvim-dap": { "branch": "master", "commit": "7367cec8e8f7a0b1e4566af9a7ef5959d11206a7" }, "nvim-dap-go": { "branch": "main", "commit": "b4421153ead5d726603b02743ea40cf26a51ed5f" }, "nvim-dap-ui": { "branch": "master", "commit": "cf91d5e2d07c72903d052f5207511bf7ecdb7122" }, @@ -35,10 +35,10 @@ "nvim-lspconfig": { "branch": "master", "commit": "db8fef885009fdec0daeff3e5dda92e1f539611e" }, "nvim-nio": { "branch": "master", "commit": "21f5324bfac14e22ba26553caf69ec76ae8a7662" }, "nvim-tree.lua": { "branch": "master", "commit": "87d096a39cb2d5d43e6771563575ff042a79f48b" }, - "nvim-treesitter": { "branch": "master", "commit": "42fc28ba918343ebfd5565147a42a26580579482" }, + "nvim-treesitter": { "branch": "main", "commit": "42fc28ba918343ebfd5565147a42a26580579482" }, "nvim-treesitter-context": { "branch": "master", "commit": "41847d3dafb5004464708a3db06b14f12bde548a" }, "nvim-ts-autotag": { "branch": "main", "commit": "c4ca798ab95b316a768d51eaaaee48f64a4a46bc" }, - "nvim-web-devicons": { "branch": "master", "commit": "b8221e42cf7287c4dcde81f232f58d7b947c210d" }, + "nvim-web-devicons": { "branch": "master", "commit": "737cf6c657898d0c697311d79d361288a1343d50" }, "plenary.nvim": { "branch": "master", "commit": "b9fd5226c2f76c951fc8ed5923d85e4de065e509" }, "py.nvim": { "branch": "main", "commit": "cc68e1adab6ff02f6d678abfbe95949391880b1a" }, "render-markdown.nvim": { "branch": "main", "commit": "48934b49a2363b49ae1d698ed4cb30fb79d7efe8" }, @@ -54,7 +54,7 @@ "telescope.nvim": { "branch": "master", "commit": "a0bbec21143c7bc5f8bb02e0005fa0b982edc026" }, "tokyonight.nvim": { "branch": "main", "commit": "4d159616aee17796c2c94d2f5f87d2ee1a3f67c7" }, "trouble.nvim": { "branch": "main", "commit": "f176232e7759c4f8abd923c21e3e5a5c76cd6837" }, - "ui": { "branch": "v3.0", "commit": "03b9718140375e7f3f5e4f3e04bc2b6c907440ec" }, + "ui": { "branch": "v3.0", "commit": "cb75908a86720172594b30de147272c1b3a7f452" }, "undotree": { "branch": "master", "commit": "0f1c9816975b5d7f87d5003a19c53c6fd2ff6f7f" }, "vim-astro": { "branch": "main", "commit": "9b4674ecfe1dd84b5fb9b4de1653975de6e8e2e1" }, "vim-be-good": { "branch": "master", "commit": "0ae3de14eb8efc6effe7704b5e46495e91931cc5" }, @@ -62,6 +62,6 @@ "vim-tmux-navigator": { "branch": "master", "commit": "c45243dc1f32ac6bcf6068e5300f3b2b237e576a" }, "vim-wakatime": { "branch": "master", "commit": "d7973b157a632d1edeff01818f18d67e584eeaff" }, "volt": { "branch": "main", "commit": "620de1321f275ec9d80028c68d1b88b409c0c8b1" }, - "which-key.nvim": { "branch": "main", "commit": "904308e6885bbb7b60714c80ab3daf0c071c1492" }, + "which-key.nvim": { "branch": "main", "commit": "3aab2147e74890957785941f0c1ad87d0a44c15a" }, "yanky.nvim": { "branch": "main", "commit": "04775cc6e10ef038c397c407bc17f00a2f52b378" } } diff --git a/.config/tmux/tmux.conf b/.config/tmux/tmux.conf index c1111e3..d6d9609 100644 --- a/.config/tmux/tmux.conf +++ b/.config/tmux/tmux.conf @@ -31,7 +31,7 @@ bind -n S-Right next-window # Easily reorder windows with CTRL+SHIFT+Arrow bind-key -n C-S-Left swap-window -t -1 bind-key -n C-S-Right swap-window -t +1 -bind -n C-f run-shell "tmux neww sessionizer" +bind-key k run-shell "tmux neww sessionizer" bind-key -r n new-session From 7661e40bbd24c30e20f90ace5b7b5ce7c2f25701 Mon Sep 17 00:00:00 2001 From: Prasanth Baskar Date: Sun, 3 May 2026 00:03:03 +0000 Subject: [PATCH 2/3] feat: add repo-topology CLI tool for repository structure visualization Go CLI tool that analyzes git repositories and outputs an ASCII visualization including directory tree with file counts, branch/remote summary, file-type distribution bar chart, and size stats. Supports --depth and --format flags. Nightshift-Task: repo-topology Nightshift-Ref: https://github.com/marcus/nightshift Co-Authored-By: Claude Opus 4.6 --- scripts/repo-topology/.gitignore | 1 + scripts/repo-topology/go.mod | 3 + scripts/repo-topology/main.go | 427 +++++++++++++++++++++++++++++++ 3 files changed, 431 insertions(+) create mode 100644 scripts/repo-topology/.gitignore create mode 100644 scripts/repo-topology/go.mod create mode 100644 scripts/repo-topology/main.go diff --git a/scripts/repo-topology/.gitignore b/scripts/repo-topology/.gitignore new file mode 100644 index 0000000..5bf7ee0 --- /dev/null +++ b/scripts/repo-topology/.gitignore @@ -0,0 +1 @@ +repo-topology diff --git a/scripts/repo-topology/go.mod b/scripts/repo-topology/go.mod new file mode 100644 index 0000000..d530ab6 --- /dev/null +++ b/scripts/repo-topology/go.mod @@ -0,0 +1,3 @@ +module github.com/bupd/dotfiles/scripts/repo-topology + +go 1.26.1 diff --git a/scripts/repo-topology/main.go b/scripts/repo-topology/main.go new file mode 100644 index 0000000..d929290 --- /dev/null +++ b/scripts/repo-topology/main.go @@ -0,0 +1,427 @@ +package main + +import ( + "flag" + "fmt" + "os" + "os/exec" + "path/filepath" + "sort" + "strconv" + "strings" +) + +const ( + colorReset = "\033[0m" + colorBold = "\033[1m" + colorGreen = "\033[32m" + colorYellow = "\033[33m" + colorBlue = "\033[34m" + colorCyan = "\033[36m" + colorGray = "\033[90m" +) + +type Config struct { + RepoPath string + Depth int + Color bool +} + +type DirInfo struct { + Path string + Files int + SubDirs int + Size int64 +} + +type RepoStats struct { + Branches []string + Remotes []string + RecentLogs []string + DirTree []DirInfo + FileTypes map[string]int + TotalFiles int + TotalSize int64 + TotalDirs int +} + +func main() { + depth := flag.Int("depth", 3, "directory tree depth") + format := flag.String("format", "color", "output format: color or plain") + flag.Usage = func() { + fmt.Fprintf(os.Stderr, "Usage: repo-topology [flags] [repo-path]\n\n") + fmt.Fprintf(os.Stderr, "Analyze and visualize a git repository's topology.\n\n") + fmt.Fprintf(os.Stderr, "Flags:\n") + flag.PrintDefaults() + } + flag.Parse() + + repoPath := "." + if flag.NArg() > 0 { + repoPath = flag.Arg(0) + } + + absPath, err := filepath.Abs(repoPath) + if err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } + + if !isGitRepo(absPath) { + fmt.Fprintf(os.Stderr, "Error: %s is not a git repository\n", absPath) + os.Exit(1) + } + + cfg := Config{ + RepoPath: absPath, + Depth: *depth, + Color: *format == "color", + } + + stats, err := analyzeRepo(cfg) + if err != nil { + fmt.Fprintf(os.Stderr, "Error analyzing repo: %v\n", err) + os.Exit(1) + } + + render(cfg, stats) +} + +func isGitRepo(path string) bool { + gitDir := filepath.Join(path, ".git") + info, err := os.Stat(gitDir) + if err != nil { + return false + } + return info.IsDir() || info.Mode().IsRegular() // .git can be a file (worktree) +} + +func gitCmd(repoPath string, args ...string) (string, error) { + cmd := exec.Command("git", append([]string{"-C", repoPath}, args...)...) + out, err := cmd.Output() + return strings.TrimSpace(string(out)), err +} + +func analyzeRepo(cfg Config) (RepoStats, error) { + var stats RepoStats + stats.FileTypes = make(map[string]int) + + // Branches + if out, err := gitCmd(cfg.RepoPath, "branch", "--format=%(refname:short)"); err == nil && out != "" { + stats.Branches = strings.Split(out, "\n") + } + + // Remotes + if out, err := gitCmd(cfg.RepoPath, "remote", "-v"); err == nil && out != "" { + seen := map[string]bool{} + for line := range strings.SplitSeq(out, "\n") { + parts := strings.Fields(line) + if len(parts) >= 2 && !seen[parts[0]] { + seen[parts[0]] = true + stats.Remotes = append(stats.Remotes, parts[0]+"\t"+parts[1]) + } + } + } + + // Recent commits + if out, err := gitCmd(cfg.RepoPath, "log", "--oneline", "-10"); err == nil && out != "" { + stats.RecentLogs = strings.Split(out, "\n") + } + + // Walk directory tree + err := walkDir(cfg.RepoPath, cfg.Depth, &stats) + return stats, err +} + +func walkDir(root string, maxDepth int, stats *RepoStats) error { + dirFiles := map[string]int{} + dirSubDirs := map[string]int{} + dirSize := map[string]int64{} + + err := filepath.Walk(root, func(path string, info os.FileInfo, err error) error { + if err != nil { + return nil // skip errors + } + + rel, _ := filepath.Rel(root, path) + if rel == "." { + return nil + } + + // Skip .git directory + if info.IsDir() && info.Name() == ".git" { + return filepath.SkipDir + } + + depth := strings.Count(rel, string(os.PathSeparator)) + + if info.IsDir() { + if depth >= maxDepth { + return filepath.SkipDir + } + stats.TotalDirs++ + parent := filepath.Dir(rel) + if parent == "." { + parent = "." + } + dirSubDirs[parent]++ + return nil + } + + // File + stats.TotalFiles++ + stats.TotalSize += info.Size() + + ext := strings.ToLower(filepath.Ext(info.Name())) + if ext == "" { + ext = "(no ext)" + } + stats.FileTypes[ext]++ + + // Count files per directory (up to maxDepth) + dir := filepath.Dir(rel) + if dir == "." { + dir = "." + } + // Truncate to maxDepth + parts := strings.Split(dir, string(os.PathSeparator)) + if len(parts) > maxDepth { + dir = strings.Join(parts[:maxDepth], string(os.PathSeparator)) + } + dirFiles[dir]++ + dirSize[dir] += info.Size() + + return nil + }) + + // Build DirTree sorted + allDirs := map[string]bool{} + for d := range dirFiles { + allDirs[d] = true + } + for d := range dirSubDirs { + allDirs[d] = true + } + + for d := range allDirs { + stats.DirTree = append(stats.DirTree, DirInfo{ + Path: d, + Files: dirFiles[d], + SubDirs: dirSubDirs[d], + Size: dirSize[d], + }) + } + sort.Slice(stats.DirTree, func(i, j int) bool { + return stats.DirTree[i].Path < stats.DirTree[j].Path + }) + + return err +} + +func render(cfg Config, stats RepoStats) { + c := colorizer(cfg.Color) + + // Header + fmt.Println(c(colorBold, "=== Repo Topology: "+filepath.Base(cfg.RepoPath)+" ===")) + fmt.Println() + + // Branches & Remotes + renderBranches(c, stats) + renderRemotes(c, stats) + renderRecentCommits(c, stats) + renderDirTree(c, stats) + renderFileTypes(c, stats) + renderSizeStats(c, stats) +} + +type colorFunc func(color, text string) string + +func colorizer(enabled bool) colorFunc { + if enabled { + return func(color, text string) string { + return color + text + colorReset + } + } + return func(_, text string) string { return text } +} + +func renderBranches(c colorFunc, stats RepoStats) { + fmt.Println(c(colorCyan, "Branches") + c(colorGray, " ("+strconv.Itoa(len(stats.Branches))+")")) + for _, b := range stats.Branches { + fmt.Println(" " + c(colorGreen, b)) + } + fmt.Println() +} + +func renderRemotes(c colorFunc, stats RepoStats) { + fmt.Println(c(colorCyan, "Remotes")) + for _, r := range stats.Remotes { + parts := strings.SplitN(r, "\t", 2) + if len(parts) == 2 { + fmt.Println(" " + c(colorYellow, parts[0]) + " " + c(colorGray, parts[1])) + } + } + fmt.Println() +} + +func renderRecentCommits(c colorFunc, stats RepoStats) { + fmt.Println(c(colorCyan, "Recent Commits")) + for _, l := range stats.RecentLogs { + parts := strings.SplitN(l, " ", 2) + if len(parts) == 2 { + fmt.Println(" " + c(colorYellow, parts[0]) + " " + parts[1]) + } else { + fmt.Println(" " + l) + } + } + fmt.Println() +} + +func renderDirTree(c colorFunc, stats RepoStats) { + fmt.Println(c(colorCyan, "Directory Tree")) + + type node struct { + name string + files int + children []*node + } + + root := &node{name: "."} + nodeMap := map[string]*node{".": root} + + for _, d := range stats.DirTree { + parts := strings.Split(d.Path, string(os.PathSeparator)) + current := "." + for i, p := range parts { + if current == "." && i == 0 && p == "." { + nodeMap["."].files = d.Files + continue + } + var fullPath string + if current == "." { + fullPath = p + } else { + fullPath = current + string(os.PathSeparator) + p + } + + if _, exists := nodeMap[fullPath]; !exists { + n := &node{name: p} + nodeMap[fullPath] = n + parent := nodeMap[current] + if parent != nil { + parent.children = append(parent.children, n) + } + } + if i == len(parts)-1 { + nodeMap[fullPath].files = d.Files + } + current = fullPath + } + } + + // Render tree + var printTree func(n *node, prefix string, isLast bool) + printTree = func(n *node, prefix string, isLast bool) { + connector := "├── " + if isLast { + connector = "└── " + } + fileInfo := "" + if n.files > 0 { + fileInfo = c(colorGray, " ("+strconv.Itoa(n.files)+" files)") + } + if n == root { + fmt.Println(" " + c(colorBlue, ".") + fileInfo) + } else { + fmt.Println(" " + prefix + connector + c(colorBlue, n.name) + fileInfo) + } + + childPrefix := prefix + if n != root { + if isLast { + childPrefix += " " + } else { + childPrefix += "│ " + } + } + + sort.Slice(n.children, func(i, j int) bool { + return n.children[i].name < n.children[j].name + }) + + for i, child := range n.children { + printTree(child, childPrefix, i == len(n.children)-1) + } + } + printTree(root, "", true) + fmt.Println() +} + +func renderFileTypes(c colorFunc, stats RepoStats) { + fmt.Println(c(colorCyan, "File Types")) + + type extCount struct { + ext string + count int + } + var sorted []extCount + for ext, count := range stats.FileTypes { + sorted = append(sorted, extCount{ext, count}) + } + sort.Slice(sorted, func(i, j int) bool { + return sorted[i].count > sorted[j].count + }) + + maxCount := 0 + if len(sorted) > 0 { + maxCount = sorted[0].count + } + barWidth := 30 + + shown := 0 + for _, ec := range sorted { + if shown >= 15 { + break + } + width := (ec.count * barWidth) / maxCount + width = max(width, 1) + bar := strings.Repeat("█", width) + fmt.Printf(" %-12s %s %s\n", + c(colorYellow, ec.ext), + c(colorGreen, bar), + c(colorGray, strconv.Itoa(ec.count))) + shown++ + } + if len(sorted) > 15 { + fmt.Printf(" %s\n", c(colorGray, "... and "+strconv.Itoa(len(sorted)-15)+" more")) + } + fmt.Println() +} + +func renderSizeStats(c colorFunc, stats RepoStats) { + fmt.Println(c(colorCyan, "Summary")) + fmt.Printf(" Total files: %s\n", c(colorBold, strconv.Itoa(stats.TotalFiles))) + fmt.Printf(" Total directories: %s\n", c(colorBold, strconv.Itoa(stats.TotalDirs))) + fmt.Printf(" Total size: %s\n", c(colorBold, formatSize(stats.TotalSize))) + fmt.Printf(" File types: %s\n", c(colorBold, strconv.Itoa(len(stats.FileTypes)))) + fmt.Printf(" Branches: %s\n", c(colorBold, strconv.Itoa(len(stats.Branches)))) + fmt.Println() +} + +func formatSize(bytes int64) string { + const ( + KB = 1024 + MB = KB * 1024 + GB = MB * 1024 + ) + switch { + case bytes >= GB: + return fmt.Sprintf("%.1f GB", float64(bytes)/float64(GB)) + case bytes >= MB: + return fmt.Sprintf("%.1f MB", float64(bytes)/float64(MB)) + case bytes >= KB: + return fmt.Sprintf("%.1f KB", float64(bytes)/float64(KB)) + default: + return fmt.Sprintf("%d B", bytes) + } +} From 57d9a718645ed0384d39970fa2c3e03cb9f55a7f Mon Sep 17 00:00:00 2001 From: Prasanth Baskar Date: Sun, 3 May 2026 00:06:30 +0000 Subject: [PATCH 3/3] test: add unit tests for repo-topology CLI tool Add comprehensive test coverage for core functions: isGitRepo, analyzeRepo, walkDir depth limiting, formatSize, colorizer, and render smoke test. Tests use temporary git repos. Nightshift-Task: repo-topology Nightshift-Ref: https://github.com/marcus/nightshift Co-Authored-By: Claude Opus 4.6 --- scripts/repo-topology/main_test.go | 190 +++++++++++++++++++++++++++++ 1 file changed, 190 insertions(+) create mode 100644 scripts/repo-topology/main_test.go diff --git a/scripts/repo-topology/main_test.go b/scripts/repo-topology/main_test.go new file mode 100644 index 0000000..69de083 --- /dev/null +++ b/scripts/repo-topology/main_test.go @@ -0,0 +1,190 @@ +package main + +import ( + "os" + "os/exec" + "path/filepath" + "strings" + "testing" +) + +func setupTestRepo(t *testing.T) string { + t.Helper() + dir := t.TempDir() + + cmds := [][]string{ + {"git", "init"}, + {"git", "config", "user.email", "test@test.com"}, + {"git", "config", "user.name", "Test"}, + } + for _, args := range cmds { + cmd := exec.Command(args[0], args[1:]...) + cmd.Dir = dir + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("setup %v failed: %v\n%s", args, err, out) + } + } + + // Create some files and directories + files := map[string]string{ + "README.md": "# Test", + "main.go": "package main", + "src/app.go": "package src", + "src/app_test.go": "package src", + "docs/guide.md": "# Guide", + } + for name, content := range files { + p := filepath.Join(dir, name) + os.MkdirAll(filepath.Dir(p), 0o755) + os.WriteFile(p, []byte(content), 0o644) + } + + cmd := exec.Command("git", "add", "-A") + cmd.Dir = dir + cmd.CombinedOutput() + + cmd = exec.Command("git", "commit", "-m", "initial") + cmd.Dir = dir + cmd.CombinedOutput() + + return dir +} + +func TestIsGitRepo(t *testing.T) { + repo := setupTestRepo(t) + + if !isGitRepo(repo) { + t.Error("expected true for a valid git repo") + } + if isGitRepo(t.TempDir()) { + t.Error("expected false for a non-git directory") + } +} + +func TestAnalyzeRepo(t *testing.T) { + repo := setupTestRepo(t) + cfg := Config{RepoPath: repo, Depth: 3, Color: false} + + stats, err := analyzeRepo(cfg) + if err != nil { + t.Fatalf("analyzeRepo failed: %v", err) + } + + if stats.TotalFiles != 5 { + t.Errorf("expected 5 files, got %d", stats.TotalFiles) + } + if stats.TotalDirs < 2 { + t.Errorf("expected at least 2 dirs, got %d", stats.TotalDirs) + } + if len(stats.Branches) == 0 { + t.Error("expected at least one branch") + } + if stats.FileTypes[".go"] != 3 { + t.Errorf("expected 3 .go files, got %d", stats.FileTypes[".go"]) + } + if stats.FileTypes[".md"] != 2 { + t.Errorf("expected 2 .md files, got %d", stats.FileTypes[".md"]) + } + if len(stats.RecentLogs) != 1 { + t.Errorf("expected 1 commit log, got %d", len(stats.RecentLogs)) + } +} + +func TestWalkDirDepth(t *testing.T) { + repo := setupTestRepo(t) + + // Create deeply nested structure + deep := filepath.Join(repo, "a", "b", "c", "d") + os.MkdirAll(deep, 0o755) + os.WriteFile(filepath.Join(deep, "deep.txt"), []byte("deep"), 0o644) + + stats := RepoStats{FileTypes: make(map[string]int)} + err := walkDir(repo, 2, &stats) + if err != nil { + t.Fatalf("walkDir failed: %v", err) + } + + // With depth 2, "a/b/c" should be skipped + for _, d := range stats.DirTree { + depth := strings.Count(d.Path, string(os.PathSeparator)) + if d.Path != "." && depth >= 2 { + // dir paths in tree should be at most depth 1 (0-indexed from root) + // but file counts may be rolled up + } + } + + // deep.txt should not be counted since it's beyond depth 2 + if stats.FileTypes[".txt"] != 0 { + t.Errorf("expected 0 .txt files at depth 2, got %d", stats.FileTypes[".txt"]) + } +} + +func TestFormatSize(t *testing.T) { + tests := []struct { + bytes int64 + expected string + }{ + {0, "0 B"}, + {500, "500 B"}, + {1024, "1.0 KB"}, + {1536, "1.5 KB"}, + {1048576, "1.0 MB"}, + {1073741824, "1.0 GB"}, + } + for _, tc := range tests { + got := formatSize(tc.bytes) + if got != tc.expected { + t.Errorf("formatSize(%d) = %q, want %q", tc.bytes, got, tc.expected) + } + } +} + +func TestColorizer(t *testing.T) { + enabled := colorizer(true) + result := enabled(colorGreen, "hello") + if !strings.Contains(result, "\033[32m") { + t.Error("expected ANSI color codes when enabled") + } + if !strings.HasSuffix(result, colorReset) { + t.Error("expected color reset suffix") + } + + disabled := colorizer(false) + result = disabled(colorGreen, "hello") + if result != "hello" { + t.Errorf("expected plain text when disabled, got %q", result) + } +} + +func TestRenderDoesNotPanic(t *testing.T) { + repo := setupTestRepo(t) + cfg := Config{RepoPath: repo, Depth: 3, Color: false} + + stats, err := analyzeRepo(cfg) + if err != nil { + t.Fatalf("analyzeRepo failed: %v", err) + } + + // Redirect stdout to discard output + old := os.Stdout + os.Stdout, _ = os.Open(os.DevNull) + defer func() { os.Stdout = old }() + + // Should not panic + render(cfg, stats) +} + +func TestAnalyzeRepoNoRemotes(t *testing.T) { + repo := setupTestRepo(t) + cfg := Config{RepoPath: repo, Depth: 3, Color: false} + + stats, err := analyzeRepo(cfg) + if err != nil { + t.Fatalf("analyzeRepo failed: %v", err) + } + + // Test repo has no remotes + if len(stats.Remotes) != 0 { + t.Errorf("expected 0 remotes, got %d", len(stats.Remotes)) + } +}