feat(stack): render stack blocks as nested topology#6
Conversation
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>
|
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. |
Greptile SummaryThis 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,
Confidence Score: 3/5Safe 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
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)
%%{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)
Reviews (1): Last reviewed commit: "feat(stack): render stack blocks as nest..." | Re-trigger Greptile |
| 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); | ||
| }; |
There was a problem hiding this comment.
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:
- The landed branch appears in the tree topology section as if it's still open.
- Because its PR number ends up in
liveKeysviaopts.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.
| err._tag === "ExecError" && err.stderr | ||
| ? ` ${err.stderr}` | ||
| : err._tag === "ReplayConflictError" && err.stderr | ||
| ? ` ${err.stderr}` | ||
| : null, |
There was a problem hiding this comment.
Both branches of this ternary produce identical output. The check can be collapsed into one condition.
| 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!
Summary
Validation