Skip to content

fix(core): keep branch commits made by a failing post-checkout hook - #11300

Merged
wenshao merged 4 commits into
QwenLM:mainfrom
dvd233:fix/git-create-branch-rollback
Sep 8, 2026
Merged

wenshao merged 4 commits into
QwenLM:mainfrom
dvd233:fix/git-create-branch-rollback

Conversation

@dvd233

@dvd233 dvd233 commented Sep 7, 2026 •

Copy link
Copy Markdown
Contributor

What this PR does

Fixes a data-loss path in branch creation rollback. gitCreateBranch treats any non-zero git checkout -b result as a half-created branch and unconditionally runs git branch -D <name> during rollback. But git checkout -b creates the ref and switches HEAD before running the post-checkout hook, so a failing hook leaves the workspace already on the new branch — and if that hook created a commit there, the force-delete discards it (git fsck --unreachable then reports the commit as unreachable from any ref).

On rollback, the new branch's HEAD is now compared against the start commit (the resolved startPoint, or the pre-checkout HEAD). The branch is only force-deleted when its HEAD has not moved; when a failing hook has moved it (created commits), the branch is kept and a debugLogger.warn is emitted instead.

Why it's needed

A failing post-checkout hook currently causes gitCreateBranch to delete a branch that may already carry hook-created commits, silently losing that work. Raw git checkout -b <name> -- with the same failing hook leaves the branch checked out and keeps the commit, so the rollback was stricter than git itself.

Reviewer Test Plan

How to verify

Install a post-checkout hook that commits then exits non-zero, then call gitCreateBranch(repo, 'topic'):

  • Before: the call rejects, HEAD is restored, and topic is force-deleted — the hook-created commit becomes unreachable.
  • After: the call still rejects and HEAD is restored, but topic is kept because it carries the hook-created commit, which stays reachable from the branch.

Covered by packages/core/src/utils/git-branches.test.ts: the new keeps a branch that a failing post-checkout hook committed to fails before this change and passes after; the existing rolls back a branch created before a failing post-checkout hook (hook makes no commit) still passes, confirming the no-commit path is unchanged.

Evidence (Before & After)

N/A (core git utility; unit tests above). Verified locally on Windows with the minimal hook from the issue: git checkout -b topic -- exits 1 with the branch already created and pointing at the hook-created commit, and the old rollback deleted it (git fsck --unreachable confirmed the commit was orphaned).

Tested on

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

Environment (optional)

N/A — unit tests only (npx vitest run src/utils/git-branches.test.ts).

Risk & Scope

  • Main risk or tradeoff: a kept branch whose hook failed remains in the branch list; callers that assume a thrown error means "no branch exists" would now see it. The branch picker listing is not re-read after a failed create.
  • Not validated / out of scope: the rollback's restore-checkout re-runs the failing hook against the original branch (pre-existing behavior); the kept-branch signal is not surfaced to callers beyond the log warning.
  • Breaking changes / migration notes: none.

Linked Issues

Fixes #11253

中文说明

本 PR 修复分支创建回滚中的一处数据丢失。gitCreateBranch 把任何非零的 git checkout -b 结果都当作"半创建分支",回滚时无条件执行 git branch -D。但 git checkout -b 会先创建 ref 并切换 HEAD、再运行 post-checkout 钩子,所以钩子失败时工作区其实已在新分支上——若钩子已在新分支上创建了提交,强制删除会丢弃该提交(git fsck --unreachable 会显示它不再可从任何引用到达)。

修复后,回滚时比较新分支的 HEAD 与起点 commit(解析后的 startPoint,或 checkout 前的 HEAD):仅当 HEAD 未移动时才强制删除分支;若失败的钩子已移动它(创建了提交),则保留分支并通过 debugLogger.warn 记录,而不是丢弃这些工作。

之所以需要:当前失败的 post-checkout 钩子会让 gitCreateBranch 删掉一个可能已带钩子提交的分支,静默丢失工作。原生 git checkout -b <name> -- 在同样的钩子失败下会保留分支和提交,因此原回滚比 git 本身更激进。

验证方式:安装一个"先提交再以非零退出"的 post-checkout 钩子,然后调用 gitCreateBranch(repo, 'topic')。修复前:调用拒绝、HEAD 恢复、topic 被强删、钩子提交变为不可达;修复后:调用仍拒绝、HEAD 恢复,但 topic 因携带钩子提交而被保留、提交仍可达。新增测试 keeps a branch that a failing post-checkout hook committed to 修复前失败、修复后通过;既有测试(钩子不产生提交时删除分支)前后均通过。已在 Windows 上用 issue 中的最小钩子实测复现。

gitCreateBranch treated any non-zero git checkout -b result as a
half-created branch and unconditionally ran git branch -D during
rollback. When a post-checkout hook had already created a commit on the
new branch, that delete discarded the commit (unreachable via any ref).

On rollback, compare the new branch's HEAD with the start commit and
keep the branch when it has moved — matching raw git's behavior of
leaving the branch in place. Branches without new commits are still
removed as before.

Fixes QwenLM#11253

@yiliang114 yiliang114 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 at this head.

The round-3 bot CR has been stale since the author's fixes landed and every thread was resolved with a cited SHA. I re-checked the one Critical at this head rather than trusting the resolution:

  • R3-1 — the keep-guard no longer requires a non-empty startCommit. At head git-branches.ts:481 it reads if (newHead && newHead !== startCommit), so when the pre-checkout rev-parse collapsed to '' (timeout, contended object store, or the TOCTOU fetch race), newHead !== '' is always true for a resolved newHead and the code takes the keep arm (:482-484, which also logs the warning) instead of the destructive branch -D at :486. That is exactly the minimal fix the finding proposed — the destructive arm now fires only when newHead itself fails to resolve or the head genuinely did not move.
  • R1-1 (startCommit captured with an unpeeled git rev-parse), R2-1 (|| vs ??), R2-2/R3-2/R3-3 — all resolved with author replies citing 17e7833b / 327b38a / c197a04ae and concrete mechanisms; round-3 ledger is floor: "o" with no live Critical.

The change stays in its stated scope (one guard + its tests, +159/-2).

@wenshao

wenshao commented Sep 8, 2026

Copy link
Copy Markdown
Collaborator

Local verification — merge-ready, no blockers

I rebuilt the failure from #11253 in a real environment rather than reading the diff: real repositories, real post-checkout hooks, and a real qwen serve daemon. The PR's merge base ac01395 and its head c197a04 were run side by side, with plain git checkout -b as a third, oracle arm.

The fix is correct, effective and well covered. I found nothing blocking. The one thing I would follow up on is that a sibling copy of the same rollback lives in the CLI and is untouched (N1).

What I ran

  • Module level. Each arm's own gitCreateBranch, compiled out of its worktree and driven over 21 throwaway repositories with real post-checkout hooks. The data-loss oracle is git fsck --unreachable --no-reflogs; the keep warning is read back out of the real debug log file (QWEN_HOME + QWEN_DEBUG_LOG_FILE=1), not a mock.
  • Suite level. packages/core/src/utils/git-branches.test.ts on both arms, plus a negative control (the PR's tests against the base's production source) and three mutations of the fix.
  • Product level. One CLI bundle built per arm, each run as a real qwen serve daemon and driven over HTTP on both branch-creation routes.

Scenario matrix

scenario matrix

9 of 21 scenarios flip from silent data loss to preserved, 10 are identical on both arms, none regressed. Wherever the hook creates a commit, the head arm's outcome now matches what plain git does with the same hook.

Two notes that keep the severity honest:

  • The orphaned commit is still named by the HEAD reflog, so before this fix it was recoverable with git reflog until the entry expires (git's 30-day gc.reflogExpireUnreachable default). The pre-fix behaviour is silent orphaning, not immediate destruction — still worth fixing, but a smaller claim than "the commit is gone".
  • An unborn HEAD cannot reach this path at all: git does not run post-checkout when checkout -b creates a branch from an unborn HEAD (checked directly), so the command succeeds and no rollback happens. That is what makes the rev-parse fail-safe added in c197a04 cheap — I could only reach that branch of the code with a shimmed git.

Does the added test discriminate the fix?

test discrimination

Yes, in both directions. Three of the four added tests fail against the base's production source, and three targeted mutations of the fix are each killed by the suite. The single failure present in every run (gitPull … lock hint) also fails at the merge base — it is an artefact of running this suite as root here, untouched by the PR.

End to end on a real daemon

daemon e2e

POST /workspace/git/branch — the daemon route behind DaemonClient.createBranch(), and the only production caller of gitCreateBranch — goes from "branch deleted, hook commit orphaned" to "branch and commit both survive". That is the fix doing real work on a real product surface, not just in unit tests.

Non-blocking observations

N1 — the same rollback shape survives in the CLI. rollbackBranchCreation (packages/cli/src/serve/routes/session.ts:948, reached from four call sites) restores the base ref and then calls deleteBranch unconditionally — exactly the pattern this PR replaces in core. It never goes through gitCreateBranch; it has its own createBranch in serve/server/git-branch-ops.ts. Driving POST /session {"branch":{"name":"topic"}} against the head bundle with the same hook still deletes the branch and orphans the commit (bottom half of the screenshot above). One nuance I measured: that delete is only reached when the restore checkout itself succeeds, i.e. when the hook fails only on the new branch — a hook that fails unconditionally makes rollbackBranchCreation bail out early and the branch survives by accident. I would leave this PR as it is and open a follow-up for session.ts rather than widen the diff, but it is worth saying on the issue, because Fixes #11253 will auto-close a report whose most user-visible path is still broken.

N2 — on the only production caller, the keep is completely silent. Round 1 already noted the debugLogger.warn "lands in the debug log only". Measured on the daemon it is stronger than that: createDebugLogger writes only while a DebugLogSession is active, and the daemon's HTTP route has none (setDebugLogSession is called from Config alone). With QWEN_DEBUG_LOG_FILE=1 the daemon log holds the route's [ERROR] route=POST /workspace/git/branch line and no GIT_BRANCHES line at all. The caller gets a 500 whose body is git's own Switched to a new branch 'topic' text, plus a branch it did not ask to keep. Not blocking — a surviving branch beats a dead commit — but if this is ever surfaced, the route's own daemonLog is the place.

N3 — the guard tests "the ref moved", not "the branch gained history". newHead !== startCommit also fires when a hook only rewinds the new branch (git reset --hard HEAD~1, a "reset to the release tag" hook), leaving a stray branch behind and logging "contains commits created after checkout" for a branch that contains strictly fewer. See the "hook only rewinds the branch" row: base deletes, head keeps. Harmless — a stray branch, not lost work — and arguably the safer default. A one-line ancestry test removes it; I ran it as a probe and the suite stays green (105 pass / 1 pre-existing fail, same as shipped) while the stray branch disappears:

const rewound = startCommit
  ? await runGit(cwd, ['merge-base', '--is-ancestor', newHead, startCommit], env)
      .then(() => true)
      .catch(() => false)
  : false;
if (newHead && newHead !== startCommit && !rewound) {

Your call whether that earns an extra subprocess on the failure path.

N4 — two shapes the new tests do not cover. The rewind shape from N3, and gitCreateBranch called inside a linked git worktree checkout. The worktree case behaves correctly on the head arm — the branch and the hook commit both survive — it is simply untested.

Not verified

  • macOS and Windows. Everything above is Linux, git 2.47.3, Node 22.22.2.
  • Concurrent branch creation against the same workspace.
  • The branch-picker UI listing after a kept branch (already declared out of scope in the PR body).

Status at c197a04

Lint & Static is green; Test (ubuntu) was still running when I posted this. The only thing still holding the merge is the round-3 bot CHANGES_REQUESTED, which is stale — its findings were addressed and @yiliang114 approved at this same head afterwards.

中文说明

本地验证 —— 可以合入,无阻塞项

我没有只读 diff,而是搭了一套真实环境复现 #11253:真实仓库、真实 post-checkout 钩子、真实的 qwen serve daemon。把 PR 的 merge base ac01395 与 head c197a04 并排跑,并用原生 git checkout -b 作为第三条「地面真值」臂。

这个修复是正确的、有效的,覆盖也扎实。我没有发现阻塞项。 唯一值得跟进的是 CLI 里还留着一份同形的回滚代码,本 PR 没有覆盖到(见 N1)。

跑了什么

  • 模块级。 两条臂各自从自己的 worktree 编译出 gitCreateBranch,在 21 个一次性 git 仓库上用真实 post-checkout 钩子驱动。数据丢失的判据是 git fsck --unreachable --no-reflogs;保留分支的告警是从真实的 debug 日志文件里读回来的(QWEN_HOME + QWEN_DEBUG_LOG_FILE=1),不是 mock。
  • 测试套件级。 在两条臂上跑 packages/core/src/utils/git-branches.test.ts,另加一个反向对照(用 PR 的测试跑 base 的生产代码)和三个针对修复本身的变异。
  • 产品级。 每条臂各构建一份 CLI bundle,各自作为真实 qwen serve daemon 启动,通过 HTTP 驱动两条建分支路由。

场景矩阵

21 个场景里有 9 个从「静默丢数据」翻转为「保住」,10 个在两条臂上完全一致,没有任何回归。凡是钩子创建了提交的场景,head 臂的结果现在与同样钩子下原生 git 的行为一致。

两点让严重度保持诚实的说明:

  • 被孤立的提交仍被 HEAD reflog 引用,所以修复前它在 reflog 条目过期之前(git 默认 gc.reflogExpireUnreachable 为 30 天)仍可用 git reflog 找回。修复前的行为是静默孤立,而不是立即销毁 —— 依然该修,但比「提交没了」这个说法要小一些。
  • unborn HEAD 根本进不到这条路径:当 checkout -b 从 unborn HEAD 建分支时 git 不会运行 post-checkout(我直接验证过),所以命令会成功,压根不会回滚。这也正是 c197a04 里那个 rev-parse 兜底代价很低的原因 —— 我只能靠 shim 掉 git 才能走到那个分支。

新增测试能否判别这个修复?

可以,而且是双向的。新增的四个测试里有三个在 base 的生产代码上失败;针对修复本身的三个定向变异全部被测试杀死。每次运行都出现的那一个失败(gitPull … lock hint)在 merge base 上同样失败 —— 那是本机以 root 身份跑这个套件的环境产物,与本 PR 无关。

真实 daemon 上的端到端验证

POST /workspace/git/branch —— DaemonClient.createBranch() 背后的 daemon 路由,也是 gitCreateBranch 唯一的生产调用方 —— 从「分支被删、钩子提交被孤立」变成「分支和提交都保住」。这说明修复在真实产品面上确实起作用,而不只是在单测里。

非阻塞观察

N1 —— CLI 里还留着同形的回滚。 rollbackBranchCreation(packages/cli/src/serve/routes/session.ts:948,有四处调用)先恢复 base ref,然后无条件调用 deleteBranch —— 正是本 PR 在 core 里替换掉的那个模式。它完全不经过 gitCreateBranch,而是用 serve/server/git-branch-ops.ts 里自己的 createBranch。用同样的钩子对 head bundle 打 POST /session {"branch":{"name":"topic"}},分支依然被删除、提交依然被孤立(见上面截图的下半部分)。我实测到一个细节:只有当恢复 checkout 本身成功时才会走到那次删除,也就是钩子只在新分支上失败的情况;如果钩子无条件失败,rollbackBranchCreation 会提前返回,分支反而侥幸活下来。我倾向于本 PR 保持现状、另开一个 follow-up 处理 session.ts,而不是把 diff 扩大;但这值得在 issue 上说明,因为 Fixes #11253 会自动关闭一个「最贴近用户的路径仍然是坏的」的报告。

N2 —— 在唯一的生产调用方上,这次「保留」完全没有声音。 第 1 轮已经指出 debugLogger.warn「只写进 debug 日志」。在 daemon 上实测的结论更强:createDebugLogger 只在存在活跃 DebugLogSession 时才写入,而 daemon 的 HTTP 路由没有(setDebugLogSession 只从 Config 调用)。在 QWEN_DEBUG_LOG_FILE=1 下,daemon 日志里只有该路由的 [ERROR] route=POST /workspace/git/branch,完全没有 GIT_BRANCHES 那一行。调用方拿到的是一个 body 为 git 自己那句 Switched to a new branch 'topic' 的 500,外加一个它并没要求保留的分支。这不阻塞 —— 分支活着总好过提交死掉 —— 但如果以后要把它暴露出来,该路由自己的 daemonLog 才是合适的位置。

N3 —— 这个守卫判的是「ref 动了」,而不是「分支多了历史」。 newHead !== startCommit 在钩子只是把新分支回退时也会命中(git reset --hard HEAD~1,比如一个「重置到发布 tag」的钩子),结果是留下一个多余分支,并且对一个提交数严格更少的分支打印「contains commits created after checkout」。见矩阵里「hook only rewinds the branch」那一行:base 删除,head 保留。危害有限 —— 只是多一个分支,不是丢工作 —— 而且从保守角度看反而更安全。一行祖先判断就能消除它;我把它当作探针跑过,套件仍然全绿(105 通过 / 1 个既有失败,与现版本一致),同时多余分支消失:

const rewound = startCommit
  ? await runGit(cwd, ['merge-base', '--is-ancestor', newHead, startCommit], env)
      .then(() => true)
      .catch(() => false)
  : false;
if (newHead && newHead !== startCommit && !rewound) {

值不值得在失败路径上多开一个子进程,由你判断。

N4 —— 新测试没有覆盖的两种形态。 一是 N3 里的回退形态;二是在 linked git worktree 检出目录里调用 gitCreateBranch。worktree 这种情况在 head 臂上行为是正确的(分支和钩子提交都保住),只是没有测试。

未验证

  • macOS 与 Windows。以上全部在 Linux 上完成,git 2.47.3,Node 22.22.2。
  • 同一 workspace 上的并发建分支。
  • 保留分支之后 branch picker 列表的表现(PR 正文已声明不在范围内)。

c197a04 上的状态

Lint & Static 已绿;发这条评论时 Test (ubuntu) 仍在运行。目前唯一还挡着合入的是第 3 轮 bot 的 CHANGES_REQUESTED,它已经过期 —— 相关问题都已处理,@yiliang114 之后在同一 head 上给了 approve。


🤖 Generated with Claude Code — Claude Opus 5 (1M context)

@wenshao
wenshao enabled auto-merge September 8, 2026 18:18
@wenshao

wenshao commented Sep 8, 2026

Copy link
Copy Markdown
Collaborator

@qwen-code /triage

@wenshao
wenshao dismissed a stale review September 8, 2026 23:20

fixed

@wenshao
wenshao added this pull request to the merge queue Sep 8, 2026
Merged via the queue into QwenLM:main with commit d670d47 Sep 8, 2026
136 of 137 checks passed
qwen-code-dev-bot added a commit to gauravyad86/qwen-code that referenced this pull request Sep 10, 2026
main landed QwenLM#11300, which stopped deleting a freshly created branch when a failing post-checkout hook had committed to it. This branch fixes the same bug with an atomic compare-and-delete plus typed rollback errors, which subsumes that guard, so keep this implementation and retain main's debug warning on both paths that preserve the branch.

Both sides also opened the same rollback describe block in the tests, so keep main's warn-spy reset and its four tests alongside this branch's nine.
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.

Branch creation rollback can discard commits made by a failing post-checkout hook

3 participants