Skip to content
Closed
Show file tree
Hide file tree
Changes from 2 commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
4bbd6d7
feat(web-shell): unblock git update on dirty working tree
wenshao Aug 23, 2026
ee9b1f2
fix(serve): harden dirty-worktree git pull resolution (#9769)
qwen-code-dev-bot Aug 23, 2026
71a3245
fix(serve): address round-2 review of dirty-worktree git pull (#9769)
qwen-code-dev-bot Aug 23, 2026
5e03ec2
fix(serve): classify dirty-worktree pull failures from repository sta…
qwen-code-dev-bot Aug 23, 2026
c6546fd
fix(serve): address round-4 review of dirty-worktree git pull (#9769)
qwen-code-dev-bot Aug 23, 2026
a7aff45
fix(serve): address round-5 review of dirty-worktree git pull (#9769)
qwen-code-dev-bot Aug 24, 2026
82cd1ea
fix(serve): address round-6 review of dirty-worktree git pull (#9769)
qwen-code-ci-bot Aug 24, 2026
8eb70af
Merge branch 'main' into feat/git-pull-dirty-worktree
wenshao Aug 24, 2026
280fa7d
fix(serve): address round-7/8 review of dirty-worktree git pull (#9769)
qwen-code-ci-bot Aug 24, 2026
4591a65
fix(serve): address round-9 review of dirty-worktree git pull (#9769)
qwen-code-ci-bot Aug 25, 2026
4998eb1
Merge remote-tracking branch 'origin/main' into feat/git-pull-dirty-w…
qwen-code-ci-bot Aug 25, 2026
302803f
fix(serve): address round-10 review of dirty-worktree git pull (#9769)
qwen-code-ci-bot Aug 25, 2026
5ab98db
fix(core): address round-11 review of dirty-worktree git pull (#9769)
qwen-code-ci-bot Aug 25, 2026
de03ee0
Merge branch 'main' into feat/git-pull-dirty-worktree
wenshao Aug 26, 2026
a6f32be
fix(core): address round-12 review of dirty-worktree git pull (#9769)
qwen-code-ci-bot Aug 26, 2026
39737cb
Merge branch 'feat/git-pull-dirty-worktree' of https://github.com/wen…
qwen-code-dev-bot Aug 26, 2026
3a47a27
Merge branch 'main' into feat/git-pull-dirty-worktree
wenshao Aug 26, 2026
b067bcd
fix(core): address round-14 review of dirty-worktree git pull (#9769)
qwen-code-ci-bot Aug 26, 2026
29342ad
fix(core): address round-15 review of dirty-worktree git pull (#9769)
qwen-code-ci-bot Aug 26, 2026
9b25ae4
Merge branch 'main' into feat/git-pull-dirty-worktree
qwen-code-dev-bot Aug 26, 2026
db0d9a2
Merge branch 'main' into feat/git-pull-dirty-worktree
qwen-code-dev-bot Aug 27, 2026
0863e06
fix(core): close gitPull guard holes in collision probes and session …
qwen-code-ci-bot Aug 27, 2026
7dc521e
test(cli): pre-warm the lazy core import in workspace-store tests (#9…
qwen-code-ci-bot Aug 27, 2026
198e3c4
Merge branch 'main' into feat/git-pull-dirty-worktree
qwen-code-dev-bot Aug 27, 2026
7688cc3
Merge branch 'main' into feat/git-pull-dirty-worktree
qwen-code-dev-bot Aug 28, 2026
690817a
Merge branch 'main' into feat/git-pull-dirty-worktree
wenshao Aug 28, 2026
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
80 changes: 80 additions & 0 deletions docs/design/git-pull-dirty-worktree.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
# Git pull with a dirty working tree

## Goal

Let the Web Shell "Update Project" action succeed when the workspace has
uncommitted changes, instead of dead-ending at an opaque
`dirty_working_tree` error that forces users back to a terminal.

## Problem

The workspace git pull path (branch picker popover → SDK
`workspaceGitPull` → `POST /workspace(s)/git/pull` → core `gitPull`) ran a
plain `git pull`. With local modifications to files the incoming merge
touches, git refuses to merge; the route already classified that failure as
`409 dirty_working_tree` with a path-redacted git message, but the popover
only rendered the SDK's raw error string, so nothing actionable surfaced.

## Behavior

`POST /workspace/git/pull` and `POST /workspaces/:workspace/git/pull`
accept two new boolean options (both default false, mutually exclusive —
sending both is a 400):

- `stash`: stash local changes (`git stash push --include-untracked`, so
untracked files that could block the merge are covered), run the pull,
then `git stash pop`. If the pull fails, any partial merge/rebase is
aborted and the stash is popped back, restoring the pre-pull state. If
the restore conflicts after a successful pull, the response is still a
success with `stashRestoreConflict: true` and its `output` carries git's
conflict notice; git keeps the stash entry, so nothing is lost.
- `force`: discard all local changes first (`git reset --hard` +
`git clean -fd`; ignored files are kept), then pull. Destructive.
Both commands act on the whole repository, so `force` is refused when
the workspace cwd is below the repository root (it could destroy
tracked changes outside the workspace while leaving the files that
block the merge in place). The pull is validated before anything is
discarded: the fetch runs first and a diverged branch (local commits
on both sides) is refused, because the post-discard merge could wedge
the repository mid-merge; a failed post-discard pull still aborts any
partial merge/rebase.

Stash detection compares `refs/stash` before/after the push instead of
parsing git output, which varies by version and locale. The pull itself
passes `--no-rebase --no-edit` when rebase is not requested, so divergent
branches merge instead of fataling on git builds without a `pull.rebase`
/ `pull.ff` policy.

## UI

When the plain pull fails with `409 dirty_working_tree`, the branch picker
footer switches from the status line to a resolution panel offering:

- **Stash Changes and Update** — pulls with `stash: true`.
- **Discard Changes and Update…** — destructive, so it takes a second
click: the panel swaps to a warning plus a confirm button that pulls with
`force: true`.
- **Cancel** — dismisses the panel.

The panel stays mounted (with a spinner on the clicked button) while its
own stash/discard pull is in flight, and resets whenever the popover is
reopened. When the blocking state is an unmerged working tree (conflicting
restore or abandoned merge), the stash option is replaced by an
explanation — `git stash push` refuses unmerged entries — leaving discard
as the recovery path.

## Ownership

Both routes keep their existing scoping: the legacy route is
legacy-primary-workspace scoped; the qualified route resolves the trusted
runtime and contained `cwd` exactly as before. The new options only change
which git commands run inside the resolved workspace and add no new trust
surface.

## Alternatives considered

Automatic stash on every dirty pull was rejected: silently moving the
user's changes through a stash is surprising, and the discard option is
destructive, so both need an explicit choice. `git pull --autostash` was
not usable because autostash only exists for rebase, while the default
pull here is a merge.
208 changes: 208 additions & 0 deletions packages/cli/src/serve/routes/workspace-git-branches.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,33 @@ describe('workspace Git branch routes', () => {
expect(response.body.error).toBe('invalid_rebase');
});

it('rejects a wrong-typed stash with 400', async () => {
const response = await request(app())
.post('/workspace/git/pull')
.send({ stash: 'yes' });

expect(response.status).toBe(400);
expect(response.body.error).toBe('invalid_stash');
});

it('rejects a wrong-typed force with 400', async () => {
const response = await request(app())
.post('/workspace/git/pull')
.send({ force: 'yes' });

expect(response.status).toBe(400);
expect(response.body.error).toBe('invalid_force');
});

it('rejects combining stash and force with 400', async () => {
const response = await request(app())
.post('/workspace/git/pull')
.send({ stash: true, force: true });

expect(response.status).toBe(400);
expect(response.body.error).toBe('invalid_stash_force');
});

it('rejects a checkout with a missing ref with 400', async () => {
const response = await request(app())
.post('/workspace/git/checkout')
Expand Down Expand Up @@ -187,6 +214,47 @@ function appWithWorkspace(cwd: string) {
return app;
}

// Repo with a remote commit that modified a.txt, plus an uncommitted local
// edit to the same file — a plain pull fails with a dirty working tree. The
// edits land in separate hunks so a stash/pull/pop round trip merges cleanly.
function makeDirtyPullRepo(): string {
const dir = makeRepo();
const lines = Array.from({ length: 10 }, (_, i) => `line ${i + 1}`);
fs.writeFileSync(path.join(dir, 'a.txt'), `${lines.join('\n')}\n`);
git(dir, 'add', '.');
git(dir, 'commit', '-q', '-m', 'extend a.txt');

const remote = fs.realpathSync(
fs.mkdtempSync(path.join(os.tmpdir(), 'qwen-gitbranch-remote-')),
);
tmpRoots.push(remote);
git(remote, 'init', '-q', '--bare');
git(dir, 'remote', 'add', 'origin', remote);
git(dir, 'push', '-q', '-u', 'origin', 'HEAD');

const clone = fs.realpathSync(
fs.mkdtempSync(path.join(os.tmpdir(), 'qwen-gitbranch-clone-')),
);
tmpRoots.push(clone);
git(clone, 'clone', '-q', remote, '.');
git(clone, 'config', 'user.email', 'other@example.com');
git(clone, 'config', 'user.name', 'Other');
git(clone, 'config', 'commit.gpgsign', 'false');
const remoteContent = fs
.readFileSync(path.join(clone, 'a.txt'), 'utf8')
.replace('line 10', 'line 10 remote');
fs.writeFileSync(path.join(clone, 'a.txt'), remoteContent);
git(clone, 'add', '.');
git(clone, 'commit', '-q', '-m', 'remote edit');
git(clone, 'push', '-q', 'origin', 'HEAD');

const localContent = fs
.readFileSync(path.join(dir, 'a.txt'), 'utf8')
.replace('line 1\n', 'line 1 local\n');
fs.writeFileSync(path.join(dir, 'a.txt'), localContent);
return dir;
}

afterEach(() => {
for (const dir of tmpRoots.splice(0)) {
fs.rmSync(dir, { recursive: true, force: true });
Expand Down Expand Up @@ -245,6 +313,146 @@ describe('workspace Git branch routes against a real repo (R10 #2)', () => {
expect(response.body.error).toBe('no_upstream');
});

it('classifies a plain pull on a dirty tree as dirty_working_tree', async () => {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Deferred as a test change, but the hazard this round's Critical fix already neutralizes: R4-19 landed --no-autostash on both update commands (git merge --no-edit --no-autostash @{u} / git rebase --no-autostash @{u}). Probe-verified on this host: with repo-local merge.autostash=true/rebase.autostash=true (the same channel as system config, higher precedence), both commands still refuse the dirty tree, so this test can no longer receive 200 instead of 409 from ambient autostash. The suggested makeRepo() pin remains available as belt-and-braces next round.

作为测试改动延后,但其危害已被本轮的 Critical 修复中和:R4-19 已给两个更新命令加上 --no-autostash(git merge --no-edit --no-autostash @{u} / git rebase --no-autostash @{u})。本机探针验证:仓库本地 merge.autostash=true/rebase.autostash=true(与系统配置同通道、更高优先级)时,两个命令仍拒绝脏树,本测试不会再因环境 autostash 得到 200 而非 409。建议的 makeRepo() 钉住仍可作为下一轮的额外保险。

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Deferred to the next round: this round implemented the review rounds 7-8 Critical batch (collision-probe restructure to enumeration, cherry-pick/revert foreign-state guard, fail-closed stash probe, merge/rebase of the captured tip, stash-restore note kept outside the message cap, and the serialization-test gate) under the per-round batch cap and the time budget this workflow flagged. This finding stays queued for the next round rather than being dropped. R4-13 fixture-duplication pattern stays queued (location 1 of 3).

顺延至下一轮:本轮在单轮批次上限与工作流时间预算内实现了第 7/8 轮评审的 Critical 批次(将冲突探测重构为枚举比对、cherry-pick/revert 外部状态守卫、暂存探测失败即拒绝、合并/变基到已捕获的上游 tip、让暂存恢复提示在消息截断时保留、并发串行化测试的环境门槛)。该发现继续排队等待下一轮处理,不会被丢弃。

const dir = makeDirtyPullRepo();

const response = await request(appWithWorkspace(dir))
.post('/workspace/git/pull')
.send({});

expect(response.status).toBe(409);
expect(response.body.error).toBe('dirty_working_tree');
const body = JSON.stringify(response.body);
expect(body).not.toContain(dir);
});

it('updates a dirty tree and restores the local changes with stash', async () => {
const dir = makeDirtyPullRepo();

const response = await request(appWithWorkspace(dir))
.post('/workspace/git/pull')
.send({ stash: true });

expect(response.status).toBe(200);
expect(response.body.success).toBe(true);
const expected = [
'line 1 local',
...Array.from({ length: 8 }, (_, i) => `line ${i + 2}`),
'line 10 remote',
];
expect(fs.readFileSync(path.join(dir, 'a.txt'), 'utf8')).toBe(
`${expected.join('\n')}\n`,
);
expect(git(dir, 'stash', 'list').trim()).toBe('');
});

it('discards the local changes and updates with force', async () => {
const dir = makeDirtyPullRepo();

const response = await request(appWithWorkspace(dir))
.post('/workspace/git/pull')
.send({ force: true });

expect(response.status).toBe(200);
expect(response.body.success).toBe(true);
const expected = [
...Array.from({ length: 9 }, (_, i) => `line ${i + 1}`),
'line 10 remote',
];
expect(fs.readFileSync(path.join(dir, 'a.txt'), 'utf8')).toBe(
`${expected.join('\n')}\n`,
);
});

it('reports stashRestoreConflict when the stash restore conflicts', async () => {
const dir = makeDirtyPullRepo();
// Also edit the same line the remote changed, so the stash pop
// conflicts after the pull succeeds.
const localContent = fs
.readFileSync(path.join(dir, 'a.txt'), 'utf8')
.replace('line 10', 'line 10 local');
fs.writeFileSync(path.join(dir, 'a.txt'), localContent);

const response = await request(appWithWorkspace(dir))
.post('/workspace/git/pull')
.send({ stash: true });

expect(response.status).toBe(200);
expect(response.body.success).toBe(true);
expect(response.body.stashRestoreConflict).toBe(true);
});

it('classifies a pull blocked by unmerged files as dirty_working_tree', async () => {
const dir = makeRepo();
const remote = fs.realpathSync(
fs.mkdtempSync(path.join(os.tmpdir(), 'qwen-gitbranch-remote-')),
);
tmpRoots.push(remote);
git(remote, 'init', '-q', '--bare');
git(dir, 'remote', 'add', 'origin', remote);
git(dir, 'push', '-q', '-u', 'origin', 'HEAD');

const clone = fs.realpathSync(
fs.mkdtempSync(path.join(os.tmpdir(), 'qwen-gitbranch-clone-')),
);
tmpRoots.push(clone);
git(clone, 'clone', '-q', remote, '.');
git(clone, 'config', 'user.email', 'other@example.com');
git(clone, 'config', 'user.name', 'Other');
git(clone, 'config', 'commit.gpgsign', 'false');

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Deferred to the next round. The six pasted 'bare remote + clone + identity + push' sequences in the route test file are real at HEAD; file-local makeBareRemote()/makeClone() helpers mirroring the core file are queued.

中文说明

延后到下一轮。路由测试文件中六处粘贴的『裸远端 + 克隆 + 身份配置 + 推送』序列在 HEAD 上确实存在;镜像 core 文件新增文件级 makeBareRemote()/makeClone() 助手已排入下一轮。

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Deferred to the next round: this round implemented the review rounds 7-8 Critical batch (collision-probe restructure to enumeration, cherry-pick/revert foreign-state guard, fail-closed stash probe, merge/rebase of the captured tip, stash-restore note kept outside the message cap, and the serialization-test gate) under the per-round batch cap and the time budget this workflow flagged. This finding stays queued for the next round rather than being dropped. R5-12 fixture-helper extraction stays queued.

顺延至下一轮:本轮在单轮批次上限与工作流时间预算内实现了第 7/8 轮评审的 Critical 批次(将冲突探测重构为枚举比对、cherry-pick/revert 外部状态守卫、暂存探测失败即拒绝、合并/变基到已捕获的上游 tip、让暂存恢复提示在消息截断时保留、并发串行化测试的环境门槛)。该发现继续排队等待下一轮处理,不会被丢弃。

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Declined. The repeated bare-remote + clone blocks that remain do so because each shapes the fixture differently (divergent commits, unmerged index, cap-sized file counts); the shared shapes already live in makeRepo/makeDirtyPullRepo/makeUnmergedRepo. Folding the remaining variants into one more helper would trade readable, grep-able fixtures for indirection without a defect being fixed — not worth the diff growth this late in the PR.

中文说明

拒绝。仍然保留的"裸远端 + 克隆"重复块各自需要不同的夹具形态(分叉提交、未合并索引、超大文件数量);共同的形态已经收敛进 makeRepo/makeDirtyPullRepo/makeUnmergedRepo。把剩余变体再塞进一个助手只会用间接层替换可读、可 grep 的夹具,且不修复任何缺陷 —— 在 PR 的这个阶段不值得为此扩大 diff。

fs.writeFileSync(path.join(clone, 'a.txt'), 'remote change\n');
git(clone, 'add', '.');
git(clone, 'commit', '-q', '-m', 'remote edit');
git(clone, 'push', '-q', 'origin', 'HEAD');

// Divergent local commit touching the same file, then a failed merge:
// the index now carries unmerged entries.
fs.writeFileSync(path.join(dir, 'a.txt'), 'local change\n');
git(dir, 'add', '.');
git(dir, 'commit', '-q', '-m', 'local edit');
git(dir, 'fetch', '-q', 'origin');
let mergeFailed = false;
try {
git(
dir,
'merge',
'origin/' + git(dir, 'symbolic-ref', '--short', 'HEAD').trim(),
);
} catch {
mergeFailed = true;
}
expect(mergeFailed).toBe(true);

const response = await request(appWithWorkspace(dir))
.post('/workspace/git/pull')
.send({});

expect(response.status).toBe(409);
expect(response.body.error).toBe('dirty_working_tree');
const body = JSON.stringify(response.body);
expect(body).not.toContain(dir);
});

it('rejects force pull from a subdirectory workspace without discarding', async () => {
const dir = makeDirtyPullRepo();
const sub = path.join(dir, 'packages', 'app');
fs.mkdirSync(sub, { recursive: true });
const before = fs.readFileSync(path.join(dir, 'a.txt'), 'utf8');

const response = await request(appWithWorkspace(sub))
.post('/workspace/git/pull')
.send({ force: true });

expect(response.status).toBe(500);
expect(response.body.error ?? response.body.message).toContain(
'subdirectory',
);
const body = JSON.stringify(response.body);
expect(body).not.toContain(dir);
// Nothing was discarded.
expect(fs.readFileSync(path.join(dir, 'a.txt'), 'utf8')).toBe(before);
});

it('does not misclassify a non-dirty error when the workspace path contains "dirty"', async () => {
const parent = fs.realpathSync(
fs.mkdtempSync(path.join(os.tmpdir(), 'qwen-dirty-utils-')),
Expand Down
33 changes: 31 additions & 2 deletions packages/cli/src/serve/routes/workspace-git-branches.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,15 @@ function sendGitError(
res.status(404).json({ error: 'not_a_git_repository', message });
return;
}
if (/dirty|uncommitted|would be overwritten/i.test(message)) {
// `unmerged files` / `have not concluded your merge` cover the states a
// conflicting stash restore or an abandoned merge leaves behind: pull and
// stash both refuse until the conflicts are resolved, so the resolution
// panel (whose discard path clears the state) must reappear for them.
if (
/dirty|uncommitted|would be overwritten|unmerged files|have not concluded your merge/i.test(

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Stopgap implemented this round; structural closure deferred to the follow-up queue. The route classifier now covers the four demonstrated entrances — needs merge (stash refusal on an unmerged tree), CONFLICT ( / Automatic merge failed (a stash pull whose merge conflicted and was aborted back to the dirty state), and the diverged-branch force refusal (has diverged from its upstream) — and the client's unmerged detection adds needs merge, so the panel reappears with the correct option set for each state. Three new route tests pin the entrances; a mutation probe reverting the alternation flips all three. The structural fix you describe (daemon-computed authoritative state with structured fields/error codes, client branching on fields instead of text at both layers) is a cross-package error-surface redesign; it is recorded in deferred-findings.json for scheduling rather than wedged into this round.

中文说明

本轮已实现权宜方案;结构性闭合记入延后跟进队列。路由分类器现已覆盖四个实证入口 —— needs merge(未合并树上 stash 的拒绝)、CONFLICT ( / Automatic merge failed(stash pull 合并冲突后被中止回脏状态)、分叉分支 force 拒绝(has diverged from its upstream)—— 客户端未合并检测也加入 needs merge,使面板在每种状态下以正确的选项组合重现。三个新路由测试钉住各入口;把备选回退的变异探针使三者全部翻转。你描述的结构性修复(守护进程权威计算状态、响应携带结构化字段/错误码、客户端两层都按字段分支)是跨包的错误面重构;已记入 deferred-findings.json 另行排期,而不是挤进本轮。

message,
)
) {
res.status(409).json({ error: 'dirty_working_tree', message });
return;
}
Expand Down Expand Up @@ -270,10 +278,31 @@ async function handlePull(
});
return;
}
if (body['stash'] !== undefined && typeof body['stash'] !== 'boolean') {
res
.status(400)
.json({ error: 'invalid_stash', message: 'stash must be a boolean' });
return;
}
if (body['force'] !== undefined && typeof body['force'] !== 'boolean') {
res
.status(400)
.json({ error: 'invalid_force', message: 'force must be a boolean' });
return;
}
const rebase = body['rebase'] === true;
const fetchOnly = body['fetchOnly'] === true;
const stash = body['stash'] === true;
const force = body['force'] === true;
if (stash && force) {
res.status(400).json({
error: 'invalid_stash_force',
message: 'stash and force are mutually exclusive',
});
return;
}
try {
const result = await gitPull(cwd, { rebase, fetchOnly }, env);
const result = await gitPull(cwd, { rebase, fetchOnly, stash, force }, env);
res.status(200).json(result);
} catch (err) {
sendGitError(res, err, route, sendBridgeError, cwd);
Expand Down
Loading
Loading