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) {