diff --git a/main.go b/main.go index 59ece35..a0145ed 100644 --- a/main.go +++ b/main.go @@ -251,6 +251,11 @@ OPTIONS Replace git status letters with Nerd Font icons (requires a Nerd Font-patched terminal font) + -c, --changed-only + Only show files that have changes (appear in git status). This + filters out unmodified tracked files and shows only files with + status indicators (modified, added, deleted, renamed, etc.) + %s `, link("https://github.com/llimllib/git-ls", "https://github.com/llimllib/git-ls")) } @@ -260,6 +265,8 @@ func main() { diffWidth := 4 monoHash := false nerdFont := false + changedOnly := false + debug := false var formatColumns []Column for len(argv) > 0 { if argv[0] == "--version" { @@ -276,6 +283,9 @@ func main() { } else if argv[0] == "--nerdfont" { nerdFont = true argv = argv[1:] + } else if argv[0] == "--changed-only" || argv[0] == "-c" { + changedOnly = true + argv = argv[1:] } else if strings.HasPrefix(argv[0], "--diffWidth") { if len(argv) == 1 { if strings.Contains(argv[0], "=") { @@ -302,6 +312,9 @@ func main() { formatColumns = parseFormat(argv[1]) argv = argv[2:] } + } else if strings.HasPrefix(argv[0], "--debug") { + debug = true + argv = argv[1:] } else { // Non-flag argument (directory), stop parsing flags break @@ -344,10 +357,60 @@ func main() { resolved := must(filepath.EvalSymlinks(must(filepath.Abs(".")))) curdir := must(filepath.Rel(gitData.root, resolved)) fileStatus(gitData.status, files, curdir) - if err := parseGitLogStreaming(files); err != nil { - log.Printf("Warning: git log streaming failed: %v", err) - // Fallback to parallel approach if streaming fails - parseGitLogParallel(files) + + // Parse deleted files from git status and merge into file list + deletedFiles := parseDeletedFiles(gitData.status, curdir) + files = append(files, deletedFiles...) + + // Filter to only changed files if requested + if changedOnly { + var changedFiles []*File + for _, file := range files { + if file.status != "" { + changedFiles = append(changedFiles, file) + } + } + files = changedFiles + } + + // Now run git log on the files we'll show + // Skip git log for files that won't have history: + // - I: Ignored files - not tracked by git + // - ??: Untracked files - not in git yet + // - *: .git directory - git metadata, not file history + // - A: Newly added files + // Examples: "A ", "AM", "AD" - staged additions not yet committed + // But "M ", "MM", or mixed dirs like "A ,M " have history + var filesNeedingLog []*File + for _, file := range files { + if file.status == "I" || file.status == "??" || file.status == "*" { + continue + } + // Check if ALL statuses in a comma-separated list are new additions + // For directories, status might be "A ,M " if it has both new and modified files + allNew := true + for status := range strings.SplitSeq(file.status, ",") { + status = strings.TrimSpace(status) + // Check if this individual status represents a file with history + // If first char (index) is not 'A' and not untracked, it has history + if len(status) > 0 && status[0] != 'A' && status != "??" { + allNew = false + break + } + } + if !allNew { + filesNeedingLog = append(filesNeedingLog, file) + } + } + + if debug { + for _, file := range filesNeedingLog { + log.Printf("%s,", file.Name()) + } + fmt.Printf("\n") + } + if err := parseGitLog(filesNeedingLog); err != nil { + log.Fatalf("Error: git log streaming failed: %v", err) } parseDiffStat(gitData.diffStat, files) @@ -356,10 +419,7 @@ func main() { file.diffStat = makeDiffGraph(file, diffWidth) } - // Parse deleted files from git status and merge into file list - deletedFiles := parseDeletedFiles(gitData.status, curdir) - parseGitLogParallel(deletedFiles) - files = append(files, deletedFiles...) + // Sort files by name slices.SortFunc(files, func(a, b *File) int { return strings.Compare(strings.ToLower(a.Name()), strings.ToLower(b.Name())) }) @@ -688,20 +748,14 @@ func parseDeletedFiles(status []byte, curdir string) []*File { return deletedFiles } -// gitLogResult holds the result of a git log call for a single file -type gitLogResult struct { - file *File - output []byte -} - -// parseGitLogStreaming runs a single git log command and streams the output, +// parseGitLog runs a single git log command and streams the output, // stopping as soon as all files are found. This is much faster than spawning // N processes because: // 1. Single process (no spawn overhead) // 2. Early exit (stops when all files found) // 3. Walks history once (all files benefit from git's caching) // 4. Directory scoped (only checks current directory) -func parseGitLogStreaming(files []*File) error { +func parseGitLog(files []*File) error { if len(files) == 0 { return nil } @@ -843,42 +897,6 @@ func parseGitLogStreaming(files []*File) error { return nil } -// parseGitLogParallel runs git log -1 for each file in parallel and parses -// the results. This is used for deleted files where we need individual lookups. -func parseGitLogParallel(files []*File) { - results := make(chan gitLogResult, len(files)) - - // Launch all git log commands in parallel - for _, file := range files { - go func(f *File) { - cmd := exec.Command("git", "-c", "core.fsmonitor=false", "log", "-1", "--date=format:%Y-%m-%d", - "--pretty=format:%H%x00%h%x00%ad%x00%aN%x00%aE%x00%s", "--", f.Name()) - out, _ := cmd.Output() - results <- gitLogResult{file: f, output: out} - }(file) - } - - // Collect results - for range len(files) { - result := <-results - if len(result.output) == 0 { - continue - } - - parts := strings.SplitN(string(result.output), "\x00", 6) - if len(parts) != 6 { - continue - } - - result.file.hash = parts[0] - result.file.shortHash = parts[1] - result.file.lastModified = parts[2] - result.file.author = parts[3] - result.file.authorEmail = parts[4] - result.file.message = parts[5] - } -} - // first returns the first part of a filepath. Given "some/file/path", it will // return "some". Modified from golang's built-in Split function: // https://github.com/golang/go/blob/c5698e315/src/internal/filepathlite/path.go#L204-L212 diff --git a/main_test.go b/main_test.go index 1cdb7a3..b112bf3 100644 --- a/main_test.go +++ b/main_test.go @@ -484,7 +484,7 @@ func TestWorktreeWithSymlink(t *testing.T) { // Create a git repo with one commit repoDir := tmpDir + "/repo" - if err := os.Mkdir(repoDir, 0755); err != nil { + if err := os.Mkdir(repoDir, 0o755); err != nil { t.Fatalf("Failed to create repo dir: %v", err) } @@ -502,7 +502,7 @@ func TestWorktreeWithSymlink(t *testing.T) { } // Create and commit a file - if err := os.WriteFile(repoDir+"/file1.txt", []byte("hello"), 0644); err != nil { + if err := os.WriteFile(repoDir+"/file1.txt", []byte("hello"), 0o644); err != nil { t.Fatalf("Failed to write file: %v", err) } if err := runCmd(repoDir, "git", "add", "file1.txt"); err != nil { @@ -519,7 +519,7 @@ func TestWorktreeWithSymlink(t *testing.T) { } // Add an untracked file in the worktree - if err := os.WriteFile(wtDir+"/rootfile.txt", []byte("untracked"), 0644); err != nil { + if err := os.WriteFile(wtDir+"/rootfile.txt", []byte("untracked"), 0o644); err != nil { t.Fatalf("Failed to write untracked file: %v", err) } @@ -656,7 +656,7 @@ func TestSubdirectoryGitInfo(t *testing.T) { // Create a git repo repoDir := tmpDir + "/repo" - if err := os.Mkdir(repoDir, 0755); err != nil { + if err := os.Mkdir(repoDir, 0o755); err != nil { t.Fatalf("Failed to create repo dir: %v", err) } @@ -675,10 +675,10 @@ func TestSubdirectoryGitInfo(t *testing.T) { // Create a subdirectory with a file subDir := repoDir + "/docs" - if err := os.Mkdir(subDir, 0755); err != nil { + if err := os.Mkdir(subDir, 0o755); err != nil { t.Fatalf("Failed to create subdir: %v", err) } - if err := os.WriteFile(subDir+"/README.md", []byte("# Documentation"), 0644); err != nil { + if err := os.WriteFile(subDir+"/README.md", []byte("# Documentation"), 0o644); err != nil { t.Fatalf("Failed to write file: %v", err) } @@ -719,8 +719,8 @@ func TestSubdirectoryGitInfo(t *testing.T) { } // Run parseGitLogStreaming to get git info - if err := parseGitLogStreaming(files); err != nil { - t.Fatalf("parseGitLogStreaming failed: %v", err) + if err := parseGitLog(files); err != nil { + t.Fatalf("parseGitLog failed: %v", err) } // Verify that README.md has git info populated @@ -767,7 +767,7 @@ func TestEmptyRepository(t *testing.T) { // Create an empty git repo (no commits) repoDir := tmpDir + "/empty-repo" - if err := os.Mkdir(repoDir, 0755); err != nil { + if err := os.Mkdir(repoDir, 0o755); err != nil { t.Fatalf("Failed to create repo dir: %v", err) } @@ -785,7 +785,7 @@ func TestEmptyRepository(t *testing.T) { } // Create a file but don't commit it - if err := os.WriteFile(repoDir+"/test.txt", []byte("hello"), 0644); err != nil { + if err := os.WriteFile(repoDir+"/test.txt", []byte("hello"), 0o644); err != nil { t.Fatalf("Failed to write file: %v", err) } @@ -816,7 +816,114 @@ func TestEmptyRepository(t *testing.T) { t.Log("Successfully ran gitDiffStat() in empty repository without crashing") } -// TestRenamedFileGitInfo tests that renamed files show the commit info from +// TestChangedOnly verifies that the --changed-only flag filters files +// to only show those with git status. +func TestChangedOnly(t *testing.T) { + // Create test files with different statuses + files := []*File{ + {entry: &mockDirEntry{name: "modified.go"}, status: "M "}, + {entry: &mockDirEntry{name: "untracked.go"}, status: "??"}, + {entry: &mockDirEntry{name: "clean.go"}, status: ""}, + {entry: &mockDirEntry{name: "added.go"}, status: "A "}, + {entry: &mockDirEntry{name: "another-clean.go"}, status: ""}, + {name: "deleted.go", status: "D ", isDeleted: true}, + } + + // Filter to only changed files (mimicking the --changed-only logic) + var changedFiles []*File + for _, file := range files { + if file.status != "" { + changedFiles = append(changedFiles, file) + } + } + + // Verify we got only the files with status + if len(changedFiles) != 4 { + t.Errorf("Expected 4 changed files, got %d", len(changedFiles)) + } + + // Verify the correct files were kept + expectedNames := map[string]bool{ + "modified.go": true, + "untracked.go": true, + "added.go": true, + "deleted.go": true, + } + + for _, f := range changedFiles { + name := f.Name() + if !expectedNames[name] { + t.Errorf("Unexpected file in changed list: %s", name) + } + delete(expectedNames, name) + } + + if len(expectedNames) > 0 { + for name := range expectedNames { + t.Errorf("Expected file %s was not in changed list", name) + } + } +} + +// TestSkipGitLogForIgnoredFiles verifies that we skip git log for files +// that don't have meaningful history (ignored, untracked, .git, newly added) +func TestSkipGitLogForIgnoredFiles(t *testing.T) { + files := []*File{ + {entry: &mockDirEntry{name: "modified.go"}, status: "M "}, + {entry: &mockDirEntry{name: "ignored.log"}, status: "I"}, + {entry: &mockDirEntry{name: "untracked.go"}, status: "??"}, + {entry: &mockDirEntry{name: ".git"}, status: "*"}, + {entry: &mockDirEntry{name: "added.go"}, status: "A "}, + {entry: &mockDirEntry{name: "added-modified.go"}, status: "AM"}, + {entry: &mockDirEntry{name: "dir-with-changes"}, status: "A ,M "}, + } + + // Filter files that need git log (mimicking the optimization) + var filesNeedingLog []*File + for _, file := range files { + if file.status == "I" || file.status == "??" || file.status == "*" { + continue + } + // Check if ALL statuses are additions (no history to look up) + allNew := true + for status := range strings.SplitSeq(file.status, ",") { + status = strings.TrimSpace(status) + if len(status) > 0 && status[0] != 'A' && status != "??" { + allNew = false + break + } + } + if !allNew { + filesNeedingLog = append(filesNeedingLog, file) + } + } + + // Should have 2 files: modified.go and dir-with-changes + // Skip: ignored, untracked, .git, purely added files + if len(filesNeedingLog) != 2 { + t.Errorf("Expected 2 files needing git log, got %d", len(filesNeedingLog)) + } + + expectedNames := map[string]bool{ + "modified.go": true, + "dir-with-changes": true, + } + + for _, f := range filesNeedingLog { + name := f.Name() + if !expectedNames[name] { + t.Errorf("Unexpected file needing git log: %s", name) + } + delete(expectedNames, name) + } + + if len(expectedNames) > 0 { + for name := range expectedNames { + t.Errorf("Expected file %s was not in files needing git log", name) + } + } +} + // the file's previous name. This is a regression test for a bug where renamed // files had no git log info because git log couldn't find history under the // new name. @@ -824,7 +931,7 @@ func TestRenamedFileGitInfo(t *testing.T) { tmpDir := t.TempDir() repoDir := tmpDir + "/repo" - if err := os.Mkdir(repoDir, 0755); err != nil { + if err := os.Mkdir(repoDir, 0o755); err != nil { t.Fatalf("Failed to create repo dir: %v", err) } @@ -839,7 +946,7 @@ func TestRenamedFileGitInfo(t *testing.T) { } // Create and commit a file - if err := os.WriteFile(repoDir+"/old-name.txt", []byte("hello world\n"), 0644); err != nil { + if err := os.WriteFile(repoDir+"/old-name.txt", []byte("hello world\n"), 0o644); err != nil { t.Fatalf("Failed to write file: %v", err) } if err := runCmd(repoDir, "git", "add", "old-name.txt"); err != nil { @@ -887,8 +994,8 @@ func TestRenamedFileGitInfo(t *testing.T) { fileStatus(gitData.status, files, curdir) // Run the streaming log — should find renamed files via their old name - if err := parseGitLogStreaming(files); err != nil { - t.Fatalf("parseGitLogStreaming failed: %v", err) + if err := parseGitLog(files); err != nil { + t.Fatalf("parseGitLog failed: %v", err) } // Find new-name.txt diff --git a/transcripts/41-changed-only-flag.html b/transcripts/41-changed-only-flag.html new file mode 100644 index 0000000..c7fd074 --- /dev/null +++ b/transcripts/41-changed-only-flag.html @@ -0,0 +1,4017 @@ + + + + + + Session Export + + + + + +
+ + +
+
+
+
+
+ +
+
+ + + + + + + + + + + + +