Skip to content

feat(stack): render stack blocks as nested topology#6

Closed
aryasaatvik wants to merge 7 commits into
devfrom
feat/nested-stack-widget
Closed

feat(stack): render stack blocks as nested topology#6
aryasaatvik wants to merge 7 commits into
devfrom
feat/nested-stack-widget

Conversation

@aryasaatvik

Copy link
Copy Markdown
Owner

Summary

Validation

  • bun run test
  • bun run typecheck
  • bun run lint
  • bun run format:check
  • bun run package:smoke

aryasaatvik and others added 7 commits June 30, 2026 15:53
When `stack merge --apply` or `stack merge --auto` lands a branch
that is checked out in another worktree, Git refuses to delete it,
leaving the stack half-landed locally. Add `Git.release(branch)` to
detach a clean sibling worktree at HEAD before deleting the landed
branch, and fail before hosted mutation when that worktree is dirty.
If stack merge persists the landed state but aborts before descendant
repair, a later stack sync --apply can lose the correct replay boundary.
In squash-merge repos, the child then replays both the already-squashed
parent commits and its own, producing avoidable conflicts.

Use the persisted branch anchor as a fallback replay boundary when the
child is already parented to trunk and the stored anchor matches a
backup/landed-* head, so stranded descendants replay only their own
commits.
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
…ing (kitlangton#35)

Write state.json and undo.json atomically via tmp+rename so a crash
mid-write cannot corrupt stack metadata. When a cherry-pick fails
during repair, surface the conflicting file paths before aborting so
the user knows which files need attention. Corrupt state files now
include a recovery hint in the error message.

Extracted from the worktree-stack-hardening branch.
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
@aryasaatvik

Copy link
Copy Markdown
Owner Author

Closing in favor of a clean fork-dev-based PR from feat/nested-stack-widget-dev. The original feat/nested-stack-widget branch is upstream-main based and is still used by upstream PR kitlangton#37.

@aryasaatvik aryasaatvik closed this Jul 3, 2026
@greptile-apps

greptile-apps Bot commented Jul 3, 2026

Copy link
Copy Markdown

Greptile Summary

This PR mirrors upstream work to replace the flat numbered stack block with a nested unordered list that reflects the true PR/MR topology, so sibling branches appear at the same indentation level and the current PR's path is clearly visible. It also bundles several reliability fixes: atomic state writes via tmp+rename, ReplayConflictError with conflicting-file paths, stranded squash-anchor recovery, and clean-worktree detach before branch deletion.

  • Nested topology rendering (stackBlock.ts, stackGraph.ts): displayTreeFor() builds a DisplayTree rooted at the stack root and treeLines() renders it with 2-space indent per level; completedLines filter is broadened from checkbox-only to all - items for migration compatibility.
  • Worktree safety (Git.ts, Stack.ts): git.release() detaches a clean sibling worktree that owns the branch being deleted; dirty-worktree guard now covers the target branch itself in addition to descendants.
  • Resilience improvements (Store.ts, domain/model.ts, services/Stack.ts): atomic file writes, ReplayConflictError carrying conflicting paths, and stranded backup/landed-* anchor recovery during stack sync --apply.

Confidence Score: 3/5

Safe to merge with one targeted fix: the stale-root rendering in displayTreeFor can write misleading content into live PR descriptions.

The Store atomic-write, release/drop ordering, and ReplayConflictError paths are solid and well-tested. The nested topology rendering works correctly for the common case where state is clean. The gap is in displayTreeFor: rootOf() can return a landed branch that is still in state links, and that branch is passed through build() without a liveness check, causing it to appear as an open entry in the nested topology and suppressing its history line. This is reachable via stack links after a manual code-host merge before the next sync.

src/stackGraph.ts (displayTreeFor root liveness) and src/stackBlock.ts (resulting render)

Important Files Changed

Filename Overview
src/stackGraph.ts Adds DisplayTree interface and displayTreeFor(), which builds the full PR tree rooted at rootOf(branch). Root branch is not filtered against liveBranches, causing a stale-state display bug when the root has no open PR.
src/stackBlock.ts Switches render from numbered ordered list to nested unordered topology. Adds treeLines/walkTree helpers, updates liveKeys to cover tree branches via metas, and broadens completedLines filter from checkbox-only to all - lines for migration compatibility.
src/services/Stack.ts Multiple improvements: landedAnchor recovery for stranded squash repair, targetOwner detection before drop to release clean sibling worktrees, displayTreeFor integration for link rendering, and expanded ReplayConflictError path reporting in error messages.
src/services/Git.ts Adds unmergedPaths() (git diff --name-only --diff-filter=U) and release() (detach clean worktree before branch deletion). replay() now converts ExecError from cherry-pick failure into ReplayConflictError with conflicting paths.
src/services/Store.ts Atomic write via tmp-then-rename prevents partial corruption of state.json/undo.json on crash mid-write. Decode error now includes a recovery hint.
src/domain/model.ts Adds ReplayConflictError with branch, parent, paths, and stderr fields. Added to StackError union type. Constructor follows the same TaggedErrorClass pattern as ExecError.
tests/stack.test.ts Extensive test additions: nested block assertions, landedAnchor stranded-repair scenario, release/drop ordering tests for clean and dirty worktrees, forked-topology sibling visibility, and ReplayConflictError expectation in replay failure test.

Sequence Diagram

%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
    participant User
    participant Stack
    participant StackGraph
    participant StackBlock
    participant Git

    User->>Stack: stack links (or sync --apply)
    Stack->>StackGraph: make(state, pulls, refs)
    Stack->>StackGraph: displayTreeFor(pull.head)
    StackGraph-->>Stack: "DisplayTree { branch, children[] }"
    Stack->>StackBlock: "render({ tree, pulls, metas, branch, previous })"
    StackBlock->>StackBlock: walkTree(tree) → treeBranches
    StackBlock->>StackBlock: "liveKeys = pulls ∪ treeBranches (+ PR numbers via metas)"
    StackBlock->>StackBlock: completedLines(previous, liveKeys) → history items
    StackBlock->>StackBlock: "treeLines(tree, depth=0) → nested entries"
    StackBlock-->>Stack: rendered block string
    Stack->>Git: release(target) if sibling worktree owns it
    Git->>Git: git checkout --detach HEAD (in worktree)
    Stack->>Git: drop(target)
Loading
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
sequenceDiagram
    participant User
    participant Stack
    participant StackGraph
    participant StackBlock
    participant Git

    User->>Stack: stack links (or sync --apply)
    Stack->>StackGraph: make(state, pulls, refs)
    Stack->>StackGraph: displayTreeFor(pull.head)
    StackGraph-->>Stack: "DisplayTree { branch, children[] }"
    Stack->>StackBlock: "render({ tree, pulls, metas, branch, previous })"
    StackBlock->>StackBlock: walkTree(tree) → treeBranches
    StackBlock->>StackBlock: "liveKeys = pulls ∪ treeBranches (+ PR numbers via metas)"
    StackBlock->>StackBlock: completedLines(previous, liveKeys) → history items
    StackBlock->>StackBlock: "treeLines(tree, depth=0) → nested entries"
    StackBlock-->>Stack: rendered block string
    Stack->>Git: release(target) if sibling worktree owns it
    Git->>Git: git checkout --detach HEAD (in worktree)
    Stack->>Git: drop(target)
Loading

Fix All in Codex Fix All in Cursor Cloud Agents Fix All in Claude Code Fix All in Cursor

Reviews (1): Last reviewed commit: "feat(stack): render stack blocks as nest..." | Re-trigger Greptile

Comment thread src/stackGraph.ts
Comment on lines +213 to +226
const displayTreeFor = (branch: string): DisplayTree => {
const root = rootOf(branch);
const build = (name: string, seen = new Set<string>()): DisplayTree => {
if (seen.has(name)) return { branch: name, children: [] };
const nextSeen = new Set(seen);
nextSeen.add(name);
const children = (explicitChildren.get(name) ?? [])
.filter((child) => liveBranches.has(child))
.map((child) => build(child, nextSeen));
return { branch: name, children };
};

return build(root);
};

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Non-live root leaks into the nested topology

displayTreeFor starts from rootOf(branch), which walks up through state links. If a PR has been manually merged on the code host but stack sync --apply hasn't run yet (stale state), the returned root can be a branch with no open PR — not in liveBranches. That stale root is passed through build() without any liveness filter and ends up rendered as a tree topology entry (e.g., - #1 \stack-a`viaopts.metas, or `` - stack-a` `` without it).

Two compounding effects:

  1. The landed branch appears in the tree topology section as if it's still open.
  2. Because its PR number ends up in liveKeys via opts.metas, the previous body's history entry for it is suppressed — so it disappears from history AND is shown as a live entry simultaneously.

The concrete failure path is stack links run after a manual code-host merge but before the next stack sync --apply.

Fix in Codex Fix in Cursor Cloud Agents Fix in Claude Code Fix in Cursor

Comment thread src/services/Stack.ts
Comment on lines +178 to +182
err._tag === "ExecError" && err.stderr
? ` ${err.stderr}`
: err._tag === "ReplayConflictError" && err.stderr
? ` ${err.stderr}`
: null,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Both branches of this ternary produce identical output. The check can be collapsed into one condition.

Suggested change
err._tag === "ExecError" && err.stderr
? ` ${err.stderr}`
: err._tag === "ReplayConflictError" && err.stderr
? ` ${err.stderr}`
: null,
(err._tag === "ExecError" || err._tag === "ReplayConflictError") && err.stderr
? ` ${err.stderr}`
: null,

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Fix in Codex Fix in Cursor Cloud Agents Fix in Claude Code Fix in Cursor

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants