diff --git a/main.go b/main.go index 7dd62d2..90255d9 100644 --- a/main.go +++ b/main.go @@ -17,7 +17,7 @@ import ( "time" ) -const VERSION = "6.2.0" +const VERSION = "6.2.1" // HistoryLimit is the maximum number of commits to review; we'll exit if we // find all files before it; if a file was more than this many commits ago we @@ -41,6 +41,7 @@ type File struct { hash string // full hash shortHash string // Git's abbreviated hash (length varies by repo size) lastModified string + commitTime int64 // unix timestamp of the commit for precise sorting message string isDir bool isExe bool @@ -321,6 +322,11 @@ OPTIONS filters out unmodified tracked files and shows only files with status indicators (modified, added, deleted, renamed, etc.) + -s, --sort + Sort files by last git modification date, most recent first. + Files without a date (ignored, untracked, .git) are placed at + the bottom in alphabetical order. + --debug Print debug timing information to stderr, including which file took the longest to find in git history and warnings for files @@ -340,6 +346,7 @@ func run() int { monoHash := false nerdFont := false changedOnly := false + sortByDate := false debug := false var formatColumns []Column for len(argv) > 0 { @@ -360,6 +367,9 @@ func run() int { } else if argv[0] == "--changed-only" || argv[0] == "-c" { changedOnly = true argv = argv[1:] + } else if argv[0] == "--sort" || argv[0] == "-s" { + sortByDate = true + argv = argv[1:] } else if strings.HasPrefix(argv[0], "--diffWidth") { if len(argv) == 1 { if strings.Contains(argv[0], "=") { @@ -523,10 +533,12 @@ func run() int { } } - // Sort files by name - slices.SortFunc(files, func(a, b *File) int { - return strings.Compare(strings.ToLower(a.Name()), strings.ToLower(b.Name())) - }) + // Sort files + if sortByDate { + sortFilesByDate(files) + } else { + sortFilesByName(files) + } // Use default columns if not specified if len(formatColumns) == 0 { @@ -728,6 +740,33 @@ func makeDiffGraph(file *File, width int) string { RESET) } +// sortFilesByDate sorts files by commit timestamp descending (most recent first). +// Files without a timestamp (commitTime == 0) go to the bottom, sorted alphabetically. +func sortFilesByDate(files []*File) { + slices.SortStableFunc(files, func(a, b *File) int { + if a.commitTime == 0 && b.commitTime == 0 { + return strings.Compare(strings.ToLower(a.Name()), strings.ToLower(b.Name())) + } + if a.commitTime == 0 { + return 1 + } + if b.commitTime == 0 { + return -1 + } + if b.commitTime != a.commitTime { + return int(b.commitTime - a.commitTime) + } + return strings.Compare(strings.ToLower(a.Name()), strings.ToLower(b.Name())) + }) +} + +// sortFilesByName sorts files alphabetically by name (case-insensitive). +func sortFilesByName(files []*File) { + slices.SortFunc(files, func(a, b *File) int { + return strings.Compare(strings.ToLower(a.Name()), strings.ToLower(b.Name())) + }) +} + func showColumns(out io.Writer, maxWidth int, files []*File, rctx *RenderContext, columns []Column) { // Calculate max widths for each column colWidths := calculateColumnWidths(files, columns, rctx) @@ -1076,8 +1115,7 @@ func parseGitLog(files []*File, timer *DebugTimer) error { cmd := exec.Command("git", "-c", "core.fsmonitor=false", "log", "--name-only", "--relative", - "--date=format:%Y-%m-%d", - "--format=%H%x00%h%x00%ad%x00%aN%x00%aE%x00%s%x00", + "--format=%H%x00%h%x00%aN%x00%aE%x00%s%x00%at%x00", "-n", HistoryLimit, "HEAD", "--", ".") @@ -1094,10 +1132,10 @@ func parseGitLog(files []*File, timer *DebugTimer) error { var currentCommit struct { hash string shortHash string - date string author string authorEmail string message string + timestamp int64 } // Track lines since last find for early exit. This counts lines of output @@ -1121,10 +1159,10 @@ func parseGitLog(files []*File, timer *DebugTimer) error { if len(parts) >= 6 { currentCommit.hash = parts[0] currentCommit.shortHash = parts[1] - currentCommit.date = parts[2] - currentCommit.author = parts[3] - currentCommit.authorEmail = parts[4] - currentCommit.message = parts[5] + currentCommit.author = parts[2] + currentCommit.authorEmail = parts[3] + currentCommit.message = parts[4] + currentCommit.timestamp, _ = strconv.ParseInt(parts[5], 10, 64) } } else if currentCommit.hash != "" { // This is a filename line @@ -1151,7 +1189,8 @@ func parseGitLog(files []*File, timer *DebugTimer) error { // Found it! Populate the file info file.hash = currentCommit.hash file.shortHash = currentCommit.shortHash - file.lastModified = currentCommit.date + file.commitTime = currentCommit.timestamp + file.lastModified = time.Unix(currentCommit.timestamp, 0).UTC().Format("2006-01-02") file.author = currentCommit.author file.authorEmail = currentCommit.authorEmail file.message = currentCommit.message diff --git a/main_test.go b/main_test.go index 85c98e7..81d5e7b 100644 --- a/main_test.go +++ b/main_test.go @@ -1405,3 +1405,80 @@ func TestSubdirectoryTrackedFiles(t *testing.T) { t.Errorf("node_modules should be marked as ignored (I), got %q", status) } } + +func TestSortFilesByDate(t *testing.T) { + files := []*File{ + {name: "old.go", commitTime: 1000}, + {name: "new.go", commitTime: 3000}, + {name: "mid.go", commitTime: 2000}, + {name: "ignored.go", status: "I", commitTime: 0}, + {name: "untracked.go", status: "??", commitTime: 0}, + {name: ".git", status: "*", commitTime: 0}, + } + + sortFilesByDate(files) + + // Files with timestamps should be sorted descending + expectedOrder := []string{"new.go", "mid.go", "old.go", ".git", "ignored.go", "untracked.go"} + for i, expected := range expectedOrder { + if files[i].Name() != expected { + t.Errorf("position %d: got %q, want %q", i, files[i].Name(), expected) + } + } +} + +func TestSortFilesByDateSameTimestamp(t *testing.T) { + // When timestamps are equal, files should be sorted alphabetically + files := []*File{ + {name: "zebra.go", commitTime: 1000}, + {name: "alpha.go", commitTime: 1000}, + {name: "middle.go", commitTime: 1000}, + } + + sortFilesByDate(files) + + expectedOrder := []string{"alpha.go", "middle.go", "zebra.go"} + for i, expected := range expectedOrder { + if files[i].Name() != expected { + t.Errorf("position %d: got %q, want %q", i, files[i].Name(), expected) + } + } +} + +func TestSortFilesByDateUndatedAtBottom(t *testing.T) { + // All undated files should appear after all dated files + files := []*File{ + {name: "untracked.go", status: "??", commitTime: 0}, + {name: "dated.go", commitTime: 500}, + {name: "ignored.go", status: "I", commitTime: 0}, + {name: "also-dated.go", commitTime: 100}, + } + + sortFilesByDate(files) + + // Dated files first (descending), then undated (alphabetical) + expectedOrder := []string{"dated.go", "also-dated.go", "ignored.go", "untracked.go"} + for i, expected := range expectedOrder { + if files[i].Name() != expected { + t.Errorf("position %d: got %q, want %q", i, files[i].Name(), expected) + } + } +} + +func TestSortFilesByName(t *testing.T) { + files := []*File{ + {name: "Zebra.go"}, + {name: "alpha.go"}, + {name: "Beta.go"}, + } + + sortFilesByName(files) + + // Case-insensitive alphabetical + expectedOrder := []string{"alpha.go", "Beta.go", "Zebra.go"} + for i, expected := range expectedOrder { + if files[i].Name() != expected { + t.Errorf("position %d: got %q, want %q", i, files[i].Name(), expected) + } + } +}