Skip to content

fix(core): validate git pull option combinations and detached HEAD - #10752

Merged
wenshao merged 1 commit into
QwenLM:mainfrom
wenshao:fix/git-pull-option-validation
Sep 2, 2026
Merged

wenshao merged 1 commit into
QwenLM:mainfrom
wenshao:fix/git-pull-option-validation

Conversation

@wenshao

@wenshao wenshao commented Sep 2, 2026 •

Copy link
Copy Markdown
Collaborator

What this PR does

Post-merge review follow-ups for the dirty-worktree git update (#10390), from the post-merge code review:

  • Option combinations are validated instead of silently resolved. { fetchOnly: true, stash: true } (or force) used to perform a bare fetch and drop the resolution the caller asked for, because the fetchOnly branch ran first. The route now refuses the combination with 400 invalid_fetch_only_combination, and gitPull carries a mirrored guard, matching the existing stash+force exclusivity.
  • Detached HEAD is a typed refusal. A stash/force pull on a detached HEAD surfaced git's raw HEAD does not point to a branch through an unclassified 500 (SDK-only surface — the UI already disables the row). It is now 409 pull_failed ("HEAD is detached; check out a branch from a terminal first"), thrown before anything is touched.
  • validatedUpstream restructured. The branch is resolved once and the configured upstream read inline; the no-upstream tail now documents the one probe race that can reach it instead of looking unreachable.
  • The resolution panel drops Discard after force_unsupported. That refusal is permanent for a subdirectory workspace, so re-offering the action could only loop the same refusal; the panel keeps Stash (still viable) and Cancel, with the daemon's explanation.
  • Design doc: a stored-back stash entry lands on top of the stack rather than its old slot (position is not identity here), and the plain-pull invariant is stated precisely — same git invocation as before; the output is path-redacted like every other response.

Why it's needed

The silent fetchOnly win is the one Important finding from the post-merge review: the feature's own design refuses silent option drops in favor of typed codes, and the SDK now documents these options publicly, so a future caller can hit the combination. The rest closes the review's minor findings while they are fresh.

Reviewer Test Plan

How to verify

  • Core (cd packages/core && npx vitest run src/utils/git-branches.test.ts, 97 passed, 2 new): fetchOnly+stash/force both reject with "cannot be combined"; a detached-HEAD stash and force pull both refuse as typed pull_failed mentioning the detached HEAD, with HEAD, the edit, and the (empty) stash untouched.
  • Route (cd packages/cli && npx vitest run src/serve/routes/workspace-git-branches.test.ts, 36 passed, 2 new): both combinations → 400 invalid_fetch_only_combination.
  • Web Shell (cd packages/web-shell && npx vitest run client/components/BranchPickerPopover.test.tsx, 42 passed): the force_unsupported panel test now also asserts the Discard action is absent while Stash remains.
  • tsc --noEmit clean in core, cli, and web-shell; eslint clean on all changed files.

Evidence (Before & After)

Behavioral surface is API-level plus one panel state reachable only for subdirectory workspaces driven via the SDK; pinned by the component test above rather than screenshots. Before: POST /workspace/git/pull {"fetchOnly":true,"stash":true} → 200 with a bare fetch. After: 400 {"error":"invalid_fetch_only_combination"}.

Tested on

OS Status
🍏 macOS ✅
🪟 Windows ⚠️
🐧 Linux ⚠️

Environment (optional)

Unit tests against real local git repositories, as in #10390.

Risk & Scope

  • Main risk or tradeoff: the new 400 rejects requests that previously "succeeded" as a bare fetch — that success was a silent misinterpretation, and no in-repo caller sends the combination (the popover sends stash/force only from the resolution panel).
  • Not validated / out of scope: the review's remaining note — verifying parseDroppedStashSha's Dropped refs/stash@{N} (<sha>) format against Git for Windows — needs a Windows environment; the shim-based concurrency tests stay skipIf(win32).
  • Breaking changes / migration notes: none for documented usage; the option combination was never meaningful.

Linked Issues

Follow-up to #10390.

中文说明

这个 PR 做了什么

#10390(脏工作区 git 更新)合并后评审的后续修复,来自合并后代码评审:

  • 选项组合改为校验而非静默取舍。 { fetchOnly: true, stash: true }(或 force)过去会因 fetchOnly 分支先行而只做裸 fetch、丢掉调用方要求的处理方式。路由现在以 400 invalid_fetch_only_combination 拒绝该组合,gitPull 内有对称守卫,与既有的 stash+force 互斥一致。
  • 游离 HEAD 是 typed 拒绝。 游离 HEAD 上的 stash/force pull 过去把 git 原始的 HEAD does not point to a branch 透传成未分类 500(仅 SDK 面可达——UI 已禁用该行)。现在在动任何东西之前就以 409 pull_failed("HEAD 已游离,请先在终端检出分支")拒绝。
  • validatedUpstream 重构。 分支只解析一次、内联读取配置的上游;无上游尾部现在明确注明唯一可达它的探测竞态,而不是看起来不可达。
  • force_unsupported 后面板不再提供放弃。 该拒绝对子目录工作区是永久的,再次提供只会循环同一拒绝;面板保留仍可用的 Stash 与取消,并显示 daemon 的解释。
  • 设计文档:回存的 stash 条目落在栈顶而非原槽位(此处位置不代表身份);精确表述裸 pull 不变量——git 调用与之前完全相同,output 与其它响应一样做路径脱敏。

为什么需要

fetchOnly 静默胜出是合并后评审唯一的 Important 发现:该特性自身的设计就拒绝静默丢弃选项而偏向 typed 错误码,且 SDK 已公开记录这些选项,未来调用方可能踩中该组合。其余改动趁热收掉评审的 Minor 项。

评审测试计划

如何验证

  • Core(cd packages/core && npx vitest run src/utils/git-branches.test.ts,97 通过,新增 2):fetchOnly+stash/force 都以 "cannot be combined" 拒绝;游离 HEAD 上的 stash 与 force pull 都以 typed pull_failed 拒绝且消息提及游离 HEAD,HEAD、修改与(空)stash 均未动。
  • 路由(cd packages/cli && npx vitest run src/serve/routes/workspace-git-branches.test.ts,36 通过,新增 2):两种组合 → 400 invalid_fetch_only_combination。
  • Web Shell(cd packages/web-shell && npx vitest run client/components/BranchPickerPopover.test.tsx,42 通过):force_unsupported 面板用例现在同时断言放弃按钮消失、Stash 保留。
  • core、cli、web-shell 三包 tsc --noEmit 干净;改动文件 eslint 干净。

前后对比证据

行为面在 API 层,外加一个只有子目录工作区经 SDK 才可达的面板状态;由上述组件测试钉住,未截图。改动前:POST /workspace/git/pull {"fetchOnly":true,"stash":true} → 200 只做裸 fetch。改动后:400 {"error":"invalid_fetch_only_combination"}。

测试环境

操作系统 状态
🍏 macOS ✅
🪟 Windows ⚠️
🐧 Linux ⚠️

环境(可选)

与 #10390 相同,针对真实本地 git 仓库的单元测试。

风险与范围

  • 主要风险或权衡:新 400 会拒绝过去"成功"(实为裸 fetch 的静默误解释)的请求;仓库内没有调用方发送该组合(弹窗只在面板内发送 stash/force)。
  • 未验证 / 超出范围:评审剩下的一条——在 Git for Windows 上核对 parseDroppedStashSha 的 Dropped refs/stash@{N} (<sha>) 消息格式——需要 Windows 环境;垫片并发测试维持 skipIf(win32)。
  • 破坏性变更 / 迁移说明:对文档化用法无;该选项组合从未有过有意义的语义。

关联 Issue

#10390 的后续。

Post-merge review follow-ups for the dirty-worktree pull (QwenLM#10390):

- fetchOnly combined with stash or force was silently winning: the flow
  performed a bare fetch and dropped the resolution the caller asked
  for. Both combinations are refused now — a 400
  invalid_fetch_only_combination at the route and a mirrored guard in
  gitPull — matching the existing stash+force exclusivity.
- A stash or force pull on a detached HEAD surfaced git's raw
  "HEAD does not point to a branch" through an unclassified 500; it is a
  typed pull_failed refusal now, before anything is touched.
- validatedUpstream resolves the branch once and reads the configured
  upstream inline, so the no-upstream tail is explicit about the one
  probe race that can reach it instead of looking unreachable.
- The resolution panel no longer offers Discard after the daemon refused
  it with force_unsupported: the refusal is permanent for a subdirectory
  workspace, so re-offering it could only loop.
- Design doc: note that a stored-back stash entry lands on top of the
  stack, and state the plain pull's invariant precisely (same git
  invocation; output now path-redacted like every other response).
@wenshao

wenshao commented Sep 2, 2026

Copy link
Copy Markdown
Collaborator Author

@qwen-code /review

@github-actions

github-actions Bot commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

Qwen Code review request accepted. Review is running in workflow run. A command-triggered review is not listed under the checks of this PR; the result is posted here as a review when it finishes.

@qqqys

qqqys commented Sep 2, 2026

Copy link
Copy Markdown
Collaborator

E2E verification report (independent reviewer run, head 012569e1ee2c).

Static review (at head)

  • validatedUpstream is only reached from stashPull/forcePull, in both after the flow's own fetch and before stash push / reset --hard — the new detached-HEAD refusal fires before anything is touched. The plain-pull path never calls it (byte-identical git pull invocation preserved).
  • Route guard ordering: type checks → invalid_stash_force → new invalid_fetch_only_combination → gitPull; core mirrors the combination guard for non-HTTP callers. GitPullFailure maps to 409 by code, plain errors keep the pre-existing classifier path.
  • Web Shell: pullBlockedDetail is set only in the isForceUnsupportedError branch and cleared on every other path (open/reset/dirty-tree), so Discard disappears exactly where the daemon declared it impossible (force_unsupported) and stays for dirty_working_tree.

Unit tests at head (scratch npm ci tree, hermetic env)

  • packages/core git-branches.test.ts: 96/97 — the 1 failure (types the refusal with a lock hint when the index is wedged) is pre-existing host noise: it fails identically on the base tree (77d41f48d36f) because this host's git 2.43.7 emits error: could not write index where the test expects the word lock. Not caused by this PR; the PR's own new tests (combination guard, detached-HEAD typed refusal incl. HEAD/edit/stash untouched) pass.
  • packages/cli workspace-git-branches.test.ts: 36/36 (both new 400 cases included).
  • packages/web-shell BranchPickerPopover.test.tsx: 42/42 (Discard-absent assertion included).

Live daemon A/B (tmux, bundled head vs bundled base 77d41f48, real git fixture workspace, loopback qwen serve):

Case Base Head
{fetchOnly:true} 200 200 (control)
{fetchOnly:true, stash:true} 200 — bare fetch, stash silently dropped (the bug) 400 invalid_fetch_only_combination
{fetchOnly:true, force:true} 200 silent drop 400 invalid_fetch_only_combination
{stash:true, force:true} 400 invalid_stash_force 400 (control, unchanged)
detached HEAD + {stash:true} 500 raw fatal: HEAD does not point to a branch 409 pull_failed — "HEAD is detached; check out a branch from a terminal first"
detached HEAD + {force:true} 500 raw 409 pull_failed (same message)
dirty tree, behind 1, {stash:true} — 200: fast-forward, local edit restored, git stash list empty

After each refusal (both builds): the dirty edit survived and the stash list stayed empty — nothing touched.

No Critical findings. CI at head is green on all product lanes (only the review-pr meta lane still pending), and ci-bot has approved at head — approving.

@qqqys qqqys left a comment

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.

Approving per the review round: independent verification report posted above (static review at head, unit suites green, live daemon A/B base-vs-head on a real git fixture). No Critical findings. ci-bot approval stands at this exact head and all product CI lanes are green (only the review-pr meta lane pending).

@wenshao
wenshao added this pull request to the merge queue Sep 2, 2026
Merged via the queue into QwenLM:main with commit 3de2e67 Sep 2, 2026
180 of 181 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants