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
58 changes: 58 additions & 0 deletions .qwen/e2e-tests/2026-08-28-webshell-branch-picker-action-hints.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
# Branch picker action hints

## Scenario

Open a trusted git workspace in the Web Shell (`qwen serve` + the sidebar).
Click the branch chip on the workspace folder header (or the composer chip /
Environment panel row) to open the branch picker. Exercise the repo through
these states between opens:

1. Tracking `origin/main`, in sync, clean tree.
2. `git reset --hard HEAD~3` (behind 3, clean).
3. Same as 2 plus an edited tracked file and one new untracked file.
4. `git checkout -b feat/no-upstream` with one local commit (no upstream).
5. Start a conflicting `git rebase` and leave it in progress.
6. `git checkout --detach`.
7. Start a conflicting `git merge` on a branch with one commit ahead.
8. Push a branch with `-u`, delete it on the remote, `git fetch --prune`.

## Checks

- State 1: Update Project shows "Up to date", Push shows "Nothing to push",
Commit shows "No changes"; all three rows are dimmed but still enabled.
- State 2: Update Project shows "↓3 · origin/main" in the neutral tone.
- State 3: Update Project shows "↓3 · uncommitted changes" in the warning tone
and stays enabled; Commit shows "2 changes (1 untracked)" (entries, not
files: a partially staged file counts twice).
- State 4: Update Project shows "No upstream" and is disabled; Push shows
"Sets upstream on push" and is enabled.
- State 5: Update Project and Push both show "Rebasing" in the warning tone and
are disabled (a rebase detaches HEAD); Commit stays enabled.
- State 6: Update Project and Push both show "Detached HEAD" and are disabled.
- State 7: Update Project shows "Merging" and is disabled; Push shows "Merging"
in the warning tone but stays enabled (a push does not consult the index).
- State 8: Update Project shows "Upstream gone" and is disabled; Push shows
"Sets upstream on push".
- After committing through the Commit dialog and reopening the picker from
any of the three entry points (sidebar chip, composer chip, Environment
panel), the Commit hint reflects the new tree without waiting for the poll.
- With the picker open, run `git branch --unset-upstream` in a terminal and
refocus the window: once the chip's status updates, Update Project flips to
disabled "No upstream" without reopening.
- Switching the UI language to 中文 renders the localized copy
("已是最新", "无上游分支", "↓3 · 有未提交更改", "2 处更改(1 未跟踪)").

## Evidence

Unit coverage lives in
`packages/web-shell/client/components/BranchPickerPopover.test.tsx`
(`deriveActionHints` decision table + rendered disabled/tone assertions) and the
open-time status refresh in
`packages/web-shell/client/components/sidebar/WorkspaceSection.test.tsx`.

```sh
cd packages/web-shell && npx vitest run \
client/components/BranchPickerPopover.test.tsx \
client/components/sidebar/WorkspaceSection.test.tsx \
client/components/panels/EnvironmentPanel.test.tsx
```
34 changes: 34 additions & 0 deletions packages/core/src/utils/git-branches.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,40 @@ describe('gitEnv (R12 env isolation)', () => {
});
});

describe('fetchGitBranches upstream tracking', () => {
it('marks a branch whose upstream ref was deleted and pruned as gone', async () => {
const dir = makeRepo();
const remote = makeBareRemote();
git(dir, 'remote', 'add', 'origin', remote);
git(dir, 'push', '-q', '-u', 'origin', 'master');
git(dir, 'checkout', '-q', '-b', 'feat');
git(dir, 'push', '-q', '-u', 'origin', 'feat');

const tracked = (await fetchGitBranches(dir)).local.find(
(b) => b.name === 'feat',
);
expect(tracked?.upstream).toBe('origin/feat');
expect(tracked?.upstreamGone).toBeUndefined();

git(dir, 'push', '-q', 'origin', '--delete', 'feat');
git(dir, 'fetch', '-q', '--prune', 'origin');

const gone = (await fetchGitBranches(dir)).local.find(
(b) => b.name === 'feat',
);
// The configured upstream is still reported so the UI can name it, but
// the flag says its ref no longer exists.
expect(gone?.upstream).toBe('origin/feat');
expect(gone?.upstreamGone).toBe(true);
expect(gone?.ahead).toBe(0);
expect(gone?.behind).toBe(0);
const master = (await fetchGitBranches(dir)).local.find(
(b) => b.name === 'master',
);
expect(master?.upstreamGone).toBeUndefined();
});
});

describe('fetchGitBranches recent branches', () => {
it('lists recently checked-out branches from the reflog', async () => {
const dir = makeRepo();
Expand Down
10 changes: 10 additions & 0 deletions packages/core/src/utils/git-branches.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,12 @@ export interface GitBranchInfo {
name: string;
isHead: boolean;
upstream?: string;
/**
* `true` when the configured upstream ref no longer exists (git's
* `[gone]` tracking state, e.g. after the remote branch was deleted and
* pruned). `upstream` still names the configured ref in that case.
*/
upstreamGone?: boolean;
ahead: number;
behind: number;
/** Unix epoch seconds of the branch tip commit. */
Expand Down Expand Up @@ -198,11 +204,15 @@ function parseBranchLines(raw: string): GitBranchInfo[] {
const behindMatch = /behind (\d+)/.exec(track);
if (aheadMatch) ahead = parseInt(aheadMatch[1], 10);
if (behindMatch) behind = parseInt(behindMatch[1], 10);
// `%(upstream:track,nobracket)` prints `gone` when the upstream is
// configured but its ref is missing; ahead/behind are meaningless then.
const upstreamGone = upstream !== undefined && /\bgone\b/.test(track);

return {
name,
isHead,
upstream,
...(upstreamGone ? { upstreamGone } : {}),
ahead,
behind,
commitDate,
Expand Down
2 changes: 2 additions & 0 deletions packages/sdk-typescript/src/daemon/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -303,6 +303,8 @@ export interface DaemonGitBranchInfo {
name: string;
isHead: boolean;
upstream?: string;
/** The configured upstream ref no longer exists (git's `[gone]` state). */
upstreamGone?: boolean;
ahead: number;
behind: number;
/** Unix epoch seconds of the branch tip commit. */
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -265,3 +265,27 @@
font-size: 12px;
color: var(--muted-foreground, #888);
}

.actionItemMuted .actionLabel {
color: var(--muted-foreground, #888);
}

.actionHint {
flex-shrink: 0;
max-width: 55%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 11px;
font-variant-numeric: tabular-nums;
color: var(--muted-foreground, #888);
}

.actionHint[data-tone='info'] {
color: var(--foreground, #e0e0e0);
opacity: 0.8;
}

.actionHint[data-tone='warning'] {
color: var(--warning-color, #f59e0b);
}
Loading
Loading