From 134ebd23766e22f526834df51b2bd17331efac7f Mon Sep 17 00:00:00 2001 From: Bill Mill Date: Tue, 26 May 2026 12:57:59 -0400 Subject: [PATCH 1/4] chore: bump to 6.2.1 --- main.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/main.go b/main.go index 7dd62d2..c272c9c 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 From 3ee081360ba939b113806c05c7b8f7d46adc6edb Mon Sep 17 00:00:00 2001 From: Bill Mill Date: Thu, 28 May 2026 14:42:43 -0400 Subject: [PATCH 2/4] feat: add --sort/-s flag to sort files by last modification date Sort files by git commit timestamp (descending, most recent first). Files without a date (ignored, untracked, .git) are placed at the bottom in alphabetical order. Uses the unix timestamp (%at) from git log for precise sub-day ordering when multiple files share the same date. --- main.go | 46 ++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 40 insertions(+), 6 deletions(-) diff --git a/main.go b/main.go index c272c9c..b03e1f5 100644 --- a/main.go +++ b/main.go @@ -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,31 @@ 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 { + slices.SortStableFunc(files, func(a, b *File) int { + // Files without dates go to the bottom + 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 + } + // Descending: newer first + if b.commitTime != a.commitTime { + return int(b.commitTime - a.commitTime) + } + // Same timestamp: fall back to alphabetical + return strings.Compare(strings.ToLower(a.Name()), strings.ToLower(b.Name())) + }) + } else { + slices.SortFunc(files, func(a, b *File) int { + return strings.Compare(strings.ToLower(a.Name()), strings.ToLower(b.Name())) + }) + } // Use default columns if not specified if len(formatColumns) == 0 { @@ -1077,7 +1108,7 @@ func parseGitLog(files []*File, timer *DebugTimer) error { "--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%ad%x00%aN%x00%aE%x00%s%x00%at%x00", "-n", HistoryLimit, "HEAD", "--", ".") @@ -1098,6 +1129,7 @@ func parseGitLog(files []*File, timer *DebugTimer) error { author string authorEmail string message string + timestamp int64 } // Track lines since last find for early exit. This counts lines of output @@ -1118,13 +1150,14 @@ func parseGitLog(files []*File, timer *DebugTimer) error { // Check if this is a commit metadata line (contains null bytes) if strings.Contains(line, "\x00") { parts := strings.Split(line, "\x00") - if len(parts) >= 6 { + if len(parts) >= 7 { 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.timestamp, _ = strconv.ParseInt(parts[6], 10, 64) } } else if currentCommit.hash != "" { // This is a filename line @@ -1152,6 +1185,7 @@ func parseGitLog(files []*File, timer *DebugTimer) error { file.hash = currentCommit.hash file.shortHash = currentCommit.shortHash file.lastModified = currentCommit.date + file.commitTime = currentCommit.timestamp file.author = currentCommit.author file.authorEmail = currentCommit.authorEmail file.message = currentCommit.message From c26ce5ce48eec51c4a979ad4c28bd9ba7b94d8dc Mon Sep 17 00:00:00 2001 From: Bill Mill Date: Thu, 28 May 2026 15:50:23 -0400 Subject: [PATCH 3/4] fix: derive display date from unix timestamp in UTC Instead of using git's --date=format (which uses the author's local timezone), we now format the display date from the unix timestamp in UTC. This avoids confusing situations where a commit made later could show an earlier date due to timezone differences. Also simplifies the git log format string by removing %ad. --- main.go | 17 +++++++---------- 1 file changed, 7 insertions(+), 10 deletions(-) diff --git a/main.go b/main.go index b03e1f5..23f9fdc 100644 --- a/main.go +++ b/main.go @@ -1107,8 +1107,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%at%x00", + "--format=%H%x00%h%x00%aN%x00%aE%x00%s%x00%at%x00", "-n", HistoryLimit, "HEAD", "--", ".") @@ -1125,7 +1124,6 @@ func parseGitLog(files []*File, timer *DebugTimer) error { var currentCommit struct { hash string shortHash string - date string author string authorEmail string message string @@ -1150,14 +1148,13 @@ func parseGitLog(files []*File, timer *DebugTimer) error { // Check if this is a commit metadata line (contains null bytes) if strings.Contains(line, "\x00") { parts := strings.Split(line, "\x00") - if len(parts) >= 7 { + 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.timestamp, _ = strconv.ParseInt(parts[6], 10, 64) + 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 @@ -1184,8 +1181,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 From 289ced2cc9556245e8c8ad92e08bf7fecff4491d Mon Sep 17 00:00:00 2001 From: Bill Mill Date: Thu, 28 May 2026 15:53:18 -0400 Subject: [PATCH 4/4] test: add unit tests for sort-by-date functionality Extract sortFilesByDate and sortFilesByName into named functions for testability. Add tests covering: - Descending date sort with undated files at the bottom - Same-timestamp fallback to alphabetical order - Undated files (ignored, untracked, .git) grouped at bottom - Case-insensitive alphabetical sort --- main.go | 50 ++++++++++++++++++++-------------- main_test.go | 77 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 106 insertions(+), 21 deletions(-) diff --git a/main.go b/main.go index 23f9fdc..90255d9 100644 --- a/main.go +++ b/main.go @@ -535,28 +535,9 @@ func run() int { // Sort files if sortByDate { - slices.SortStableFunc(files, func(a, b *File) int { - // Files without dates go to the bottom - 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 - } - // Descending: newer first - if b.commitTime != a.commitTime { - return int(b.commitTime - a.commitTime) - } - // Same timestamp: fall back to alphabetical - return strings.Compare(strings.ToLower(a.Name()), strings.ToLower(b.Name())) - }) + sortFilesByDate(files) } else { - slices.SortFunc(files, func(a, b *File) int { - return strings.Compare(strings.ToLower(a.Name()), strings.ToLower(b.Name())) - }) + sortFilesByName(files) } // Use default columns if not specified @@ -759,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) 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) + } + } +}