Skip to content

Commit 23257da

Browse files
author
Test
committed
fix(stacks): resolve GitHub stack membership by checkout identity, not branch name
The Stacks view matched a stack purely by `git branch --show-current`, so switching to a PR (review worktree, detached HEAD, `gh pr checkout`/rename) never resolved a stack even though the checkout was clearly a member. Detect via a precedence-ordered identity match instead: PR number (parsed from a `pr/<n>-review` branch) > branch name > upstream ref > HEAD commit sha, falling back to a target-only match, with dissolved (`open: false`) stacks excluded. Also derive per-member status (open/draft/merged/closed) and a local "Blocked downstack" approximation, since the Stacks API doesn't report per-PR mergeability, and fix a stale-view race and a leaked debounce timer in the refresh path. Restyle the Stacks webview rows to match GitHub's own stack map: a status icon + badge per PR, dimmed merged/closed rows, and the target rendered as a code chip.
1 parent 89abe6e commit 23257da

22 files changed

Lines changed: 822 additions & 154 deletions

File tree

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ All commands are grouped under the `GSC:` prefix in the Command Palette.
4141
| Open a terminal in a selected worktree's directory | `GSC: Open Worktree Dev Terminal...` | [Open worktree dev terminal](docs/open-worktree-dev-terminal.md) |
4242
| Remove several Git worktrees at once with a single confirmation | `GSC: Remove Multiple Worktrees...` | [Remove multiple worktrees](docs/remove-multiple-worktrees.md) |
4343
| Browse all worktrees for the open repositories in a dedicated "Worktrees" view (activity bar), with dirty/ahead-behind/PR-review status, last commit + relative age, an upstream-gone flag with a dedicated inline Remove action, an auto-stash marker, live refresh on external git activity, and a badge showing the count of dirty worktrees, plus inline Open/Terminal/Copy WIP/Remove actions | *(tree view — right-click an item for Add to Workspace, Copy Path, Reveal in Finder/Explorer)* ||
44-
| Detect the GitHub-native [stacked PR](https://docs.github.com/en/rest/pulls/stacks) chain the current branch belongs to, and browse it in a dedicated "Stacks" view (activity bar) showing each PR's title and branch plus the branch the stack is ultimately targeting. Click a row (or its target chip) to check it out using the currently selected checkout/stash strategy; right-click for "Open PR in Browser". Nothing is cached — an in-view refresh icon re-checks live at any time. A status bar indicator (`$(layers) <position>/<size>`) appears only while the current branch is part of a stack; click it to reveal the Stacks view. | `GSC: Refresh Stacks` | [Stacks](docs/stacks.md) |
44+
| Detect the GitHub-native [stacked PR](https://docs.github.com/en/rest/pulls/stacks) chain the current checkout belongs to — resolved by branch, PR-review worktree, HEAD commit, or upstream ref, so it works whether you switched branches or switched PRs — and browse it in a dedicated "Stacks" view (activity bar) with a GitHub-style status icon/badge per PR (open/draft/merged/closed/blocked downstack) plus the branch the stack is ultimately targeting. Click a row (or its target chip) to check it out using the currently selected checkout/stash strategy; right-click for "Open PR in Browser". Nothing is cached — an in-view refresh icon re-checks live at any time. A status bar indicator (`$(layers) <position>/<size>`) appears only while the current checkout is part of a stack, including on a matched detached HEAD; click it to reveal the Stacks view. | `GSC: Refresh Stacks` | [Stacks](docs/stacks.md) |
4545
| Create a new PR from selected commits in another GitHub PR | `GSC: Clone Pull Request...` | [GitHub PR clone](docs/github-pr-clone.md) |
4646
| Generate and optionally push a tag from a reusable template | `GSC: Create Tag from Template...` | [Create tag from template](docs/create-tag-from-template.md) |
4747
| Create and check out a branch from a template (Jira, file, regex, scripts) | `GSC: Create Branch from Template...` | [Create branch from template](docs/create-branch-from-template.md) |

docs/stacks.md

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -18,21 +18,29 @@ Whether a single PR onto trunk counts as a stack, how forks in history are resol
1818

1919
## The Stacks View
2020

21-
Shows the stack containing the **current branch**, top PR first, target branch as a chip at the bottom. Each row shows the PR title and `#number · branch`; the current branch/target is highlighted.
21+
Shows the stack containing the **current checkout**, top PR first, target branch as a code chip at the bottom. Each row shows a GitHub-style status icon, the PR title, a right-aligned status badge, and `#number · head-ref` (the PR's own head branch — not necessarily your local branch name, see [Detection](#detection)); the current row is marked with a left accent bar.
2222

23-
- **Click a row (or the target chip)** to check out that branch, using whichever [stash mode](switch-mode.md) is currently active — including the manual prompt if that's what's configured.
23+
- **Status icon and badge**: open (green), draft (dashed, "Draft"), merged (purple check, "Merged"), closed (red ×, "Closed"), or blocked (warning circle, "Blocked downstack" — shown when an unmerged draft/closed PR sits below it in the stack; this is a local approximation, since the Stacks API doesn't report per-member mergeability). Merged and closed rows render dimmed but stay in the list, matching their place in the real stack.
24+
- **Click a row (or the target chip)** to check out that PR's head branch, using whichever [stash mode](switch-mode.md) is currently active — including the manual prompt if that's what's configured.
2425
- **Right-click a row** (or use the inline external-link icon) for "Open PR in Browser".
2526
- The target chip shows **⇡N ⇣N** (commits waiting to be pushed / commits to pull), read directly from the branch's upstream tracking info — the same convention as the Worktrees view — whenever the target branch has an upstream configured. Next to it, a **fetch icon (⟳, "Fetch latest changes")** fetches the target branch from its remote (updating the remote-tracking ref only, no checkout, no merge) and refreshes the indicator.
26-
- The **refresh icon (⟳)** in the view's title bar re-fetches the current branch's stack status from GitHub on demand — useful right after a stack changes on GitHub (a PR merged, or the stack was reordered/dissolved), since nothing is cached locally.
27-
- When the current branch isn't part of any stack, the view shows an empty state with a Refresh button.
27+
- The **refresh icon (⟳)** in the view's title bar re-fetches the current stack status from GitHub on demand — useful right after a stack changes on GitHub (a PR merged, or the stack was reordered/dissolved), since nothing is cached locally.
28+
- When the current checkout isn't part of any stack, the view shows an empty state with a Refresh button.
2829

2930
## Status Bar Indicator
3031

31-
`$(layers) <position>/<size>` appears immediately to the left of the stash-mode item, but **only** while the current branch is part of a detected stack — it's hidden when stacks are disabled, the extension's status bar is off, HEAD is detached, or the branch simply isn't stacked. The count is of **stacked PRs only** — the target branch doesn't count toward `<size>`, so sitting on the target itself shows no position (there's no PR there). Its tooltip lists the chain top-to-bottom with PR numbers/titles, plus the target branch on its own line. Click it to reveal the Stacks view.
32+
`$(layers) <position>/<size>` appears immediately to the left of the stash-mode item, but **only** while the current checkout is part of a detected stack — it's hidden when stacks are disabled, the extension's status bar is off, or the checkout simply isn't stacked. Unlike earlier versions, a detached HEAD does **not** hide the indicator on its own: a detached checkout sitting on a stack member's commit is still resolved and shown (see [Detection](#detection)). The count is of **stacked PRs only** — the target branch doesn't count toward `<size>`, so sitting on the target itself shows no position (there's no PR there). Its tooltip lists the chain top-to-bottom with PR numbers/titles, plus the target branch on its own line. Click it to reveal the Stacks view.
3233

3334
## Detection
3435

35-
Detection is automatic and always on for GitHub repositories, with no configuration knob: whenever the current branch is the target or a stacked PR's head in one of the repository's stacks (as reported live by `GET /repos/{owner}/{repo}/stacks`), it shows up. Nothing is cached — a stack can be reordered or dissolved on GitHub at any time, so every refresh re-fetches live rather than risking a stale view.
36+
Detection is automatic and always on for GitHub repositories, with no configuration knob. Every refresh resolves the current checkout's *identity* — its branch name, HEAD commit sha, any `pr/<n>-review` worktree it's parsed from, and its upstream branch name — and matches that identity against the members and targets of the repository's stacks (as reported live by `GET /repos/{owner}/{repo}/stacks`), by precedence:
37+
38+
1. **PR number** — a `GSC: Review PR by number` / "Review PR in worktree" checkout (branch `pr/<n>-review`) matches PR `<n>` directly, even though that branch name never appears in any PR's `head.ref`.
39+
2. **Branch name** — the checked-out local branch equals a stacked PR's `head.ref`, or the stack's `base.ref` (a target-only match, used only when no member matched).
40+
3. **Upstream ref** — the checked-out branch's remote-tracking branch (e.g. after `gh pr checkout` or a local rename) equals a stacked PR's `head.ref`.
41+
4. **HEAD commit sha** — a detached HEAD sitting exactly on a stacked PR's `head.sha`.
42+
43+
A member match always wins over a target match, even across different stacks (e.g. standing on `main`, which is the target of one stack but also happens to be a stacked PR's head in another, resolves to the stack you're actually in). Stacks with `open: false` (dissolved on GitHub) are never matched. Nothing is cached — a stack can be reordered or dissolved on GitHub at any time, so every refresh re-fetches live rather than risking a stale view.
3644

3745
Disable everything with:
3846

src/commands/reviewPrByNumberCommand/index.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,12 @@ export function getReviewBranchName(prNumber: number): string {
4343
return `pr/${prNumber}-review`;
4444
}
4545

46+
/** Parses the PR number out of a `pr/<n>-review` branch name, or undefined when it doesn't match. */
47+
export function parseReviewBranchName(branchName: string): number | undefined {
48+
const match = /^pr\/(\d+)-review$/.exec(branchName);
49+
return match ? Number(match[1]) : undefined;
50+
}
51+
4652
interface ExistingReviewWorktree {
4753
worktreePath: string;
4854
branchName: string;

src/common/api/ghClient.ts

Lines changed: 6 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -293,10 +293,8 @@ export class GitHubClient {
293293
}
294294

295295
/**
296-
* List all open pull requests for the repository. Used by stack detection
297-
* to find PR base-branch chains; returns an empty array (rather than
298-
* throwing) on any API failure so detection can fall back to the local
299-
* ancestry heuristic without crashing.
296+
* List all open pull requests for the repository. Returns an empty array
297+
* (rather than throwing) on any API failure.
300298
*/
301299
public async listOpenPullRequests(): Promise<GitHubPR[]> {
302300
try {
@@ -309,11 +307,10 @@ export class GitHubClient {
309307

310308
/**
311309
* Same as {@link listOpenPullRequests}, but propagates API failures instead
312-
* of swallowing them into an empty array. `StackService` needs this
313-
* distinction: an empty list because there genuinely are no open PRs must
314-
* not be conflated with a 401/rate-limit failure, which should instead fall
315-
* back to a cached PR list (or the heuristic) rather than silently
316-
* reporting "not stacked".
310+
* of swallowing them into an empty array. `StackService` uses this list
311+
* only to enrich a GitHub-native stack's members with title/URL — a failure
312+
* here still yields a stack, with placeholder titles for members missing
313+
* from the list (see `prStackFromGithubStack`).
317314
*/
318315
public async listOpenPullRequestsOrThrow(): Promise<GitHubPR[]> {
319316
const endpoint = `/repos/${this.owner}/${this.repo}/pulls?state=open`;

src/common/git/gitExecutor.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -611,6 +611,23 @@ export class GitExecutor {
611611
} catch { return undefined; }
612612
}
613613

614+
/** Short upstream branch name (remote prefix stripped) for the current HEAD, or undefined when there is none. */
615+
async getUpstreamRef(): Promise<string | undefined> {
616+
try {
617+
const { stdout } = await this.#execGitCommand([
618+
'rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{upstream}',
619+
]);
620+
const upstream = stdout.trim();
621+
if (!upstream) {
622+
return undefined;
623+
}
624+
const slashIndex = upstream.indexOf('/');
625+
return slashIndex === -1 ? upstream : upstream.slice(slashIndex + 1);
626+
} catch {
627+
return undefined;
628+
}
629+
}
630+
614631
async pullFromRemoteBranch(options: { rebase?: boolean } = {}) {
615632
await this.#execGitCommand(['pull', ...(options.rebase ? ['--rebase'] : [])]);
616633
}

src/extension.ts

Lines changed: 36 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -248,28 +248,35 @@ export function activate(context: vscode.ExtensionContext) {
248248
);
249249

250250
/**
251-
* Refreshes the current branch's PR stack (GitHub PR chains, authoritative;
252-
* the local ancestry heuristic only runs as an offline fallback — see
253-
* `StackService`) and pushes the result to the Stacks webview and the
254-
* status bar indicator. Never throws — detection failures (no GitHub
255-
* remote/token, git errors) are swallowed so they can't break activation
256-
* or command completion.
251+
* Refreshes the current checkout's PR stack (GitHub's native Stacks API,
252+
* matched by branch, PR-review worktree, HEAD sha, or upstream ref — see
253+
* `findGithubStackForCheckout` in `StackService`) and pushes the result to
254+
* the Stacks webview and the status bar indicator. Never throws —
255+
* detection failures (no GitHub remote/token, git errors) are swallowed so
256+
* they can't break activation or command completion. Guarded against
257+
* out-of-order completion: activation, config-change, and debounced
258+
* refreshes can all be in flight at once, and an older one finishing last
259+
* must not clobber a newer view.
257260
*/
261+
let stackRefreshSequence = 0;
258262
const refreshStacks = async (): Promise<void> => {
263+
const sequence = ++stackRefreshSequence;
259264
try {
260265
const result = await stackService.refresh();
266+
if (sequence !== stackRefreshSequence) {
267+
return;
268+
}
261269
stackWebViewProvider.setStack(result.view);
262270
statusBarManager.setStackIndicator({
263271
isDetached: result.isDetached,
264-
data:
265-
result.view && result.currentBranch
266-
? {
267-
orderedBranches: indicatorBranchesOf(result.view),
268-
currentBranch: result.currentBranch,
269-
target: result.view.target,
270-
info: stackInfoMapOf(result.view),
271-
}
272-
: undefined,
272+
data: result.view
273+
? {
274+
orderedBranches: indicatorBranchesOf(result.view),
275+
currentIndex: result.view.currentIndex,
276+
target: result.view.target,
277+
info: stackInfoMapOf(result.view),
278+
}
279+
: undefined,
273280
});
274281
} catch (error) {
275282
logService.warn(`Stack detection failed: ${error}`);
@@ -626,10 +633,22 @@ export function activate(context: vscode.ExtensionContext) {
626633
void refreshStacks();
627634
}, delayMs);
628635
};
636+
// A checked-out-HEAD change (branch switch, PR-review checkout) should
637+
// refresh the Stacks view promptly; ordinary working-tree churn (saves,
638+
// stages) keeps the longer debounce so it doesn't spend API calls on
639+
// every keystroke-driven git status update.
640+
const currentHeadsSnapshot = (): string =>
641+
(vscode.workspace.workspaceFolders ?? [])
642+
.map((folder) => vscodeGitProvider?.getCurrentBranch(folder.uri.fsPath) ?? '')
643+
.join('\x1f');
644+
let lastStackHeadsSnapshot = currentHeadsSnapshot();
629645
const gitStateListener = vscodeGitProvider?.onDidChangeAnyRepositoryState(() => {
630646
worktreeTreeDataProvider.refreshDebounced();
631647
stashTreeDataProvider.refreshDebounced();
632-
refreshStacksDebounced();
648+
const heads = currentHeadsSnapshot();
649+
const headChanged = heads !== lastStackHeadsSnapshot;
650+
lastStackHeadsSnapshot = heads;
651+
refreshStacksDebounced(headChanged ? 250 : 2000);
633652
});
634653

635654
// Listen for configuration changes
@@ -662,6 +681,7 @@ export function activate(context: vscode.ExtensionContext) {
662681
windowStateListener,
663682
workspaceFoldersListener,
664683
...(gitStateListener ? [gitStateListener] : []),
684+
{ dispose: () => clearTimeout(stackRefreshDebounceTimer) },
665685
telemetryChangeListener,
666686
statusBarManager,
667687
logService,

0 commit comments

Comments
 (0)