From 75c19754f9fd1a80915e275107306503f269fa67 Mon Sep 17 00:00:00 2001 From: Ashish Patel Date: Wed, 3 Jun 2026 11:49:30 +0530 Subject: [PATCH] feat: per-commit branch label and --project filter Resolve the branch each commit was reached from (git log --all --source, %S) and surface it everywhere a commit is listed: - model.Item gains a Branch field - collect: capture %S, strip refs/{heads,remotes/,tags}/ prefixes - render: commit markdown leads with the branch (linked to the commit) and keeps the repo as context, dropping the bare @hash; JSON gains a branch field - tui picker: new branch column, branch added to the filter haystack Add a --project flag that narrows the resolved repos to a single one by directory name (case-insensitive), erroring clearly if none match. --- cmd/commit-chronicle/main.go | 2 ++ internal/app/app.go | 23 +++++++++++++++++++++++ internal/collect/git.go | 35 +++++++++++++++++++++++++++++------ internal/model/item.go | 1 + internal/render/render.go | 12 +++++++++--- internal/tui/picker.go | 6 +++--- 6 files changed, 67 insertions(+), 12 deletions(-) diff --git a/cmd/commit-chronicle/main.go b/cmd/commit-chronicle/main.go index 32bc6bc..a6237e8 100644 --- a/cmd/commit-chronicle/main.go +++ b/cmd/commit-chronicle/main.go @@ -48,6 +48,7 @@ func parseFlags() (*app.Config, error) { fs.StringVar(&c.User, "user", "", "GitHub login for PR discovery (default: gh user)") fs.StringVar(&c.Repos, "repos", "", "comma-separated repo paths (overrides config)") fs.StringVar(&c.Root, "root", "", "comma-separated dirs to scan for git repos, e.g. ~/work") + fs.StringVar(&c.Project, "project", "", "limit to the repo with this name, e.g. saas-super-admin") fs.StringVar(&c.Out, "out", "", "output path (default: Downloads, timestamped)") fs.StringVar(&c.Format, "format", "md", "output format: md | json") fs.BoolVar(&c.NoEdit, "no-edit", false, "skip the editor step") @@ -90,6 +91,7 @@ OPTIONS: --user GitHub login for PR discovery (default: gh user) --repos a,b,c comma-separated repo paths (overrides config) --root ~/work comma-separated dirs to auto-discover git repos under + --project limit to the repo with this name, e.g. saas-super-admin --out output path (default: Downloads, timestamped) --format md|json output format (default: md) --all select everything (skip the picker) diff --git a/internal/app/app.go b/internal/app/app.go index ab5757d..fe5b160 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -22,6 +22,7 @@ import ( type Config struct { Since, From, To, Month, Date string Author, User, Repos, Root string + Project string // limit to the repo with this name Out, Format string NoEdit, All, Copy, NoPR bool Setup bool // force the guided first-run setup @@ -62,6 +63,15 @@ func Run(c Config) error { return err } + // --project narrows the resolved set to a single repo by name, so the + // worklog (and every commit's project/branch labelling) covers just it. + if c.Project != "" { + repos = filterByProject(repos, c.Project) + if len(repos) == 0 { + return fmt.Errorf("no configured repo named %q (matched against repo directory names)", c.Project) + } + } + author := c.Author if author == "" { author = gitConfigName(repos[0]) @@ -229,6 +239,19 @@ func resolveRange(c Config, interactive bool) (model.Range, error) { return model.Preset(model.PresetNames[idx]), nil } +// filterByProject keeps only the repos whose directory name matches project +// (case-insensitive), letting the user scope a worklog to one project. +func filterByProject(repos []string, project string) []string { + want := strings.ToLower(strings.TrimSpace(project)) + var out []string + for _, r := range repos { + if strings.ToLower(filepath.Base(r)) == want { + out = append(out, r) + } + } + return out +} + func splitCSV(s string) []string { if s == "" { return nil diff --git a/internal/collect/git.go b/internal/collect/git.go index 45ff7bc..417a8dc 100644 --- a/internal/collect/git.go +++ b/internal/collect/git.go @@ -54,11 +54,13 @@ func gitCommits(repos []string, author string, r model.Range) []model.Item { name := filepath.Base(repo) base := originURL(repo) + // --source tags each commit with the ref it was reached from, exposed via + // %S; with --all that's the branch (or remote/tag) carrying the commit. args := []string{ - "-C", repo, "log", "--all", "--no-merges", + "-C", repo, "log", "--all", "--source", "--no-merges", "--author=" + author, "--regexp-ignore-case", "--date=short", - "--pretty=format:%h" + fieldSep + "%H" + fieldSep + "%ad" + fieldSep + "%s", + "--pretty=format:%h" + fieldSep + "%H" + fieldSep + "%ad" + fieldSep + "%S" + fieldSep + "%s", } if r.Since != "" { args = append(args, "--since="+anchorMidnight(r.Since)) @@ -75,11 +77,11 @@ func gitCommits(repos []string, author string, r model.Range) []model.Item { if strings.TrimSpace(line) == "" { continue } - p := strings.SplitN(line, fieldSep, 4) - if len(p) != 4 { + p := strings.SplitN(line, fieldSep, 5) + if len(p) != 5 { continue } - if isNoiseSubject(p[3]) { + if isNoiseSubject(p[4]) { continue } url := "" @@ -94,13 +96,34 @@ func gitCommits(repos []string, author string, r model.Range) []model.Item { URL: url, Hash: p[1], ShortHash: p[0], - Title: model.CleanText(p[3]), + Branch: shortRef(p[3]), + Title: model.CleanText(p[4]), }) } } return items } +// shortRef turns a fully-qualified git ref (as emitted by %S under --source) +// into a bare branch name: refs/heads/x → x, refs/remotes/origin/x → x, +// refs/tags/x → x. Anything else is returned trimmed and unchanged. +func shortRef(ref string) string { + ref = strings.TrimSpace(ref) + switch { + case strings.HasPrefix(ref, "refs/heads/"): + return strings.TrimPrefix(ref, "refs/heads/") + case strings.HasPrefix(ref, "refs/remotes/"): + rest := strings.TrimPrefix(ref, "refs/remotes/") + if i := strings.IndexByte(rest, '/'); i >= 0 { + return rest[i+1:] // drop the remote name, keep the branch + } + return rest + case strings.HasPrefix(ref, "refs/tags/"): + return strings.TrimPrefix(ref, "refs/tags/") + } + return ref +} + // Preview returns `git show --stat` for a commit item (for the picker pane). func Preview(it model.Item) string { if it.Kind != model.KindCommit || it.Hash == "" { diff --git a/internal/model/item.go b/internal/model/item.go index bad7fe2..5dda110 100644 --- a/internal/model/item.go +++ b/internal/model/item.go @@ -36,6 +36,7 @@ type Item struct { // Commit-only Hash string ShortHash string + Branch string // branch the commit was reached from (git source ref) // PR/Review-only Number int diff --git a/internal/render/render.go b/internal/render/render.go index dc4392e..c4b29ee 100644 --- a/internal/render/render.go +++ b/internal/render/render.go @@ -98,8 +98,13 @@ func line(it model.Item) string { return fmt.Sprintf("- %s **%s** %s — %s (PR %s) · %s\n", icon, link(it.Ref()), it.Title, verdict, strings.ToLower(it.State), it.RepoName) default: // commit - return fmt.Sprintf("- %s _(%s)_\n", it.Title, - link(it.RepoName+"@"+it.ShortHash)) + // Lead with the branch (linked to the commit) and keep the repo as + // context; fall back to repo@hash when the branch is unknown. + meta := link(it.RepoName + "@" + it.ShortHash) + if it.Branch != "" { + meta = link(it.Branch) + " · " + it.RepoName + } + return fmt.Sprintf("- %s _(%s)_\n", it.Title, meta) } } @@ -122,6 +127,7 @@ type jsonItem struct { Kind string `json:"kind"` Date string `json:"date"` Repo string `json:"repo"` + Branch string `json:"branch,omitempty"` Title string `json:"title"` URL string `json:"url"` Hash string `json:"hash,omitempty"` @@ -135,7 +141,7 @@ func JSON(items []model.Item, _ Meta) string { out := make([]jsonItem, 0, len(items)) for _, it := range items { out = append(out, jsonItem{ - Kind: it.Tag(), Date: it.Date, Repo: it.RepoName, + Kind: it.Tag(), Date: it.Date, Repo: it.RepoName, Branch: it.Branch, Title: it.Title, URL: it.URL, Hash: it.Hash, Number: it.Number, State: it.State, ReviewState: it.ReviewState, }) diff --git a/internal/tui/picker.go b/internal/tui/picker.go index 3200d25..45033d9 100644 --- a/internal/tui/picker.go +++ b/internal/tui/picker.go @@ -84,7 +84,7 @@ func (m *pickerModel) applyFilter() { tokens := strings.Fields(q) m.filtered = m.filtered[:0] for i, it := range m.items { - hay := strings.ToLower(it.Tag() + " " + it.Date + " " + it.RepoName + " " + it.Ref() + " " + it.Title) + hay := strings.ToLower(it.Tag() + " " + it.Date + " " + it.RepoName + " " + it.Branch + " " + it.Ref() + " " + it.Title) ok := true for _, t := range tokens { if !strings.Contains(hay, t) { @@ -254,8 +254,8 @@ func (m pickerModel) View() string { box = "[x]" } // Plain text first so truncation counts real characters. - line := fmt.Sprintf("%s %-7s %s %-16s %-8s %s", - box, it.Tag(), it.Date, truncate(it.RepoName, 16), it.Ref(), it.Title) + line := fmt.Sprintf("%s %-7s %s %-16s %-8s %-14s %s", + box, it.Tag(), it.Date, truncate(it.RepoName, 16), it.Ref(), truncate(it.Branch, 14), it.Title) line = truncate(line, listW-3) switch { case i == m.cursor: