From d97269aa67278d9ad7b40e7b8e452d21db1e97bd Mon Sep 17 00:00:00 2001 From: Mo Valipour Date: Fri, 23 Oct 2026 10:57:44 +0100 Subject: [PATCH] Add git stack log short and long, and --stack git stack log is the only view, and it waits on GitHub for PR state even when all you want is the shape of the stacks. log short draws the same tree with branch names only and reads nothing but local git, so it never touches the network. log long names the full tree explicitly. stack.log.short makes plain log and bare git stack short for anyone who wants that by default. --stack (-s) limits the tree to the current branch's stack, which keeps a repo with many stacks readable. On a trunk it shows every stack on it. On a branch that is in no stack it shows them all and says so. --- README.md | 18 +++++++++ cmd/log.go | 60 +++++++++++++++++++++++++---- cmd/log_long.go | 21 ++++++++++ cmd/log_short.go | 21 ++++++++++ cmd/log_test.go | 80 +++++++++++++++++++++++++++++++++++++++ cmd/root.go | 2 +- docs/mcp.md | 2 +- pkg/config/config.go | 12 +++++- pkg/config/config_test.go | 5 +++ pkg/mcp/server_test.go | 21 ++++++++++ pkg/mcp/tools.go | 7 +++- pkg/ui/log.go | 15 +++++++- pkg/ui/log_test.go | 19 ++++++++++ 13 files changed, 271 insertions(+), 12 deletions(-) create mode 100644 cmd/log_long.go create mode 100644 cmd/log_short.go diff --git a/README.md b/README.md index 51f6da1..cd6a21a 100644 --- a/README.md +++ b/README.md @@ -742,6 +742,23 @@ bottom, each stack rising out of it and the current branch marked. Under each br PR state and titles come from a local cache that's refreshed when it goes stale, and the commits come from local git, so it stays quick. +`git stack log short` draws the same tree with just the branch names, one line each. It only reads +local git, so it never waits on GitHub. To make that what `git stack` and `git stack log` show, +`git config --global stack.log.short true`; `git stack log long` still gets you the full tree. + +``` +❯ git stack log short +○ billing-webhook-retries +● billing-webhook-schema +│ ○ fix-login-timeout +├─┘ +■ main +``` + +Flags (on `log`, `log short` and `log long`): +- `-s` / `--stack` only show the current branch's stack (on a trunk, every stack on it). On a + branch that's in no stack it shows them all and says so. + ### `git stack merge [branch]` There's no `git merge` alias for this one, for obvious reasons. @@ -798,6 +815,7 @@ Everything is plain `git config`, so set it globally or per repo as you like. | `stack.sync.prune` | `always` | what `sync` does with branches whose PRs merged or closed; `ask` on a terminal, or `never` | | `stack.merge.method` | (repo default) | `merge`, `squash` or `rebase` for `git stack merge` | | `stack.cacheTTL` | `5m` | how long PR state is cached for | +| `stack.log.short` | `false` | `true` makes `git stack` and `git stack log` show only branch names, like `git stack log short` | | `stack.ai.auto` | `false` | `true` makes `create` and `submit` behave as if `--ai` was passed; `--no-ai` still wins | | `stack.ai.command` | `claude` | the Claude Code binary used for `--ai` | | `stack.ai.model` | `haiku` | model alias passed to `--model` | diff --git a/cmd/log.go b/cmd/log.go index 622116f..36792d7 100644 --- a/cmd/log.go +++ b/cmd/log.go @@ -11,7 +11,8 @@ import ( ) func newLogCmd(c *cli) *cobra.Command { - return &cobra.Command{ + var currentStack bool + cmd := &cobra.Command{ Use: "log", Short: "Show every stack as a tree with PR state", Long: `Shows every stack in the repo as a tree; trunk at the bottom, each stack rising @@ -22,28 +23,73 @@ since the last push, so its PR is behind), and its own commits, newest first and titles come from a local cache and are refreshed when they go stale; the commits come from local git. +--stack shows only the current branch's stack (every stack on it, on a trunk). +git stack log short shows just the branch names and git stack log long the +full tree; plain log and bare git stack show the full tree unless +git config stack.log.short is true. + Bare git stack runs this.`, Args: cobra.NoArgs, ValidArgsFunction: completeNothing, RunE: func(cmd *cobra.Command, _ []string) error { - return showLog(cmd.Context(), c) + return showLog(cmd.Context(), c, logMode{currentStack: currentStack}) }, } + cmd.PersistentFlags().BoolVarP(¤tStack, "stack", "s", false, "only show the current branch's stack") + cmd.AddCommand(newLogShortCmd(c, ¤tStack), newLogLongCmd(c, ¤tStack)) + return cmd } -// showLog renders the stack tree; it backs both `git stack log` and a bare -// `git stack`. -func showLog(ctx context.Context, c *cli) error { +// logFormat is how much of each branch the log shows. +type logFormat int + +const ( + // logDefault follows stack.log.short. + logDefault logFormat = iota + logShort + logLong +) + +// logMode picks what showLog renders. +type logMode struct { + format logFormat + // currentStack shows only the current branch's stack. + currentStack bool +} + +// showLog renders the stack tree; it backs `git stack log` (and its short +// and long), and a bare `git stack`. +func showLog(ctx context.Context, c *cli, m logMode) error { a, repo, err := c.app(ctx) if err != nil { return err } - v, err := a.View(ctx, repo, app.ViewOptions{PRs: app.PRsFresh, Commits: true}) + short := m.format == logShort + if m.format == logDefault { + cfg, err := c.runtime().Config(ctx) + if err != nil { + return err + } + short = cfg.LogShort + } + o := app.ViewOptions{CurrentStackOnly: m.currentStack, PRs: app.PRsFresh, Commits: true} + if short { + // Names only: no PR state, commits or restack checks to work out. + o = app.ViewOptions{CurrentStackOnly: m.currentStack, SkipRestackCheck: true} + } + v, err := a.View(ctx, repo, o) if err != nil { return err } rep := c.report() - out := ui.RenderLog(v, ui.LogOptions{Styles: rep.Styles(), Now: time.Now(), Links: rep.Links(), PRURL: rep.PRURL, Width: rep.Width()}) + if m.currentStack && len(v.Graph.Stacks) > 0 && !v.Graph.Tracked(v.Current) && !v.Graph.IsTrunk(v.Current) { + if v.Current == "" { + rep.Warn("Not on a branch, so showing every stack") + } else { + rep.Warn("%s is in no stack, so showing every stack", rep.Branch(v.Current)) + } + } + out := ui.RenderLog(v, ui.LogOptions{Styles: rep.Styles(), Now: time.Now(), Links: rep.Links(), PRURL: rep.PRURL, Width: rep.Width(), Short: short}) if out == "" { // Not even a trunk: no stacks and no default branch to show. rep.Warn("No stacks yet; check out your trunk and run git stack create to start one") diff --git a/cmd/log_long.go b/cmd/log_long.go new file mode 100644 index 0000000..3553ff2 --- /dev/null +++ b/cmd/log_long.go @@ -0,0 +1,21 @@ +package cmd + +import ( + "github.com/spf13/cobra" +) + +// newLogLongCmd is `git stack log long`; currentStack is log's persistent +// --stack flag. +func newLogLongCmd(c *cli, currentStack *bool) *cobra.Command { + return &cobra.Command{ + Use: "long", + Short: "Show every stack as a tree with PR state and commits", + Long: `Shows the full tree, as git stack log does without stack.log.short: under each +branch its PR, state and own commits.`, + Args: cobra.NoArgs, + ValidArgsFunction: completeNothing, + RunE: func(cmd *cobra.Command, _ []string) error { + return showLog(cmd.Context(), c, logMode{format: logLong, currentStack: *currentStack}) + }, + } +} diff --git a/cmd/log_short.go b/cmd/log_short.go new file mode 100644 index 0000000..cc27d80 --- /dev/null +++ b/cmd/log_short.go @@ -0,0 +1,21 @@ +package cmd + +import ( + "github.com/spf13/cobra" +) + +// newLogShortCmd is `git stack log short`; currentStack is log's persistent +// --stack flag. +func newLogShortCmd(c *cli, currentStack *bool) *cobra.Command { + return &cobra.Command{ + Use: "short", + Short: "Show every stack as a tree of branch names", + Long: `Shows the same tree as git stack log with only the branch names; no PRs, state +or commits. It reads nothing but local git, so it never touches the network.`, + Args: cobra.NoArgs, + ValidArgsFunction: completeNothing, + RunE: func(cmd *cobra.Command, _ []string) error { + return showLog(cmd.Context(), c, logMode{format: logShort, currentStack: *currentStack}) + }, + } +} diff --git a/cmd/log_test.go b/cmd/log_test.go index db7e0c3..9495713 100644 --- a/cmd/log_test.go +++ b/cmd/log_test.go @@ -2,6 +2,8 @@ package cmd import ( "context" + "os" + "path/filepath" "strings" "testing" @@ -65,3 +67,81 @@ func TestLogWithNoStacksShowsTrunk(t *testing.T) { t.Errorf("no warning when the trunk is shown:\n%s", errOut) } } + +// TestLogShortAndStack checks that log short shows only the branch names and +// that --stack keeps to the current branch's stack, on log and log short. +func TestLogShortAndStack(t *testing.T) { + dir := stackRepo(t) // main -> a (#41) -> b -> c, on b + gittest.Run(t, dir, "switch", "-q", "-c", "other", "main") + gittest.Commit(t, dir, "other.txt", "other", "other") + gittest.Run(t, dir, "switch", "-q", "b") + gitDir := gittest.Run(t, dir, "rev-parse", "--absolute-git-dir") + meta := `{"schemaVersion":1,"stacks":[{"trunk":{"branch":"main"},"branches":[ + {"branch":"a","pullRequest":{"number":41,"url":"https://example.com/41"}},{"branch":"b"},{"branch":"c"}]}, + {"trunk":{"branch":"main"},"branches":[{"branch":"other"}]}]}` + if err := os.WriteFile(filepath.Join(gitDir, "gh-stack"), []byte(meta), 0o644); err != nil { + t.Fatal(err) + } + f := fakeGh(t, dir) + + out, errOut, err := runWith(t, f, "--cwd", dir, "log", "short") + if err != nil { + t.Fatalf("log short: %v\n%s", err, errOut) + } + if want := "○ c\n● b\n○ a\n│ ○ other\n├─┘\n■ main\n"; out != want { + t.Errorf("log short =\n%s\nwant\n%s", out, want) + } + + for _, args := range [][]string{{"log", "short", "--stack"}, {"log", "-s", "short"}} { + out, errOut, err = runWith(t, f, append([]string{"--cwd", dir}, args...)...) + if err != nil { + t.Fatalf("%v: %v\n%s", args, err, errOut) + } + if want := "○ c\n● b\n○ a\n■ main\n"; out != want { + t.Errorf("%v =\n%s\nwant\n%s", args, out, want) + } + } + + out, errOut, err = runWith(t, f, "--cwd", dir, "log", "-s") + if err != nil { + t.Fatalf("log -s: %v\n%s", err, errOut) + } + if !strings.Contains(out, "#41") || strings.Contains(out, "other") { + t.Errorf("log -s should show the full current stack only:\n%s", out) + } + + // From the trunk, every stack on it. + gittest.Run(t, dir, "switch", "-q", "main") + if out, _, _ = runWith(t, f, "--cwd", dir, "log", "short", "-s"); !strings.Contains(out, "other") || !strings.Contains(out, "○ c") { + t.Errorf("log short -s on trunk shows its stacks:\n%s", out) + } + + // An untracked branch has no stack, so everything is shown and we say so. + gittest.Run(t, dir, "switch", "-q", "loose") + out, errOut, err = runWith(t, f, "--cwd", dir, "log", "short", "-s") + if err != nil || !strings.Contains(out, "other") || !strings.Contains(errOut, "loose is in no stack") { + t.Errorf("log short -s on an untracked branch:\n%s\n%s (%v)", out, errOut, err) + } +} + +// TestLogShortConfig checks that stack.log.short makes log and bare git stack +// short, and that log long still shows everything. +func TestLogShortConfig(t *testing.T) { + dir := stackRepo(t) + gittest.Run(t, dir, "config", "stack.log.short", "true") + f := fakeGh(t, dir) + + short := "○ c\n● b\n○ a\n■ main\n" + for _, args := range [][]string{nil, {"log"}, {"log", "short"}} { + out, errOut, err := runWith(t, f, append([]string{"--cwd", dir}, args...)...) + if err != nil || out != short { + t.Errorf("%v with stack.log.short =\n%s\nwant\n%s (%v %s)", args, out, short, err, errOut) + } + } + for _, args := range [][]string{{"log", "long"}, {"log", "long", "-s"}} { + out, errOut, err := runWith(t, f, append([]string{"--cwd", dir}, args...)...) + if err != nil || !strings.Contains(out, "#41") || !strings.Contains(out, "│ • ") { + t.Errorf("%v should show the full log:\n%s (%v %s)", args, out, err, errOut) + } + } +} diff --git a/cmd/root.go b/cmd/root.go index c8b17ca..1fc7746 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -296,7 +296,7 @@ func newRootCmd(c *cli) *cobra.Command { SilenceErrors: true, // A bare `git stack` shows the stacks, like `gt log`. RunE: func(cmd *cobra.Command, _ []string) error { - return showLog(cmd.Context(), c) + return showLog(cmd.Context(), c, logMode{}) }, PersistentPreRun: func(cmd *cobra.Command, _ []string) { if c.globals.Cwd != "" { diff --git a/docs/mcp.md b/docs/mcp.md index 6fad53a..eb2c2b5 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -19,7 +19,7 @@ path is resolved with `git rev-parse`; anything outside a repository returns `no | Tool | Purpose | Key inputs | Annotations | |---|---|---|---| -| `stack_view` | Tree of every stack: branches, parents, PR number/state/URL/title, each branch's own `commits`, `needs_restack`, `needs_push`, current branch, untracked branches | `include_untracked`, `fresh` (default true) | read-only, open-world | +| `stack_view` | Tree of every stack: branches, parents, PR number/state/URL/title, each branch's own `commits`, `needs_restack`, `needs_push`, current branch, untracked branches | `include_untracked`, `current_stack` (only the current branch's stack), `fresh` (default true) | read-only, open-world | | `stack_create` | New branch on top of the current one (from trunk: new stack), commit staged changes; with `insert`, right above the current branch even mid-stack, restacking the branches above onto it (the next `stack_submit` regroups the stack on GitHub; a conflict is finished with `stack_continue`, and `stack_abort` puts those branches back but keeps the new branch) | `message` (required), `branch`, `staging` = `all`/`update`/`none`, `use_ai`, `insert` | destructive | | `stack_modify` | Amend the current branch (or `mode: commit`) and restack everything above | `mode`, `staging`, `message`, `continue`, `abort` | destructive | | `stack_absorb` | Fold each staged hunk into the commit of the current stack that last changed those lines, rewrite it and everything above, then restack what sits above the current branch; the result's `plan` lists every hunk's destination and what stays staged | `staging` = `all`/`update`/`none`, `dry_run` | destructive | diff --git a/pkg/config/config.go b/pkg/config/config.go index 9fc903c..4f1b216 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -22,6 +22,7 @@ const ( KeySubmitDefault = "stack.submit.default" KeySyncPrune = "stack.sync.prune" KeyMergeMethod = "stack.merge.method" + KeyLogShort = "stack.log.short" KeyManagedAliases = "stack.managedAliases" KeyManagedFiles = "stack.managedFiles" ) @@ -57,7 +58,10 @@ type Config struct { SyncPrune string // MergeMethod is merge, squash or rebase; empty leaves it to the // repository's settings. - MergeMethod string + MergeMethod string + // LogShort makes git stack and git stack log show only branch names, + // as git stack log short does. + LogShort bool ManagedAliases []string ManagedFiles []string } @@ -138,6 +142,12 @@ func FromEntries(entries []git.ConfigEntry) (*Config, error) { default: return nil, fmt.Errorf("%s: %q is not one of merge, squash, rebase", KeyMergeMethod, v) } + case strings.ToLower(KeyLogShort): + b, err := parseBool(v) + if err != nil { + return nil, fmt.Errorf("%s: %w", KeyLogShort, err) + } + c.LogShort = b case strings.ToLower(KeyManagedAliases): c.ManagedAliases = append(c.ManagedAliases, v) case strings.ToLower(KeyManagedFiles): diff --git a/pkg/config/config_test.go b/pkg/config/config_test.go index 90fe265..88fbf05 100644 --- a/pkg/config/config_test.go +++ b/pkg/config/config_test.go @@ -21,6 +21,7 @@ func TestFromEntries(t *testing.T) { {Key: "stack.ai.auto", Value: "yes"}, {Key: "stack.cachettl", Value: "30s"}, {Key: "stack.submit.default", Value: "publish"}, + {Key: "stack.log.short", Value: "true"}, {Key: "stack.managedaliases", Value: "c"}, {Key: "stack.managedaliases", Value: "co"}, {Key: "stack.unknownkey", Value: "ignored"}, @@ -37,6 +38,9 @@ func TestFromEntries(t *testing.T) { if !c.AIAuto { t.Errorf("stack.ai.auto yes should enable auto AI: %+v", c) } + if !c.LogShort { + t.Errorf("stack.log.short true should make the log short: %+v", c) + } if c.SubmitDefault != config.SubmitPublish || !slices.Equal(c.ManagedAliases, []string{"c", "co"}) { t.Errorf("submit/aliases: %+v", c) } @@ -48,6 +52,7 @@ func TestFromEntriesRejectsBadValues(t *testing.T) { {Key: "stack.cachettl", Value: "5"}, {Key: "stack.submit.default", Value: "maybe"}, {Key: "stack.ai.auto", Value: "sometimes"}, + {Key: "stack.log.short", Value: "brief"}, } { _, err := config.FromEntries([]git.ConfigEntry{e}) if err == nil || !strings.Contains(err.Error(), "stack.") { diff --git a/pkg/mcp/server_test.go b/pkg/mcp/server_test.go index 1bcb9ac..3cadd65 100644 --- a/pkg/mcp/server_test.go +++ b/pkg/mcp/server_test.go @@ -284,6 +284,27 @@ func TestViewCommits(t *testing.T) { } } +func TestViewCurrentStack(t *testing.T) { + h := newHarness(t) + defer h.assertNoStdout() + gittest.Run(t, h.dir, "branch", "x", "main") + h.backend.SetGraph(stack.NewGraph([]stack.Stack{ + {Trunk: "main", Branches: []stack.Branch{{Name: "a"}, {Name: "b"}}}, + {Trunk: "main", Branches: []stack.Branch{{Name: "x"}}}, + })) + + var v viewOutput + h.call("stack_view", map[string]any{"fresh": false}, &v) + if len(v.Stacks) != 2 { + t.Fatalf("every stack without current_stack: %+v", v.Stacks) + } + v = viewOutput{} + h.call("stack_view", map[string]any{"fresh": false, "current_stack": true}, &v) + if len(v.Stacks) != 1 || v.Stacks[0].Branches[0].Name != "a" || !slices.Equal(v.Trunks, []string{"main"}) { + t.Errorf("current_stack should keep only b's stack: %+v", v) + } +} + func TestViewCreateModifyNavigate(t *testing.T) { h := newHarness(t) defer h.assertNoStdout() diff --git a/pkg/mcp/tools.go b/pkg/mcp/tools.go index 690687b..3491590 100644 --- a/pkg/mcp/tools.go +++ b/pkg/mcp/tools.go @@ -27,6 +27,7 @@ type repoArg struct { type viewInput struct { repoArg IncludeUntracked bool `json:"include_untracked,omitempty" jsonschema:"also list local branches that are in no stack"` + CurrentStack bool `json:"current_stack,omitempty" jsonschema:"only the current branch's stack and its trunk (from a trunk, every stack on it); every stack when the current branch is in none"` Fresh *bool `json:"fresh,omitempty" jsonschema:"refresh pull request state from GitHub when the cache is stale (default true); false never touches the network"` } @@ -78,7 +79,7 @@ func (s *Server) view(ctx context.Context, req *mcp.CallToolRequest, in viewInpu if in.Fresh != nil && !*in.Fresh { mode = app.PRsCached } - v, err := a.View(ctx, repo, app.ViewOptions{IncludeUntracked: in.IncludeUntracked, PRs: mode, Commits: true}) + v, err := a.View(ctx, repo, app.ViewOptions{IncludeUntracked: in.IncludeUntracked, CurrentStackOnly: in.CurrentStack, PRs: mode, Commits: true}) if err != nil { return nil, viewOutput{}, wrapErr(err) } @@ -93,6 +94,10 @@ func (s *Server) view(ctx context.Context, req *mcp.CallToolRequest, in viewInpu } } for _, st := range v.Graph.Stacks { + if r, ok := rowsByName[st.Bottom()]; !ok || !r.Tracked { + // Left out of the view (current_stack). + continue + } vs := viewStack{Trunk: st.Trunk, Number: st.Number, Branches: []viewBranch{}} parent := st.Trunk for _, b := range st.Branches { diff --git a/pkg/ui/log.go b/pkg/ui/log.go index ec60439..138b92b 100644 --- a/pkg/ui/log.go +++ b/pkg/ui/log.go @@ -26,6 +26,8 @@ type LogOptions struct { // would run past it is cut short with "…". 0 never cuts (not a // terminal). Width int + // Short shows only the branch names, with no PR, state or commits. + Short bool } // RenderLog renders every stack as a tree: trunk at the bottom, each stack a @@ -55,6 +57,14 @@ type LogOptions struct { // ├─┘ // ■ main // +// Short leaves just the branch names, one line each: +// +// ● feat-b +// ○ feat-a +// │ ○ hotfix +// ├─┘ +// ■ main +// // A trunk with no stacks is still drawn, so an empty repository shows where a // stack would start. Untracked branches are not shown. It returns "" when // there is no trunk at all. @@ -96,6 +106,9 @@ func RenderLog(v *app.View, o LogOptions) string { for i := len(s) - 1; i >= 0; i-- { r := s[i] b.WriteString(prefix + logMarker(r, st) + " " + logName(r, st) + "\n") + if o.Short { + continue + } pr, title := logPR(r, o) b.WriteString(FitLine(gutter+" "+pr, title, o.Width) + "\n") if meta := logMeta(r, o); meta != "" { @@ -119,7 +132,7 @@ func RenderLog(v *app.View, o LogOptions) string { b.WriteString(st.Prefix.Render(join) + "\n") } b.WriteString(logMarker(g.trunk, st) + " " + logName(g.trunk, st)) - if age := RelativeTime(g.trunk.LastCommit, o.Now); age != "" { + if age := RelativeTime(g.trunk.LastCommit, o.Now); age != "" && !o.Short { b.WriteString(" " + st.Muted.Render(age)) } b.WriteString("\n") diff --git a/pkg/ui/log_test.go b/pkg/ui/log_test.go index 277d395..caaea52 100644 --- a/pkg/ui/log_test.go +++ b/pkg/ui/log_test.go @@ -74,6 +74,25 @@ func TestRenderLogPlain(t *testing.T) { } } +func TestRenderLogShort(t *testing.T) { + out := RenderLog(&app.View{Rows: logRows()}, LogOptions{Now: now, Short: true}) + want := strings.Join([]string{ + "○ feat/ui-tests", + "● feat/ui", + "○ feat/api", + "│ ○ fix/typo", + "├─┘", + "■ main", + "", + "○ hotfix", + "■ release", + "", + }, "\n") + if out != want { + t.Errorf("RenderLog short =\n%s\nwant\n%s", out, want) + } +} + // TestRenderLogBranch covers the block under one branch: the PR and title // line, the state line and the commit list. func TestRenderLogBranch(t *testing.T) {