From 3721e98ec7442f1703fad911ea79c9507dfd1144 Mon Sep 17 00:00:00 2001 From: Mo Valipour Date: Wed, 28 Oct 2026 18:16:20 +0000 Subject: [PATCH 1/2] Mark branches that need a restack in log short The names-only tree hid the one thing a stack view is most useful for: whether a branch has fallen behind its parent. Short now appends "(needs restack)" to those branches. The check only reads local git, so short still never touches the network and stays quick. The restack check was skipped for short before. It is now run for it as well, and the README, help text and rendering tests are updated to match. --- README.md | 7 ++++--- cmd/log.go | 4 ++-- cmd/log_short.go | 5 +++-- cmd/log_test.go | 11 +++++++++++ pkg/ui/log.go | 15 +++++++++++---- pkg/ui/log_test.go | 2 +- 6 files changed, 32 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index fe38163..2c3f8df 100644 --- a/README.md +++ b/README.md @@ -747,13 +747,14 @@ 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 stack log short` draws the same tree with just the branch names, one line each, and +`(needs restack)` after any branch whose parent has moved on. 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-retries (needs restack) ● billing-webhook-schema │ ○ fix-login-timeout ├─┘ diff --git a/cmd/log.go b/cmd/log.go index 36792d7..4bdc7d1 100644 --- a/cmd/log.go +++ b/cmd/log.go @@ -74,8 +74,8 @@ func showLog(ctx context.Context, c *cli, m logMode) error { } 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} + // Names and restack markers only: no PR state or commits to work out. + o = app.ViewOptions{CurrentStackOnly: m.currentStack} } v, err := a.View(ctx, repo, o) if err != nil { diff --git a/cmd/log_short.go b/cmd/log_short.go index cc27d80..d158f52 100644 --- a/cmd/log_short.go +++ b/cmd/log_short.go @@ -10,8 +10,9 @@ 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.`, + Long: `Shows the same tree as git stack log with only the branch names, and +"(needs restack)" after any branch whose parent has moved on; 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 { diff --git a/cmd/log_test.go b/cmd/log_test.go index 9495713..44eef8f 100644 --- a/cmd/log_test.go +++ b/cmd/log_test.go @@ -122,6 +122,17 @@ func TestLogShortAndStack(t *testing.T) { 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) } + + // Moving trunk on leaves the bottom branches needing a restack. + gittest.Run(t, dir, "switch", "-q", "main") + gittest.Commit(t, dir, "main.txt", "more", "more") + out, errOut, err = runWith(t, f, "--cwd", dir, "log", "short") + if err != nil { + t.Fatalf("log short after trunk moved: %v\n%s", err, errOut) + } + if want := "○ c\n○ b\n○ a (needs restack)\n│ ○ other (needs restack)\n├─┘\n● main\n"; out != want { + t.Errorf("log short after trunk moved =\n%s\nwant\n%s", out, want) + } } // TestLogShortConfig checks that stack.log.short makes log and bare git stack diff --git a/pkg/ui/log.go b/pkg/ui/log.go index 138b92b..0fb8a02 100644 --- a/pkg/ui/log.go +++ b/pkg/ui/log.go @@ -26,7 +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 shows only the branch names, with "(needs restack)" after those + // that need one, and no PR, state or commits. Short bool } @@ -57,10 +58,11 @@ type LogOptions struct { // ├─┘ // ■ main // -// Short leaves just the branch names, one line each: +// Short leaves just the branch names, one line each, marking those that +// need a restack: // // ● feat-b -// ○ feat-a +// ○ feat-a (needs restack) // │ ○ hotfix // ├─┘ // ■ main @@ -105,10 +107,15 @@ func RenderLog(v *app.View, o LogOptions) string { gutter := prefix + st.Prefix.Render("│") for i := len(s) - 1; i >= 0; i-- { r := s[i] - b.WriteString(prefix + logMarker(r, st) + " " + logName(r, st) + "\n") + b.WriteString(prefix + logMarker(r, st) + " " + logName(r, st)) if o.Short { + if r.NeedsRestack { + b.WriteString(" " + st.Restack.Render("(needs restack)")) + } + b.WriteString("\n") continue } + b.WriteString("\n") pr, title := logPR(r, o) b.WriteString(FitLine(gutter+" "+pr, title, o.Width) + "\n") if meta := logMeta(r, o); meta != "" { diff --git a/pkg/ui/log_test.go b/pkg/ui/log_test.go index caaea52..fd3f6bb 100644 --- a/pkg/ui/log_test.go +++ b/pkg/ui/log_test.go @@ -77,7 +77,7 @@ 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-tests (needs restack)", "● feat/ui", "○ feat/api", "│ ○ fix/typo", From 9fb1ec76c82ade5643ea0e1d73296d11dfe8a7d1 Mon Sep 17 00:00:00 2001 From: Mo Valipour Date: Wed, 28 Oct 2026 18:34:39 +0000 Subject: [PATCH 2/2] Stop the picker filter test waiting on text that may never be drawn whole The renderer only redraws what changed, so after typing "hot" the last frame can be a cursor move and a lone "t"; "> hot" never appears in the output and the wait times out (seen on CI under -race). Messages are handled in order, so enter already sees the whole filter without waiting. Co-Authored-By: Claude Opus 5.5 --- pkg/ui/picker_test.go | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/pkg/ui/picker_test.go b/pkg/ui/picker_test.go index c547e72..1e3cfce 100644 --- a/pkg/ui/picker_test.go +++ b/pkg/ui/picker_test.go @@ -95,8 +95,10 @@ func TestPickerNavigationKeys(t *testing.T) { func TestPickerFilterKeepsTreeContext(t *testing.T) { tm := newTestPicker(t, PickerOptions{}) + // No waiting on "> hot": the renderer may draw just the last "t" after a + // cursor move, so the text need never appear whole. Messages are handled + // in order, so enter still sees the full filter. tm.Type("hot") - waitForText(t, tm, "> hot") tm.Send(tea.KeyPressMsg{Code: tea.KeyEnter}) p := finalPicker(t, tm) if r := p.Result(); r.Branch != "hotfix" {