Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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` |
Expand Down
60 changes: 53 additions & 7 deletions cmd/log.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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(&currentStack, "stack", "s", false, "only show the current branch's stack")
cmd.AddCommand(newLogShortCmd(c, &currentStack), newLogLongCmd(c, &currentStack))
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")
Expand Down
21 changes: 21 additions & 0 deletions cmd/log_long.go
Original file line number Diff line number Diff line change
@@ -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})
},
}
}
21 changes: 21 additions & 0 deletions cmd/log_short.go
Original file line number Diff line number Diff line change
@@ -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})
},
}
}
80 changes: 80 additions & 0 deletions cmd/log_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ package cmd

import (
"context"
"os"
"path/filepath"
"strings"
"testing"

Expand Down Expand Up @@ -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)
}
}
}
2 changes: 1 addition & 1 deletion cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 != "" {
Expand Down
2 changes: 1 addition & 1 deletion docs/mcp.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
12 changes: 11 additions & 1 deletion pkg/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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):
Expand Down
5 changes: 5 additions & 0 deletions pkg/config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"},
Expand All @@ -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)
}
Expand All @@ -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.") {
Expand Down
21 changes: 21 additions & 0 deletions pkg/mcp/server_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
7 changes: 6 additions & 1 deletion pkg/mcp/tools.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
}

Expand Down Expand Up @@ -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)
}
Expand All @@ -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 {
Expand Down
Loading
Loading