Merge pull request #2466 from pikasTech/fix/2462-local-branch-preservation
Pipelines as Code CI / hwlab-web-probe-sentinel-nc01- Success
Pipelines as Code CI / platform-infra-gitea-nc01- Success
Pipelines as Code CI / unidesk-host- Success

补齐 PR 合并的本地 head 状态保护
This commit is contained in:
Lyon
2026-07-17 21:47:12 +08:00
committed by GitHub
6 changed files with 50 additions and 17 deletions
+2 -1
View File
@@ -35,8 +35,9 @@ GitHub issue/PR 正式读写必须走 `bun scripts/cli.ts gh ...` 或 `trans gh:
- 回滚 PR 与正确实现 PR 必须分离,完整流程见 [references/pull-requests.md](references/pull-requests.md)。
- PR merge 只走 guarded `gh pr merge`;它内建 readiness/preflight,普通已审 PR 不先机械执行独立 `pr preflight`。主代理按 `$unidesk-subagent` 完成 review 和 merge 授权判断后执行;只有确认 ancestry 可丢弃时才显式 `--squash`
- `gh pr merge` 的远端 head branch 生命周期:
- 默认保留;
- 默认保留远端和本地 head branch 状态
- 只有确认 head 是可删除的临时分支时才显式传 `--delete-branch`
- 显式删除同时清理 Git 已登记、分支精确匹配、非主 worktree 且状态干净的本地 worktree
- `--keep-branch` 仅作为兼容的显式声明。
- 功能、skill、MDTODO 报告和完成状态在 merge 前已齐备时,必须进入同一个 PR。禁止仅为补写 merge SHA、`mergedAt`、branch deletion 或重复 closeout 再开文档 PR;这些事实由 GitHub PR 和 `gh pr merge` 默认摘要保存。
- 成功 merge 后默认摘要已披露 `mergeCommit``mergedAt`,不再机械执行 `pr view`。只有输出缺字段、merge 后出现新运行证据,或需要定点排障时才继续查询;只有用户明确要求把 merge 后新证据写回 Git 时,才创建第二个 closeout PR。
@@ -16,10 +16,11 @@ PR 工作必须使用受控 UniDesk GitHub 命令:
- 主代理按 `$unidesk-subagent` 完成 review 和 merge 授权判断后使用 guarded `pr merge`
- 不得把 `pr create` Next 限制误解成“PR 永远不能人工受控合并”。
- `pr merge --merge` 的分支处理规则:
- 默认保留已合并的 head branch
- 默认保留已合并的远端和本地 head branch 状态
-`branch-preserved` 披露未删除原因;
- 只有确认 head 是可删除的临时分支时才显式使用 `--delete-branch`
- 本地 closeout 仍会清理匹配且干净的 `.worktree`,并快进位于 PR base branch 的本地主工作区。
- 显式删除会清理 Git 已登记、分支精确匹配、非主 worktree 且状态干净的本地 worktree,不按目录名称限制;
- 本地 closeout 仍会快进位于 PR base branch 的本地主工作区。
- 整个 PR 的授权目标、架构方向、data flow 或 source of truth 被判定错误时:
- 未合并:通过受控 GitHub 入口关闭 PR,停止旧 writer,保留 PR/commit 作为证据;从最新目标分支创建新分支、worktree 和 PR,禁止在错误 head 上继续提交纠偏补丁;
- 已合并:从包含该 merge 的最新目标分支创建独立 revert PR,只精确反向原 PR;先 guarded merge 回滚,再从恢复后的目标分支创建正确实现 PR;
@@ -2,25 +2,28 @@
## 结论
受控 `gh pr merge` 已改为默认保留远端 head branch。只有显式传入 `--delete-branch` 才删除已确认的临时分支`--keep-branch` 保留兼容,冲突标志继续拒绝。
受控 `gh pr merge` 现在默认同时保留远端和本地 head branch 状态。只有显式传入 `--delete-branch` 才删除已确认的临时远端 branch,并清理精确匹配的本地临时 worktree/branch`--keep-branch` 保留兼容,冲突标志继续拒绝。
## 实现
- 参数解析不再根据 `pr merge` 命令隐式启用删除。
- 未删除结果使用稳定 typed reason `branch-preserved`
- local closeout 在未显式删除时不进入 head worktree 或本地 branch 清理路径。
- 显式删除只匹配 Git 已登记、branch 精确一致、非主 worktree 且状态干净的 worktree,不依赖 repo 内外目录名称。
- 自动重试命令在默认保留场景不再附加冗余 `--keep-branch`
- scoped help、顶层 help 和 `unidesk-gh` 长期合同同步默认保留语义。
- scoped help、顶层 help 和 `unidesk-gh` 长期合同同步完整保留语义。
- 未按分支名称硬编码,不新增运行面、配置合同或部署步骤。
## 验证
- `bun test scripts/src/gh-pr-merge-options.test.ts scripts/src/gh-default-render.test.ts scripts/src/gh-pr-ready.test.ts`
- 18 项通过,0 项失败。
- 20 项通过,0 项失败。
- 覆盖默认远端保留、默认本地保留、显式删除、冲突标志、外置已登记 worktree 和主 worktree 排除。
- `bun scripts/cli.ts gh pr merge --help`
- 明确显示默认保留,显式 `--delete-branch` 才删除。
- 明确显示默认保留远端和本地 head 状态,显式 `--delete-branch` 才删除。
- `git diff --check`
- 通过。
## 工具调用改进
同类长期分支合并不再需要“显式保留、检查远端分支、发现误删、恢复分支”的补救调用链。临时分支仍可用单个 `--delete-branch` 参数完成明确删除
同类长期分支合并不再需要“显式保留、检查远端和本地分支、发现误删、恢复分支”的补救调用链。临时分支使用单个 `--delete-branch` 参数即可完成远端 branch、本地已登记 clean worktree 和本地 branch 的受控清理,不再要求手工 `git worktree remove``git branch -d`
+28 -1
View File
@@ -1,7 +1,11 @@
import { describe, expect, test } from "bun:test";
import { join } from "node:path";
import { repoRoot } from "./config";
import { ghScopedHelp } from "./gh/help";
import { parseOptions } from "./gh/options";
import { isRemovableHeadWorktree, runPrMergeCloseout } from "./gh/pr-merge-closeout";
import { prMergeRetryCommand } from "./gh/pr-summary";
import type { GitHubPullRequest } from "./gh/types";
describe("gh pr merge branch lifecycle", () => {
test("preserves the head branch by default", () => {
@@ -26,8 +30,31 @@ describe("gh pr merge branch lifecycle", () => {
test("documents the safe default in scoped help", () => {
const help = ghScopedHelp(["pr", "merge", "--help"]) as { notes: string[] };
const notes = help.notes.join("\n");
expect(notes).toContain("preserves the head branch by default");
expect(notes).toContain("preserves remote and local head branch state by default");
expect(notes).toContain("--delete-branch");
expect(notes).not.toContain("deletes the same-repo head branch by default");
});
test("never removes the main worktree and accepts registered task worktrees outside the repo", () => {
expect(isRemovableHeadWorktree(repoRoot)).toBe(false);
expect(isRemovableHeadWorktree(join(repoRoot, "..", ".worktree", "task"))).toBe(true);
expect(isRemovableHeadWorktree(join(repoRoot, ".worktree", "task"))).toBe(true);
});
test("preserves local head state unless deletion is explicit", () => {
const pullRequest = {
head: { ref: "development" },
base: { ref: "master" },
} as GitHubPullRequest;
const result = runPrMergeCloseout(
"pikasTech/unidesk",
pullRequest,
parseOptions(["pr", "merge", "24", "--dry-run"]),
);
expect(result.localWorktree).toMatchObject({
skipped: true,
skippedReason: "branch-preserved",
});
});
});
+2 -2
View File
@@ -107,7 +107,7 @@ export function ghHelp(): unknown {
"PR preflight/closeout accept the same owner/repo#number shorthand as PR view/read so merge readiness checks do not require repeating --repo after a PR URL has already been normalized.",
"PR list does not fetch mergeability or statusCheckRollup; request those closeout fields with gh pr view <number> --json headRefName,baseRefName,mergeable,mergeStateStatus,statusCheckRollup.",
"PR preflight is a low-noise read-only closeout helper for explicit diagnosis only. It combines redacted auth capability, PR branch/state metadata, mergeability, mergeStateStatus, compact status check counts, and an explicit read-only policy. It is not a required step before gh pr merge. Use --full or --raw to include all fetched status contexts.",
"PR merge is the one-command guarded write path: it reads closeout metadata itself, retries GitHub UNKNOWN/null mergeability with YAML-configured exponential backoff, refuses non-open/draft/conflicting/non-clean/failed/pending PRs, then uses GitHub REST merge. Its success summary includes mergeCommit and mergedAt, so a routine follow-up pr view is unnecessary. It preserves the merged head branch by default; use --delete-branch only for a confirmed temporary branch. Local closeout still cleans a matching local .worktree when safe and fast-forwards the local main worktree on the PR base branch; --sync-node NODE additionally runs mapped node source-workspace sync. Use --dry-run to see the exact merge and closeout plan without writing.",
"PR merge is the one-command guarded write path: it reads closeout metadata itself, retries GitHub UNKNOWN/null mergeability with YAML-configured exponential backoff, refuses non-open/draft/conflicting/non-clean/failed/pending PRs, then uses GitHub REST merge. Its success summary includes mergeCommit and mergedAt, so a routine follow-up pr view is unnecessary. It preserves remote and local head branch state by default; use --delete-branch only for a confirmed temporary branch, which also cleans an exact matching registered clean non-main worktree. Local closeout still fast-forwards the local main worktree on the PR base branch; --sync-node NODE additionally runs mapped node source-workspace sync. Use --dry-run to see the exact merge and closeout plan without writing.",
],
};
}
@@ -185,7 +185,7 @@ export function ghScopedHelpNotes(tokens: string[]): string[] {
} else if (key === "pr merge") {
notes.push("PR merge is one-command guarded: it performs the readiness check itself; `gh pr preflight` is optional read-only diagnosis, not a required first step.");
notes.push("When GitHub reports mergeability as UNKNOWN/null, merge automatically retries with YAML-configured exponential backoff and shows retry attempts as N/M.");
notes.push("After merge it preserves the head branch by default and reports a typed branch-preserved reason; use `--delete-branch` only for a confirmed temporary branch. Local closeout still removes a clean matching `.worktree` and fast-forwards the local main worktree; use `--skip-local-closeout` only when intentionally preserving local state.");
notes.push("After merge it preserves remote and local head branch state by default and reports a typed branch-preserved reason. Use `--delete-branch` only for a confirmed temporary branch; this also removes an exact matching registered clean non-main worktree. Local closeout still fast-forwards the local main worktree; use `--skip-local-closeout` only when intentionally preserving all local state.");
} else if (key === "pr review-plan" || key === "pr diff") {
notes.push("Use `pr review-plan` first for a bounded changed-file index with per-file drill-down commands.");
notes.push("Use `pr diff <number> --file <path> [--hunk N]` for bounded patch review; full patch disclosure requires explicit --full or --raw.");
+7 -6
View File
@@ -1,7 +1,7 @@
// SPEC: PJ2026-01060703 GitHub PR merge closeout.
// Responsibility: guarded local/node worktree closeout after a successful PR merge.
import { relative, resolve, sep } from "node:path";
import { resolve } from "node:path";
import { repoRoot } from "../config";
import { runCommand, type CommandResult } from "../command";
import type { GitHubOptions, GitHubPullRequest } from "./types";
@@ -21,7 +21,9 @@ export function runPrMergeCloseout(repo: string, pr: GitHubPullRequest, options:
valuesPrinted: false,
headRef,
baseRef,
localWorktree: localEnabled ? cleanupHeadWorktree(headRef, options.dryRun) : skipped("local-closeout-disabled"),
localWorktree: localEnabled && options.deleteBranch
? cleanupHeadWorktree(headRef, options.dryRun)
: skipped(localEnabled ? "branch-preserved" : "local-closeout-disabled"),
mainWorktree: localEnabled ? syncLocalMainWorktree(repo, baseRef, options.dryRun) : skipped("local-closeout-disabled"),
nodeSyncs: syncRequestedNodes(repo, baseRef, options.syncNodes, options.dryRun),
};
@@ -32,7 +34,7 @@ function cleanupHeadWorktree(headRef: string | null, dryRun: boolean): Record<st
const list = git(["worktree", "list", "--porcelain"], 10_000);
if (list.exitCode !== 0) return failed("worktree-list-failed", list);
const entries = parseWorktreeList(list.stdout);
const matches = entries.filter((entry) => entry.branch === `refs/heads/${headRef}` && isManagedTaskWorktree(entry.path));
const matches = entries.filter((entry) => entry.branch === `refs/heads/${headRef}` && isRemovableHeadWorktree(entry.path));
if (matches.length === 0) return skipped("local-head-worktree-not-found", { headRef });
const removals = matches.map((entry) => removeWorktree(entry.path, dryRun));
const branchDelete = deleteLocalBranch(headRef, dryRun);
@@ -131,9 +133,8 @@ function parseWorktreeList(text: string): WorktreeEntry[] {
return entries;
}
function isManagedTaskWorktree(path: string): boolean {
const rel = relative(repoRoot, resolve(path));
return rel === ".worktree" || rel.startsWith(`.worktree${sep}`);
export function isRemovableHeadWorktree(path: string): boolean {
return resolve(path) !== resolve(repoRoot);
}
function git(args: string[], timeoutMs: number): CommandResult {