diff --git a/.agents/skills/unidesk-gh/SKILL.md b/.agents/skills/unidesk-gh/SKILL.md index f4f2a8c1..77e814ae 100644 --- a/.agents/skills/unidesk-gh/SKILL.md +++ b/.agents/skills/unidesk-gh/SKILL.md @@ -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。 diff --git a/.agents/skills/unidesk-gh/references/pull-requests.md b/.agents/skills/unidesk-gh/references/pull-requests.md index 5cbe24a6..b76b013b 100644 --- a/.agents/skills/unidesk-gh/references/pull-requests.md +++ b/.agents/skills/unidesk-gh/references/pull-requests.md @@ -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; diff --git a/docs/MDTODO/details/pr-merge-driven-automatic-delivery/R1.5_Task_Report.md b/docs/MDTODO/details/pr-merge-driven-automatic-delivery/R1.5_Task_Report.md index fc61729a..124bb9c9 100644 --- a/docs/MDTODO/details/pr-merge-driven-automatic-delivery/R1.5_Task_Report.md +++ b/docs/MDTODO/details/pr-merge-driven-automatic-delivery/R1.5_Task_Report.md @@ -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`。 diff --git a/scripts/src/gh-pr-merge-options.test.ts b/scripts/src/gh-pr-merge-options.test.ts index 7b18991f..6fad81a9 100644 --- a/scripts/src/gh-pr-merge-options.test.ts +++ b/scripts/src/gh-pr-merge-options.test.ts @@ -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", + }); + }); }); diff --git a/scripts/src/gh/help.ts b/scripts/src/gh/help.ts index bf16cf6d..64bd4333 100644 --- a/scripts/src/gh/help.ts +++ b/scripts/src/gh/help.ts @@ -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 --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 --file [--hunk N]` for bounded patch review; full patch disclosure requires explicit --full or --raw."); diff --git a/scripts/src/gh/pr-merge-closeout.ts b/scripts/src/gh/pr-merge-closeout.ts index 9420c029..d3ec6196 100644 --- a/scripts/src/gh/pr-merge-closeout.ts +++ b/scripts/src/gh/pr-merge-closeout.ts @@ -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 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 {