Skip to content
Merged
100 changes: 100 additions & 0 deletions .qwen/e2e-tests/2026-09-02-webshell-push-nonff-disable.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
# Push hints follow the push destination

## Scenario

Open a trusted git workspace in the Web Shell and open the branch picker from
the sidebar git chip with the repo in each state:

1. Tracking upstream, ahead 1 / behind 0 (pushable).
2. Behind 3, clean tree, ahead 0.
3. Ahead 1 / behind 1 (diverged).
4. Conflicted merge in progress on the branch, ahead 1 / behind 1.
5. Behind 1 with a dirty tracked file whose incoming change conflicts; click
Update Project to raise the 409 resolution panel.
6. Triangular (fork) workflow with a resolvable push destination:
`branch.<name>.remote = upstream`, `branch.<name>.pushRemote = origin`,
**and `push.default = current`** (required — under the default
`push.default=simple`, git refuses to resolve `@{push}` in this shape and
the listing names no push destination); behind `upstream/main` by 3,
ahead of `origin/main` by 2.
7. Same config but `push.default` left at its default `simple`: git names no
push destination, though a bare `git push` still succeeds via the
configured `pushRemote`.
8. `branch.<name>.pushRemote = origin` with `push.default = current`, never
pushed to origin — the push ref does not exist yet.
9. A tracking upstream whose name the branch does not match — from `master`,
`git push origin master:bar`, `git branch --set-upstream-to=origin/bar`,
then one local commit — with `push.default` at its default, so the branch
is ahead 1 of an upstream git will not turn into a push destination. Git
names no destination *and* refuses the bare push (`exit 128`).
`push.default = nothing` on an otherwise plain tracking branch is the
sibling shape.
10. A branch with no upstream at all, in a repo that configures a repo-wide
push override (`remote.pushDefault`, or a `remote.<name>.push` refspec).
11. Detached HEAD (`git checkout --detach`).

## Checks

Only state 11 disables Push — a detached HEAD is the one push failure provable
from local state alone. Everything count-based warns on an enabled row and
lets git answer authoritatively on click:

- State 1: Push shows `↑1`, enabled.
- State 2: Push shows the warning `↓3`, enabled; clicking surfaces git's own
non-fast-forward rejection in the status line.
- State 3: Push shows the warning `↑1 ↓1 · diverged`, enabled.
- State 4: Push shows the warning "Merging", enabled (a push does not consult
the index).
- State 5: the panel is up; Push still renders its own hint and stays
enabled. Clicking Push clears the panel and shows the push outcome.
- State 6: Update Project shows `↓3 · upstream/main`; Push shows `↑2`
(push-side counts), enabled.
- State 7: Push shows **no hint** — git named no destination, so the row
makes no count claim; enabled.
- State 8: Push shows "Creates origin/<branch>", enabled.
- State 9: Push shows **no hint**, enabled. The upstream `↑1` must not appear
as a push count here: git refuses this push outright, and clicking surfaces
git's own refusal in the status line.
- State 10: Push shows "Sets upstream on push", enabled — the daemon pushes a
branch with no upstream through an explicit `--set-upstream` refspec, which
ignores `push.default` and the repo-wide override.
- State 11: Update Project and Push disabled with "Detached HEAD".
- After a **failed** Update Project against a force-reset upstream (reset the
remote branch to an ancestor in a second clone, no fetch in between): the
pull fails, and the re-fetched listing updates the rows in place — the pull
row leaves its stale `↓n` without reopening the popover. (A *deleted*
upstream ref defeats the fetch itself; only a prune refreshes that shape,
as the rule-site comment states.)
- After a **rejected** Push, both the listing and the working-tree status
re-read. That is a re-read, not a fetch: a rejected push moves no local ref,
so git's own message in the status line — not the refreshed counts — is what
explains the rejection.

## Evidence

Round 2 pivoted from disabling on `behind > 0` to warn-only after review
measured the disable misfiring across independent config axes
(`remote.<name>.push` refspecs / Gerrit, forcing refspecs, triangular
`push.default=simple`, `checkout -b` name-mismatch shapes, stale last-fetch
counts): remote acceptance is not locally decidable. Round 3 then re-keyed the
row's silence from "a push override is configured" to "git named no
destination for a live upstream" — the boundary the rule site states. The old
key was wrong in both directions: state 9 showed pull-side counts for a push
git refuses, and state 10 dropped the accurate "Sets upstream on push". The
`pushConfigured` atom it read had no other consumer, so it and the
`git config --get-regexp` probe that produced it are gone. Unit coverage pins
the warn-only rule, the push-side count display, the silence boundary on both
real-git shapes, the `pushGone` copy, the status-only fallback, and the
post-failure refresh (both actions, its best-effort failure path, and the
resolution panel staying usable while the refresh is in flight); core pins the
push atoms — including that git names no destination in the three silence
shapes — and a nonzero real-git `pushBehind`, under a hermetic env.

```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 \
client/components/ChatEditor.test.tsx
cd packages/core && npx vitest run src/utils/git-branches.test.ts
```
164 changes: 164 additions & 0 deletions packages/core/src/utils/git-branches.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -285,6 +285,170 @@ describe('fetchGitBranches upstream tracking', () => {
});
});

describe('fetchGitBranches push-side tracking', () => {
it('reports the push target and its counts in a triangular workflow', async () => {
const dir = makeRepo();
const upstreamRemote = makeBareRemote();
const originRemote = makeBareRemote();
git(dir, 'remote', 'add', 'upstream', upstreamRemote);
git(dir, 'remote', 'add', 'origin', originRemote);
git(dir, 'push', '-q', '-u', 'upstream', 'master');
git(dir, 'config', 'branch.master.pushRemote', 'origin');
git(dir, 'push', '-q', 'origin', 'master');
// Local gains one commit (ahead of origin), upstream gains one via a
// second clone (local behind upstream) — the fork-workflow shape.
fs.writeFileSync(path.join(dir, 'local.txt'), 'x\n');
git(dir, 'add', '.');
git(dir, 'commit', '-q', '-m', 'local');
const other = fs.mkdtempSync(path.join(os.tmpdir(), 'qwen-gitother-'));
tmpRoots.push(other);
git(other, 'clone', '-q', upstreamRemote, 'c');
const clone = path.join(other, 'c');
git(clone, 'config', 'user.email', 'test@example.com');
git(clone, 'config', 'user.name', 'Test');
git(clone, 'config', 'commit.gpgsign', 'false');
fs.writeFileSync(path.join(clone, 'up.txt'), 'y\n');
git(clone, 'add', '.');
git(clone, 'commit', '-q', '-m', 'up');
git(clone, 'push', '-q', 'origin', 'master');
git(dir, 'fetch', '-q', 'upstream');

const env = hermeticEnv();
// Under the default `push.default=simple`, git refuses to resolve
// `@{push}` in a triangular repo even though a plain `git push`
// succeeds — the listing names no push destination.
const simpleHead = (await fetchGitBranches(dir, env)).local.find(
(b) => b.name === 'master',
);
expect(simpleHead?.upstream).toBe('upstream/master');
expect(simpleHead?.behind).toBe(1);
expect(simpleHead?.pushTarget).toBeUndefined();

// With a resolvable push.default the push-side counts come through.
git(dir, 'config', 'push.default', 'current');
const head = (await fetchGitBranches(dir, env)).local.find(
(b) => b.name === 'master',
);
expect(head?.pushTarget).toBe('origin/master');
expect(head?.pushAhead).toBe(1);
expect(head?.pushBehind).toBe(0);
expect(head?.pushGone).toBeUndefined();
});

it('marks a resolvable push destination whose ref is missing as pushGone', async () => {
const dir = makeRepo();
const upstreamRemote = makeBareRemote();
const originRemote = makeBareRemote();
git(dir, 'remote', 'add', 'upstream', upstreamRemote);
git(dir, 'remote', 'add', 'origin', originRemote);
git(dir, 'push', '-q', '-u', 'upstream', 'master');
git(dir, 'config', 'branch.master.pushRemote', 'origin');
git(dir, 'config', 'push.default', 'current');
// Never pushed to origin: the push destination resolves by config but
// its ref does not exist — `git push` would create it.
const head = (await fetchGitBranches(dir, hermeticEnv())).local.find(
(b) => b.name === 'master',
);
expect(head?.pushTarget).toBe('origin/master');
expect(head?.pushGone).toBe(true);
expect(head?.pushAhead).toBeUndefined();
expect(head?.pushBehind).toBeUndefined();
});

it('reports the upstream itself as push target in the plain clone shape', async () => {
const dir = makeRepo();
const remote = makeBareRemote();
git(dir, 'remote', 'add', 'origin', remote);
git(dir, 'push', '-q', '-u', 'origin', 'master');
const head = (await fetchGitBranches(dir, hermeticEnv())).local.find(
(b) => b.name === 'master',
);
expect(head?.pushTarget).toBe('origin/master');
expect(head?.pushAhead).toBe(0);
expect(head?.pushBehind).toBe(0);
});

it('reports a nonzero pushBehind when the push remote has advanced', async () => {
const dir = makeRepo();
const remote = makeBareRemote();
git(dir, 'remote', 'add', 'origin', remote);
git(dir, 'push', '-q', '-u', 'origin', 'master');
const other = fs.mkdtempSync(path.join(os.tmpdir(), 'qwen-gitother-'));
tmpRoots.push(other);
git(other, 'clone', '-q', remote, 'c');
const clone = path.join(other, 'c');
git(clone, 'config', 'user.email', 'test@example.com');
git(clone, 'config', 'user.name', 'Test');
git(clone, 'config', 'commit.gpgsign', 'false');
fs.writeFileSync(path.join(clone, 'r.txt'), 'r\n');
git(clone, 'add', '.');
git(clone, 'commit', '-q', '-m', 'remote moves');
git(clone, 'push', '-q', 'origin', 'master');
git(dir, 'fetch', '-q', 'origin');

const head = (await fetchGitBranches(dir, hermeticEnv())).local.find(
(b) => b.name === 'master',
);
expect(head?.pushTarget).toBe('origin/master');
expect(head?.pushBehind).toBe(1);
expect(head?.pushAhead).toBe(0);
});

it('reports no push destination when git declines to name one', async () => {
// A live upstream git cannot turn into an `@{push}` answer. The push row
// stays silent on these: the bare `git push` is either refused outright
// or routed somewhere the listing cannot name, so the upstream counts
// are never a stand-in for push-side ones.
const env = hermeticEnv();

// The tracking upstream's name does not match the branch and
// `push.default` is the default `simple`.
const mismatch = makeRepo();
const remoteM = makeBareRemote();
git(mismatch, 'remote', 'add', 'origin', remoteM);
git(mismatch, 'push', '-q', 'origin', 'master:bar');
git(mismatch, 'fetch', '-q', 'origin');
git(mismatch, 'branch', '--set-upstream-to=origin/bar', 'master');
commitFile(mismatch, 'b.txt', 'two\n');
const headM = (await fetchGitBranches(mismatch, env)).local.find(
(b) => b.name === 'master',
);
expect(headM?.upstream).toBe('origin/bar');
expect(headM?.ahead).toBe(1);
expect(headM?.pushTarget).toBeUndefined();
expect(headM?.pushAhead).toBeUndefined();
expect(headM?.pushBehind).toBeUndefined();

// `push.default=nothing`: the upstream matches, git still names nothing.
const nothing = makeRepo();
const remoteN = makeBareRemote();
git(nothing, 'remote', 'add', 'origin', remoteN);
git(nothing, 'push', '-q', '-u', 'origin', 'master');
commitFile(nothing, 'c.txt', 'three\n');
git(nothing, 'config', 'push.default', 'nothing');
const headN = (await fetchGitBranches(nothing, env)).local.find(
(b) => b.name === 'master',
);
expect(headN?.upstream).toBe('origin/master');
expect(headN?.ahead).toBe(1);
expect(headN?.pushTarget).toBeUndefined();
expect(headN?.pushAhead).toBeUndefined();

// A `remote.<name>.push` refspec (Gerrit): `%(push)` cannot express
// `refs/for/*` as a branch at all.
const gerrit = makeRepo();
const remoteG = makeBareRemote();
git(gerrit, 'remote', 'add', 'origin', remoteG);
git(gerrit, 'push', '-q', '-u', 'origin', 'master');
git(gerrit, 'config', 'remote.origin.push', 'refs/heads/*:refs/for/*');
const headG = (await fetchGitBranches(gerrit, env)).local.find(
(b) => b.name === 'master',
);
expect(headG?.upstream).toBe('origin/master');
expect(headG?.pushTarget).toBeUndefined();
});
});

describe('fetchGitBranches recent branches', () => {
it('lists recently checked-out branches from the reflog', async () => {
const dir = makeRepo();
Expand Down
40 changes: 38 additions & 2 deletions packages/core/src/utils/git-branches.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,22 @@ export interface GitBranchInfo {
upstreamGone?: boolean;
ahead: number;
behind: number;
/**
* Where `git push` would push, by git's own resolution
* (`branch.<name>.pushRemote` / `remote.pushDefault` / the upstream via
* `push.default`). Absent when git cannot resolve a push destination.
* Differs from `upstream` in triangular (fork) workflows.
*/
pushTarget?: string;
/** Commits ahead of the push target; absent when `pushTarget` is. */
pushAhead?: number;
/** Commits behind the push target; absent when `pushTarget` is. */
pushBehind?: number;
/**
* Push destination resolves but its ref does not exist yet (`git push`
* would create the remote branch). `pushAhead`/`pushBehind` are absent.
*/
pushGone?: boolean;
/** Unix epoch seconds of the branch tip commit. */
commitDate: number;
commitSubject: string;
Expand Down Expand Up @@ -127,7 +143,7 @@ export async function fetchGitBranches(
cwd,
[
'for-each-ref',
'--format=%(refname:short)%00%(HEAD)%00%(upstream:short)%00%(upstream:track,nobracket)%00%(committerdate:unix)%00%(subject)%00%(symref)',
'--format=%(refname:short)%00%(HEAD)%00%(upstream:short)%00%(upstream:track,nobracket)%00%(committerdate:unix)%00%(subject)%00%(symref)%00%(push:short)%00%(push:track,nobracket)',
'refs/heads/',
],
env,
Expand All @@ -136,7 +152,7 @@ export async function fetchGitBranches(
cwd,
[
'for-each-ref',
'--format=%(refname:short)%00%(HEAD)%00%(upstream:short)%00%(upstream:track,nobracket)%00%(committerdate:unix)%00%(subject)%00%(symref)',
'--format=%(refname:short)%00%(HEAD)%00%(upstream:short)%00%(upstream:track,nobracket)%00%(committerdate:unix)%00%(subject)%00%(symref)%00%(push:short)%00%(push:track,nobracket)',
'refs/remotes/',
],
env,
Expand Down Expand Up @@ -210,13 +226,33 @@ function parseBranchLines(raw: string): GitBranchInfo[] {
// configured but its ref is missing; ahead/behind are meaningless then.
const upstreamGone = upstream !== undefined && /\bgone\b/.test(track);

// Push-side counterpart: `%(push)` is git's own answer to "where
// would `git push` go", honoring pushRemote/pushDefault — the same
// resolution a plain `git push` uses, so no precedence is re-derived
// here. Empty when unresolvable (e.g. `push.default` cannot pick).
const pushTarget = parts[7] || undefined;
const pushTrack = parts[8] ?? '';
const pushGone = pushTarget !== undefined && /\bgone\b/.test(pushTrack);
let pushAhead: number | undefined;
let pushBehind: number | undefined;
if (pushTarget !== undefined && !pushGone) {
const pa = /ahead (\d+)/.exec(pushTrack);
const pb = /behind (\d+)/.exec(pushTrack);
pushAhead = pa ? parseInt(pa[1], 10) : 0;
pushBehind = pb ? parseInt(pb[1], 10) : 0;
Comment thread
wenshao marked this conversation as resolved.
}

return {
name,
isHead,
upstream,
...(upstreamGone ? { upstreamGone } : {}),
ahead,
behind,
...(pushTarget !== undefined ? { pushTarget } : {}),
...(pushAhead !== undefined ? { pushAhead } : {}),
...(pushBehind !== undefined ? { pushBehind } : {}),
...(pushGone ? { pushGone } : {}),
commitDate,
commitSubject,
};
Expand Down
9 changes: 9 additions & 0 deletions packages/sdk-typescript/src/daemon/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -310,6 +310,15 @@ export interface DaemonGitBranchInfo {
upstreamGone?: boolean;
ahead: number;
behind: number;
/** Where `git push` would push (git's own resolution); may differ from
* `upstream` in triangular workflows. Absent when unresolvable. */
pushTarget?: string;
/** Commits ahead of the push target; absent when `pushTarget` is. */
pushAhead?: number;
/** Commits behind the push target; absent when `pushTarget` is. */
pushBehind?: number;
/** Push destination resolves but its ref is missing (push creates it). */
pushGone?: boolean;
/** Unix epoch seconds of the branch tip commit. */
commitDate: number;
commitSubject: string;
Expand Down
Loading
Loading