Skip to content

Commit 4759402

Browse files
committed
fix(worktree): 按基分支合并状态删除专用分支
产品层面: - 当主 worktree 当前未签出基分支时,删除子 worktree 仍按基分支的真实合并状态判断是否允许删除专用分支。 - 已经合并到目标基线的专用分支,不再因为当前 HEAD 偏离基分支而误报为未合并,减少用户额外强制删除的心智负担。 技术层面: - 在 `removeWorktreeAsync` 中复用基分支作为合并校验基线,并将分支删除逻辑收敛到统一 helper。 - 对已通过显式祖先校验的分支,优先执行 `git branch -d`;若仅因当前 HEAD 或上游判断不同被 Git 拒绝,则安全回退到 `git branch -D`。 - 补充回归测试,覆盖主 worktree 未签出基分支时,已合并专用分支仍可正常删除的场景。 Signed-off-by: Lulu <58587930+lulu-sk@users.noreply.github.com>
1 parent 9ed0ee5 commit 4759402

File tree

2 files changed

+130
-9
lines changed

2 files changed

+130
-9
lines changed

electron/git/worktreeBaseBranchOwner.test.ts

Lines changed: 59 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ vi.mock("electron", () => ({
1111
},
1212
}));
1313

14-
import { createWorktreesAsync, recycleWorktreeAsync } from "./worktreeOps";
14+
import { createWorktreesAsync, recycleWorktreeAsync, removeWorktreeAsync } from "./worktreeOps";
1515
import { getWorktreeMeta, setWorktreeMeta } from "../stores/worktreeMetaStore";
1616

1717
/**
@@ -23,6 +23,13 @@ async function git(repo: string, argv: string[], timeoutMs: number = 12_000): Pr
2323
return String(res.stdout || "");
2424
}
2525

26+
/**
27+
* 中文说明:执行 git 命令但不强制断言成功,用于验证分支已删除等预期失败场景。
28+
*/
29+
async function gitTry(repo: string, argv: string[], timeoutMs: number = 12_000) {
30+
return await execGitAsync({ argv: ["-C", repo, ...argv], timeoutMs });
31+
}
32+
2633
/**
2734
* 中文说明:创建一个带 `main` 初始提交的临时仓库,并返回后续测试会复用的目录路径。
2835
*/
@@ -136,4 +143,55 @@ describe("worktree 基分支落点解析", () => {
136143
},
137144
{ timeout: 120_000 }
138145
);
146+
147+
it(
148+
"remove:当前主 worktree 未签出基分支时,仍应按基分支合并状态删除专用分支",
149+
async () => {
150+
const { sandbox, repo } = await setupRepoFixtureAsync("codexflow-wt-remove-base-owner-");
151+
152+
try {
153+
await git(repo, ["checkout", "-b", "feature"]);
154+
await git(repo, ["checkout", "main"]);
155+
156+
await fsp.writeFile(path.join(repo, "main-only.txt"), "main\n", "utf8");
157+
await git(repo, ["add", "main-only.txt"]);
158+
await git(repo, ["commit", "-m", "main: advance"]);
159+
160+
await git(repo, ["checkout", "feature"]);
161+
162+
const createRes = await createWorktreesAsync({
163+
repoDir: repo,
164+
baseBranch: "main",
165+
instances: [{ providerId: "codex", count: 1 }],
166+
copyRules: false,
167+
});
168+
169+
expect(createRes.ok).toBe(true);
170+
expect(createRes.items?.length).toBe(1);
171+
172+
const item = createRes.items?.[0];
173+
expect(item).toBeTruthy();
174+
175+
const removeRes = await removeWorktreeAsync({
176+
worktreePath: String(item?.worktreePath || ""),
177+
deleteBranch: true,
178+
});
179+
180+
expect(removeRes.ok).toBe(true);
181+
if (removeRes.ok) {
182+
expect(removeRes.removedWorktree).toBe(true);
183+
expect(removeRes.removedBranch).toBe(true);
184+
}
185+
186+
const wtBranch = String(item?.wtBranch || "").trim();
187+
expect(wtBranch).not.toBe("");
188+
189+
const ref = await gitTry(repo, ["show-ref", "--verify", `refs/heads/${wtBranch}`]);
190+
expect(ref.ok).toBe(false);
191+
} finally {
192+
try { await fsp.rm(sandbox, { recursive: true, force: true }); } catch {}
193+
}
194+
},
195+
{ timeout: 120_000 }
196+
);
139197
});

electron/git/worktreeOps.ts

Lines changed: 71 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -207,6 +207,61 @@ async function removeWorktreeDirBestEffortAsync(worktreePath: string): Promise<v
207207
} catch {}
208208
}
209209

210+
/**
211+
* 中文说明:在已完成合并安全检查后删除 worktree 对应分支。
212+
* - 未合并且未强制时,返回 `needsForceDeleteBranch` 让上层二次确认;
213+
* - 已合并时优先尝试 `git branch -d`,保持与 Git 常规删除语义一致;
214+
* - 若 `-d` 因当前 `HEAD/上游` 与创建基分支不同而拒绝删除,则回退到 `-D`;
215+
* 因为调用方已经通过显式祖先检查确认该分支已合并到目标基线,回退为安全兜底。
216+
*/
217+
async function deleteCheckedWorktreeBranchAsync(args: {
218+
repoMainPath: string;
219+
wtBranch: string;
220+
isMerged: boolean;
221+
forceDeleteBranch?: boolean;
222+
gitPath?: string;
223+
}): Promise<
224+
| { ok: true; removedBranch: boolean }
225+
| { ok: false; removedBranch: false; needsForceDeleteBranch?: boolean; error: string }
226+
> {
227+
const repoMainPath = toFsPathAbs(args.repoMainPath);
228+
const wtBranch = String(args.wtBranch || "").trim();
229+
if (!wtBranch) return { ok: true, removedBranch: false };
230+
if (!repoMainPath) return { ok: false, removedBranch: false, error: "missing repoMainPath" };
231+
232+
if (!args.isMerged && args.forceDeleteBranch !== true) {
233+
return { ok: false, removedBranch: false, needsForceDeleteBranch: true, error: "branch not merged" };
234+
}
235+
236+
/**
237+
* 中文说明:执行一次分支删除,并统一抽取可读错误文本。
238+
*/
239+
const runDeleteAsync = async (mode: "-d" | "-D"): Promise<{ ok: true } | { ok: false; error: string }> => {
240+
const del = await execGitAsync({
241+
gitPath: args.gitPath,
242+
argv: ["-C", repoMainPath, "branch", mode, wtBranch],
243+
timeoutMs: 10_000,
244+
});
245+
if (del.ok) return { ok: true };
246+
return {
247+
ok: false,
248+
error: String(del.error || del.stderr || del.stdout || "git branch delete failed").trim() || "git branch delete failed",
249+
};
250+
};
251+
252+
const primaryMode: "-d" | "-D" = args.isMerged ? "-d" : "-D";
253+
const primary = await runDeleteAsync(primaryMode);
254+
if (primary.ok) return { ok: true, removedBranch: true };
255+
256+
if (!args.isMerged) {
257+
return { ok: false, removedBranch: false, error: primary.error };
258+
}
259+
260+
const fallback = await runDeleteAsync("-D");
261+
if (fallback.ok) return { ok: true, removedBranch: true };
262+
return { ok: false, removedBranch: false, error: fallback.error || primary.error };
263+
}
264+
210265
/**
211266
* 读取当前仓库的分支信息(用于 baseBranch 下拉)。
212267
*/
@@ -1791,15 +1846,23 @@ export async function removeWorktreeAsync(req: RemoveWorktreeRequest): Promise<R
17911846
return { ok: false, removedWorktree, removedBranch: false, error: merged.error || merged.stderr.trim() || "git merge-base failed" };
17921847
}
17931848
const isMerged = merged.exitCode === 0;
1794-
if (!isMerged && !req.forceDeleteBranch) {
1795-
return { ok: false, removedWorktree, removedBranch: false, needsForceDeleteBranch: true, error: "branch not merged" };
1796-
}
1797-
const delArgv = ["-C", repoMainPath, "branch", isMerged ? "-d" : "-D", wtBranch];
1798-
const del = await execGitAsync({ gitPath, argv: delArgv, timeoutMs: 10_000 });
1799-
if (!del.ok) {
1800-
return { ok: false, removedWorktree, removedBranch: false, error: del.error || del.stderr.trim() || "git branch delete failed" };
1849+
const deleteRes = await deleteCheckedWorktreeBranchAsync({
1850+
repoMainPath,
1851+
wtBranch,
1852+
isMerged,
1853+
forceDeleteBranch: req.forceDeleteBranch,
1854+
gitPath,
1855+
});
1856+
if (!deleteRes.ok) {
1857+
return {
1858+
ok: false,
1859+
removedWorktree,
1860+
removedBranch: false,
1861+
needsForceDeleteBranch: deleteRes.needsForceDeleteBranch,
1862+
error: deleteRes.error,
1863+
};
18011864
}
1802-
removedBranch = true;
1865+
removedBranch = deleteRes.removedBranch;
18031866
}
18041867
}
18051868

0 commit comments

Comments
 (0)