@zhushanwen/pi-subagent-workflow 7.3.4 → 8.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +39 -12
- package/agents/analyst.md +61 -0
- package/agents/coder.md +70 -0
- package/agents/debugger.md +67 -0
- package/agents/doc-reviewer.md +3 -3
- package/agents/explorer.md +50 -18
- package/agents/general-purpose.md +19 -8
- package/agents/orchestrator.md +37 -32
- package/agents/planner.md +45 -11
- package/agents/researcher.md +53 -11
- package/agents/reviewer.md +74 -0
- package/package.json +1 -1
- package/skills/workflow-script-format/SKILL.md +1 -1
- package/src/execution/__tests__/__fixtures__/truncline.snapshot.json +1 -0
- package/src/execution/__tests__/agent-registry.test.ts +13 -11
- package/src/execution/__tests__/ask-user-transit-e2e.test.ts +10 -4
- package/src/execution/__tests__/before-agent-start-injection.test.ts +132 -0
- package/src/execution/__tests__/bg-notify-render.test.ts +15 -15
- package/src/execution/__tests__/chatmode-first-round-closure-service.test.ts +365 -0
- package/src/execution/__tests__/chatmode-first-round-closure-spawn.test.ts +190 -0
- package/src/execution/__tests__/chatmode-round-notify-real-chain.test.ts +215 -0
- package/src/execution/__tests__/conversation-wiring.test.ts +198 -0
- package/src/execution/__tests__/crash-recovery.test.ts +8 -2
- package/src/execution/__tests__/delivery-methods.test.ts +385 -0
- package/src/execution/__tests__/epipe-fallback.test.ts +241 -0
- package/src/execution/__tests__/execute-and-await-worktree.test.ts +49 -2
- package/src/execution/__tests__/execute-nesting.test.ts +20 -72
- package/src/execution/__tests__/execution-record.test.ts +199 -0
- package/src/execution/__tests__/finalize-record.test.ts +170 -14
- package/src/execution/__tests__/format.test.ts +131 -7
- package/src/execution/__tests__/gc-timer.test.ts +184 -0
- package/src/execution/__tests__/get-record-for-action-restart.test.ts +254 -0
- package/src/execution/__tests__/helpers/spawn-mock.ts +25 -7
- package/src/execution/__tests__/index-session-start-identity.test.ts +371 -0
- package/src/execution/__tests__/index-session-start.test.ts +257 -5
- package/src/execution/__tests__/lifecycle-manager-lock.test.ts +211 -0
- package/src/execution/__tests__/lifecycle-manager.test.ts +337 -0
- package/src/execution/__tests__/lifecycle-predicates.test.ts +116 -0
- package/src/execution/__tests__/list-component.test.ts +59 -5
- package/src/execution/__tests__/list-fields.test.ts +109 -0
- package/src/execution/__tests__/model-resolver.test.ts +38 -1
- package/src/execution/__tests__/nested-visibility-env-propagation.test.ts +287 -0
- package/src/execution/__tests__/nested-visibility.test.ts +325 -0
- package/src/execution/__tests__/notifier-flush.test.ts +209 -7
- package/src/execution/__tests__/one-shot-upgrade.test.ts +205 -0
- package/src/execution/__tests__/parent-child-matrix.test.ts +336 -0
- package/src/execution/__tests__/record-store.test.ts +158 -52
- package/src/execution/__tests__/recursive-visibility-baseline.test.ts +11 -12
- package/src/execution/__tests__/recursive-visibility-env.test.ts +18 -20
- package/src/execution/__tests__/resource-policy.test.ts +109 -0
- package/src/execution/__tests__/run-and-finalize-chatmode.test.ts +267 -0
- package/src/execution/__tests__/run-spawn-chatmode-settled.test.ts +253 -0
- package/src/execution/__tests__/run-spawn-edges.test.ts +18 -25
- package/src/execution/__tests__/run-spawn-integration.test.ts +29 -25
- package/src/execution/__tests__/run-spawn-resume.test.ts +322 -0
- package/src/execution/__tests__/run-spawn-rpc-mode.test.ts +14 -11
- package/src/execution/__tests__/session-pending.test.ts +61 -2
- package/src/execution/__tests__/session-reconstructor.test.ts +4 -4
- package/src/execution/__tests__/session-runner-epipe.test.ts +178 -0
- package/src/execution/__tests__/session-runner-schema-env.test.ts +15 -21
- package/src/execution/__tests__/session-start-reaper.test.ts +10 -8
- package/src/execution/__tests__/spawn-args.test.ts +127 -49
- package/src/execution/__tests__/spawn-worktree-guidance.test.ts +1 -0
- package/src/execution/__tests__/spawned-children.test.ts +92 -0
- package/src/execution/__tests__/status-refactor.test.ts +345 -0
- package/src/execution/__tests__/stdin-writer.test.ts +97 -0
- package/src/execution/__tests__/subagent-service-message-close.test.ts +598 -0
- package/src/execution/__tests__/subagent-service-parent-guard.test.ts +180 -0
- package/src/execution/__tests__/subagent-service.test.ts +49 -11
- package/src/execution/__tests__/timeout-integration.test.ts +27 -13
- package/src/execution/__tests__/tool-action.test.ts +12 -10
- package/src/execution/__tests__/truncline-snapshot.test.ts +81 -0
- package/src/execution/__tests__/turn-limiter-semantics.test.ts +194 -0
- package/src/execution/__tests__/worktree-manager.test.ts +292 -89
- package/src/execution/__tests__/worktree-pid-registration.integration.test.ts +13 -12
- package/src/execution/argv-mirror.ts +21 -2
- package/src/execution/execution-record.ts +126 -9
- package/src/execution/finalize-record.ts +90 -13
- package/src/execution/host-mode.ts +1 -1
- package/src/execution/lifecycle-manager.ts +484 -0
- package/src/execution/lifecycle-predicates.ts +65 -0
- package/src/execution/manifest-store.ts +61 -16
- package/src/execution/model-resolver.ts +26 -5
- package/src/execution/notifier.ts +69 -12
- package/src/execution/pi-invocation.ts +21 -1
- package/src/execution/record-store.ts +554 -107
- package/src/execution/session-pending.ts +116 -45
- package/src/execution/session-reconstructor.ts +224 -7
- package/src/execution/session-runner.ts +290 -75
- package/src/execution/sessions-index.ts +304 -0
- package/src/execution/stdin-writer.ts +93 -7
- package/src/execution/stream-sink.ts +20 -3
- package/src/execution/subagent-service.ts +867 -138
- package/src/execution/turn-limiter.ts +14 -0
- package/src/execution/types.ts +204 -22
- package/src/execution/worktree-manager.ts +128 -49
- package/src/execution/worktree-registry.ts +13 -2
- package/src/index.ts +277 -19
- package/src/injectors/subagent-list-injector.ts +26 -8
- package/src/injectors/workflow-list-injector.ts +25 -8
- package/src/interface/__tests__/subagent-tool-prompt.test.ts +18 -5
- package/src/interface/__tests__/tool-render.test.ts +15 -13
- package/src/interface/bg-notify-render.ts +32 -8
- package/src/interface/command-actions.ts +26 -7
- package/src/interface/commands.ts +21 -22
- package/src/interface/format.ts +44 -17
- package/src/interface/gui-mappers.ts +6 -8
- package/src/interface/helpers.ts +170 -10
- package/src/interface/list-component.ts +53 -14
- package/src/interface/subagent-actions.ts +235 -17
- package/src/interface/subagent-tool.ts +82 -17
- package/src/interface/subagents.ts +2 -1
- package/src/interface/tool-render.ts +11 -24
- package/src/interface/tool-workflow.ts +20 -35
- package/src/interface/views/WorkflowsView.ts +89 -32
- package/src/interface/views/__tests__/WorkflowsView-signature.test.ts +264 -0
- package/src/interface/views/__tests__/detail-content-session-file.test.ts +1 -1
- package/src/interface/views/format.ts +3 -3
- package/src/orchestration/__tests__/__fixtures__/worker-template.snapshot.txt +325 -0
- package/src/orchestration/__tests__/args-validator.test.ts +1 -1
- package/src/orchestration/__tests__/config-loader.test.ts +38 -0
- package/src/orchestration/__tests__/error-recovery-handlers.test.ts +394 -4
- package/src/orchestration/__tests__/error-recovery-workflow-call.test.ts +4 -4
- package/src/orchestration/__tests__/execute-agent-call.test.ts +95 -0
- package/src/orchestration/__tests__/jsonl-run-store-session-file.test.ts +657 -18
- package/src/orchestration/__tests__/launcher-nested-workflow.test.ts +0 -2
- package/src/orchestration/__tests__/lifecycle.test.ts +332 -149
- package/src/orchestration/__tests__/skill-discovery.test.ts +157 -0
- package/src/orchestration/__tests__/test-mocks.ts +191 -0
- package/src/orchestration/__tests__/worker-script-template-snapshot.test.ts +98 -0
- package/src/orchestration/__tests__/workflow-nesting-e2e.test.ts +0 -2
- package/src/orchestration/__tests__/workflow-script-lint-memo.test.ts +110 -0
- package/src/orchestration/__tests__/workflows-e2e.test.ts +1 -1
- package/src/orchestration/agent-opts-resolver.ts +4 -1
- package/src/orchestration/args-validator.ts +2 -2
- package/src/orchestration/config-loader.ts +30 -1
- package/src/orchestration/error-recovery.ts +133 -29
- package/src/orchestration/execute-agent-call.ts +31 -7
- package/src/orchestration/jsonl-run-store.ts +287 -40
- package/src/orchestration/launcher.ts +7 -1
- package/src/orchestration/lifecycle.ts +135 -132
- package/src/orchestration/models/__tests__/trace.test.ts +408 -0
- package/src/orchestration/models/budget.ts +1 -1
- package/src/orchestration/models/run-runtime.ts +15 -17
- package/src/orchestration/models/run-spec.ts +2 -2
- package/src/orchestration/models/run-state.ts +3 -3
- package/src/orchestration/models/trace.ts +95 -15
- package/src/orchestration/models/types.ts +8 -9
- package/src/orchestration/models/workflow-run.ts +50 -71
- package/src/orchestration/models/workflow-script.ts +32 -1
- package/src/orchestration/skill-discovery.ts +30 -0
- package/src/orchestration/worker-handle.ts +1 -1
- package/src/orchestration/worker-host.ts +1 -1
- package/src/orchestration/worker-script-builder.ts +29 -10
- package/src/shared/__tests__/agent-ref.test.ts +34 -0
- package/src/shared/__tests__/resource-discovery-manifest-cache.test.ts +280 -0
- package/src/shared/__tests__/resource-discovery.test.ts +55 -0
- package/src/shared/__tests__/schema-jsonify.test.ts +81 -0
- package/src/shared/agent-ref.ts +16 -0
- package/src/shared/resource-discovery.ts +147 -58
- package/src/shared/schema-jsonify.ts +53 -0
- package/workflows/README.md +4 -4
- package/agents/code-reviewer.md +0 -47
- package/agents/context-builder.md +0 -21
- package/agents/oracle.md +0 -34
- package/agents/worker.md +0 -20
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// src/__tests__/worktree-manager.test.ts
|
|
2
2
|
//
|
|
3
3
|
// WorktreeManager 单元测试。
|
|
4
|
-
// mock
|
|
4
|
+
// mock execFile(git 命令)+ WorktreeRegistry(注册表)+ alive-store(进程探活)。
|
|
5
5
|
//
|
|
6
6
|
// [全局注册表重构] scan 不再读 sidecar / 不再依赖 cwd 是否 git repo。
|
|
7
7
|
// 判据从「终态 marker 状态机」改为「pid 死活」。
|
|
@@ -14,7 +14,7 @@ import { DirtyWorktreeError } from "../types.ts";
|
|
|
14
14
|
// ── mock modules ──
|
|
15
15
|
|
|
16
16
|
vi.mock("node:child_process", () => ({
|
|
17
|
-
|
|
17
|
+
execFile: vi.fn(),
|
|
18
18
|
}));
|
|
19
19
|
|
|
20
20
|
vi.mock("node:fs", () => ({
|
|
@@ -36,7 +36,7 @@ vi.mock("../alive-store.ts", () => ({
|
|
|
36
36
|
|
|
37
37
|
// WorktreeRegistry mock:内存数组模拟,add/updatePid/remove/load 全部可追踪
|
|
38
38
|
const { mockLoad, mockAdd, mockUpdatePid, mockRemove, registryEntries } = vi.hoisted(() => {
|
|
39
|
-
type Entry = { repo: string; branch: string; checkout: string; pid: number; createdAt: number };
|
|
39
|
+
type Entry = { repo: string; branch: string; checkout: string; pid: number; createdAt: number; sessionFile?: string };
|
|
40
40
|
const entries: Entry[] = [];
|
|
41
41
|
return {
|
|
42
42
|
registryEntries: entries,
|
|
@@ -46,9 +46,12 @@ const { mockLoad, mockAdd, mockUpdatePid, mockRemove, registryEntries } = vi.hoi
|
|
|
46
46
|
if (idx >= 0) entries[idx] = e;
|
|
47
47
|
else entries.push(e);
|
|
48
48
|
}),
|
|
49
|
-
mockUpdatePid: vi.fn((branch: string, pid: number): void => {
|
|
49
|
+
mockUpdatePid: vi.fn((branch: string, pid: number, sessionFile?: string): void => {
|
|
50
50
|
const e = entries.find((x) => x.branch === branch);
|
|
51
|
-
if (e)
|
|
51
|
+
if (e) {
|
|
52
|
+
e.pid = pid;
|
|
53
|
+
if (sessionFile !== undefined) e.sessionFile = sessionFile;
|
|
54
|
+
}
|
|
52
55
|
}),
|
|
53
56
|
mockRemove: vi.fn((branch: string): void => {
|
|
54
57
|
const idx = entries.findIndex((x) => x.branch === branch);
|
|
@@ -67,7 +70,8 @@ vi.mock("../worktree-registry.ts", () => ({
|
|
|
67
70
|
SPAWN_GRACE_MS: 60_000,
|
|
68
71
|
}));
|
|
69
72
|
|
|
70
|
-
|
|
73
|
+
|
|
74
|
+
import { execFile } from "node:child_process";
|
|
71
75
|
import * as fs from "node:fs";
|
|
72
76
|
import * as os from "node:os";
|
|
73
77
|
import * as path from "node:path";
|
|
@@ -76,10 +80,29 @@ import { isProcessAlive } from "../alive-store.ts";
|
|
|
76
80
|
import { encodeCwd } from "../path-encoding.ts";
|
|
77
81
|
import { WorktreeManager } from "../worktree-manager.ts";
|
|
78
82
|
|
|
79
|
-
const
|
|
83
|
+
const mockExecFile = vi.mocked(execFile);
|
|
80
84
|
const mockExistsSync = vi.mocked(fs.existsSync);
|
|
81
85
|
const mockIsProcessAlive = vi.mocked(isProcessAlive);
|
|
82
86
|
|
|
87
|
+
/**
|
|
88
|
+
* gitRunAsync(execFile callback 风格)mock 装配。
|
|
89
|
+
* impl 返回 {err?, stdout?, stderr?};err 模拟失败路径(exitCode=killed/signal 挂在 err 上)。
|
|
90
|
+
*/
|
|
91
|
+
type ExecFileResult = {
|
|
92
|
+
err?: (Error & { code?: unknown; killed?: boolean; signal?: string }) | null;
|
|
93
|
+
stdout?: string;
|
|
94
|
+
stderr?: string;
|
|
95
|
+
};
|
|
96
|
+
function setupExecFile(impl?: (args: readonly string[]) => ExecFileResult): void {
|
|
97
|
+
mockExecFile.mockImplementation(
|
|
98
|
+
(_cmd: string, args: readonly string[], _opts: unknown, cb: (err: Error | null, stdout?: string, stderr?: string) => void) => {
|
|
99
|
+
const r = impl ? impl(args) : { stdout: "" };
|
|
100
|
+
if (r.err) cb(r.err, r.stdout ?? "", r.stderr ?? "");
|
|
101
|
+
else cb(null, r.stdout ?? "", r.stderr ?? "");
|
|
102
|
+
},
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
|
|
83
106
|
const MAIN_CWD = "/home/user/project";
|
|
84
107
|
const AGENT_DIR = "/home/user/.pi/agent";
|
|
85
108
|
const RECORD_ID = "bg-42-abc";
|
|
@@ -101,12 +124,10 @@ function makeHandle(checkoutPath: string = expectedCreatePath(RECORD_ID)) {
|
|
|
101
124
|
}
|
|
102
125
|
|
|
103
126
|
function setupCleanTree(): void {
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
if (
|
|
107
|
-
|
|
108
|
-
if (args?.[0] === "branch") return "";
|
|
109
|
-
return "";
|
|
127
|
+
setupExecFile((_args: readonly string[]) => {
|
|
128
|
+
// rev-parse 真实输出形态:hash + \n(gitRunAsync 不 trim,消费点 trim 后才得干净 hash)
|
|
129
|
+
if (_args?.[0] === "rev-parse" && _args?.[1] === "HEAD") return { stdout: `${BASE_COMMIT}\n` };
|
|
130
|
+
return { stdout: "" };
|
|
110
131
|
});
|
|
111
132
|
// worktreePath 不存在(无需前置清理);node_modules 存在
|
|
112
133
|
mockExistsSync.mockImplementation((p: unknown) => {
|
|
@@ -118,13 +139,14 @@ function setupCleanTree(): void {
|
|
|
118
139
|
}
|
|
119
140
|
|
|
120
141
|
/** 向注册表注入一条活条目(模拟 create 后的状态)。 */
|
|
121
|
-
function injectEntry(overrides: Partial<{ branch: string; pid: number; checkout: string; repo: string; createdAt: number }> = {}): void {
|
|
142
|
+
function injectEntry(overrides: Partial<{ branch: string; pid: number; checkout: string; repo: string; createdAt: number; sessionFile: string }> = {}): void {
|
|
122
143
|
registryEntries.push({
|
|
123
144
|
repo: overrides.repo ?? MAIN_CWD,
|
|
124
145
|
branch: overrides.branch ?? "pi-sub-orphan1",
|
|
125
146
|
checkout: overrides.checkout ?? path.join(os.tmpdir(), "pi-sub-orphan1"),
|
|
126
147
|
pid: overrides.pid ?? 0,
|
|
127
148
|
createdAt: overrides.createdAt ?? Date.now(),
|
|
149
|
+
sessionFile: overrides.sessionFile,
|
|
128
150
|
});
|
|
129
151
|
}
|
|
130
152
|
|
|
@@ -138,10 +160,10 @@ describe("WorktreeManager", () => {
|
|
|
138
160
|
});
|
|
139
161
|
|
|
140
162
|
describe("create", () => {
|
|
141
|
-
it("正常流程返回冻结 handle(path 在 tmpdir,含 mainCwd)", () => {
|
|
163
|
+
it("正常流程返回冻结 handle(path 在 tmpdir,含 mainCwd)", async () => {
|
|
142
164
|
setupCleanTree();
|
|
143
165
|
|
|
144
|
-
const handle = mgr.create(MAIN_CWD, RECORD_ID);
|
|
166
|
+
const handle = await mgr.create(MAIN_CWD, RECORD_ID);
|
|
145
167
|
|
|
146
168
|
expect(handle.path).toBe(expectedCreatePath(RECORD_ID));
|
|
147
169
|
expect(handle.branch).toBe(`pi-sub-${RECORD_ID}`);
|
|
@@ -150,10 +172,10 @@ describe("WorktreeManager", () => {
|
|
|
150
172
|
expect(Object.isFrozen(handle)).toBe(true);
|
|
151
173
|
});
|
|
152
174
|
|
|
153
|
-
it("成功后写入注册表(pid=0 占位)", () => {
|
|
175
|
+
it("成功后写入注册表(pid=0 占位)", async () => {
|
|
154
176
|
setupCleanTree();
|
|
155
177
|
|
|
156
|
-
mgr.create(MAIN_CWD, RECORD_ID);
|
|
178
|
+
await mgr.create(MAIN_CWD, RECORD_ID);
|
|
157
179
|
|
|
158
180
|
expect(mockAdd).toHaveBeenCalledTimes(1);
|
|
159
181
|
const entry = mockAdd.mock.calls[0][0] as { repo: string; branch: string; pid: number };
|
|
@@ -162,30 +184,35 @@ describe("WorktreeManager", () => {
|
|
|
162
184
|
expect(entry.pid).toBe(0);
|
|
163
185
|
});
|
|
164
186
|
|
|
165
|
-
it("脏树抛 DirtyWorktreeError
|
|
166
|
-
|
|
167
|
-
if (args
|
|
168
|
-
return "";
|
|
187
|
+
it("脏树抛 DirtyWorktreeError 且不写注册表(status 与 rev-parse 并行,均发起)", async () => {
|
|
188
|
+
setupExecFile((args: readonly string[]) => {
|
|
189
|
+
if (args[0] === "status") return { stdout: "M src/index.ts\n" };
|
|
190
|
+
return { stdout: "" };
|
|
169
191
|
});
|
|
170
192
|
|
|
171
|
-
|
|
193
|
+
// async create:同步 throw 变 rejected promise
|
|
194
|
+
await expect(mgr.create(MAIN_CWD, RECORD_ID)).rejects.toThrow(DirtyWorktreeError);
|
|
172
195
|
expect(mockAdd).not.toHaveBeenCalled();
|
|
196
|
+
// allSettled 并行:两条读命令都发起(rev-parse 结果在脏树时被丢弃)
|
|
197
|
+
const cmds = mockExecFile.mock.calls.map((c) => `${c[1]?.[0]} ${c[1]?.[1] ?? ""}`);
|
|
198
|
+
expect(cmds).toContain("status --porcelain");
|
|
199
|
+
expect(cmds).toContain("rev-parse HEAD");
|
|
173
200
|
});
|
|
174
201
|
|
|
175
|
-
it("recordId 含特殊字符抛 DirtyWorktreeError", () => {
|
|
176
|
-
expect(
|
|
177
|
-
expect(
|
|
178
|
-
expect(
|
|
179
|
-
expect(
|
|
202
|
+
it("recordId 含特殊字符抛 DirtyWorktreeError", async () => {
|
|
203
|
+
await expect(mgr.create(MAIN_CWD, "../evil-path")).rejects.toThrow(DirtyWorktreeError);
|
|
204
|
+
await expect(mgr.create(MAIN_CWD, "hello world")).rejects.toThrow(DirtyWorktreeError);
|
|
205
|
+
await expect(mgr.create(MAIN_CWD, "")).rejects.toThrow(DirtyWorktreeError);
|
|
206
|
+
await expect(mgr.create(MAIN_CWD, "a;b")).rejects.toThrow(DirtyWorktreeError);
|
|
180
207
|
});
|
|
181
208
|
|
|
182
|
-
it("recordId 单字符 / 连续短横线合法", () => {
|
|
209
|
+
it("recordId 单字符 / 连续短横线合法", async () => {
|
|
183
210
|
setupCleanTree();
|
|
184
|
-
expect(mgr.create(MAIN_CWD, "a").branch).toBe("pi-sub-a");
|
|
185
|
-
expect(mgr.create(MAIN_CWD, "--test--").branch).toBe("pi-sub---test--");
|
|
211
|
+
expect((await mgr.create(MAIN_CWD, "a")).branch).toBe("pi-sub-a");
|
|
212
|
+
expect((await mgr.create(MAIN_CWD, "--test--")).branch).toBe("pi-sub---test--");
|
|
186
213
|
});
|
|
187
214
|
|
|
188
|
-
it("残留 checkout 目录存在时前置清理(fs.rmSync)", () => {
|
|
215
|
+
it("残留 checkout 目录存在时前置清理(fs.rmSync)", async () => {
|
|
189
216
|
setupCleanTree();
|
|
190
217
|
// worktreePath 已存在 → 触发前置 rmSync
|
|
191
218
|
mockExistsSync.mockImplementation((p: unknown) => {
|
|
@@ -195,7 +222,7 @@ describe("WorktreeManager", () => {
|
|
|
195
222
|
return false;
|
|
196
223
|
});
|
|
197
224
|
|
|
198
|
-
mgr.create(MAIN_CWD, RECORD_ID);
|
|
225
|
+
await mgr.create(MAIN_CWD, RECORD_ID);
|
|
199
226
|
|
|
200
227
|
expect(fs.rmSync).toHaveBeenCalledWith(
|
|
201
228
|
expectedCreatePath(RECORD_ID),
|
|
@@ -203,75 +230,120 @@ describe("WorktreeManager", () => {
|
|
|
203
230
|
);
|
|
204
231
|
});
|
|
205
232
|
|
|
206
|
-
it("symlink 失败时回滚 worktree + 分支 +
|
|
233
|
+
it("symlink 失败时回滚 worktree + 分支 + 注册表(回滚经 gitRunAsync)", async () => {
|
|
207
234
|
setupCleanTree();
|
|
208
235
|
// symlink 抛错触发 MF#3 回滚
|
|
209
236
|
vi.mocked(fs.symlinkSync).mockImplementation(() => {
|
|
210
237
|
throw new Error("symlink permission denied");
|
|
211
238
|
});
|
|
212
239
|
|
|
213
|
-
expect(
|
|
240
|
+
await expect(mgr.create(MAIN_CWD, RECORD_ID)).rejects.toThrow("symlink permission denied");
|
|
214
241
|
|
|
215
|
-
// 回滚:worktree remove + branch delete
|
|
216
|
-
expect(
|
|
242
|
+
// 回滚:worktree remove + branch delete(异步路径)+ registry remove
|
|
243
|
+
expect(mockExecFile).toHaveBeenCalledWith(
|
|
217
244
|
"git",
|
|
218
245
|
expect.arrayContaining(["worktree", "remove", "--force"]),
|
|
219
246
|
expect.anything(),
|
|
247
|
+
expect.anything(),
|
|
220
248
|
);
|
|
221
|
-
expect(
|
|
249
|
+
expect(mockExecFile).toHaveBeenCalledWith(
|
|
222
250
|
"git",
|
|
223
251
|
expect.arrayContaining(["branch", "-D"]),
|
|
224
252
|
expect.anything(),
|
|
253
|
+
expect.anything(),
|
|
225
254
|
);
|
|
226
255
|
expect(mockRemove).toHaveBeenCalledWith(`pi-sub-${RECORD_ID}`);
|
|
227
256
|
});
|
|
257
|
+
it("per-repo mutex:同 repo 并发 create 的 worktree add 串行(maxActive=1)", async () => {
|
|
258
|
+
// clearAllMocks 不清 mockImplementation——重置前例注入的 symlinkSync throw,本用例走完整 create
|
|
259
|
+
vi.mocked(fs.symlinkSync).mockImplementation(() => {});
|
|
260
|
+
mockExistsSync.mockImplementation((p: unknown) => {
|
|
261
|
+
const s = String(p);
|
|
262
|
+
if (s.includes("node_modules")) return true;
|
|
263
|
+
return false;
|
|
264
|
+
});
|
|
265
|
+
let writeActive = 0;
|
|
266
|
+
let maxWriteActive = 0;
|
|
267
|
+
let addCount = 0;
|
|
268
|
+
mockExecFile.mockImplementation(
|
|
269
|
+
(_cmd: string, args: readonly string[], _opts: unknown, cb: (err: Error | null, stdout?: string, stderr?: string) => void) => {
|
|
270
|
+
const isAdd = args[0] === "worktree" && args[1] === "add";
|
|
271
|
+
if (isAdd) {
|
|
272
|
+
// 只对写命令(worktree add)计并发——读命令(status/rev-parse)无锁可并行是设计预期
|
|
273
|
+
writeActive++;
|
|
274
|
+
maxWriteActive = Math.max(maxWriteActive, writeActive);
|
|
275
|
+
addCount++;
|
|
276
|
+
}
|
|
277
|
+
setTimeout(() => {
|
|
278
|
+
if (isAdd) writeActive--;
|
|
279
|
+
// rev-parse 真实输出带尾换行;baseCommit 由消费点 trim
|
|
280
|
+
cb(null, args[0] === "rev-parse" ? `${BASE_COMMIT}\n` : "", "");
|
|
281
|
+
}, 5);
|
|
282
|
+
},
|
|
283
|
+
);
|
|
284
|
+
|
|
285
|
+
const handles = await Promise.all([
|
|
286
|
+
mgr.create(MAIN_CWD, "con-a"),
|
|
287
|
+
mgr.create(MAIN_CWD, "con-b"),
|
|
288
|
+
]);
|
|
289
|
+
|
|
290
|
+
// 两个 create 全部成功、branch 不同
|
|
291
|
+
expect(handles[0].branch).toBe("pi-sub-con-a");
|
|
292
|
+
expect(handles[1].branch).toBe("pi-sub-con-b");
|
|
293
|
+
// 同 repo 的 2 个 worktree add 全部执行且互斥(per-repo mutex)
|
|
294
|
+
expect(addCount).toBe(2);
|
|
295
|
+
expect(maxWriteActive).toBe(1);
|
|
296
|
+
});
|
|
228
297
|
});
|
|
229
298
|
|
|
230
299
|
describe("registerPid", () => {
|
|
231
300
|
it("委托 registry.updatePid", () => {
|
|
232
301
|
mgr.registerPid("pi-sub-bg-1", 12345);
|
|
233
|
-
expect(mockUpdatePid).toHaveBeenCalledWith("pi-sub-bg-1", 12345);
|
|
302
|
+
expect(mockUpdatePid).toHaveBeenCalledWith("pi-sub-bg-1", 12345, undefined);
|
|
234
303
|
});
|
|
235
304
|
});
|
|
236
305
|
|
|
237
306
|
describe("cleanup", () => {
|
|
238
|
-
it("worktree remove + branch -D + 注册表移除(cwd 用 handle.mainCwd)", () => {
|
|
239
|
-
|
|
307
|
+
it("worktree remove + branch -D + 注册表移除(cwd 用 handle.mainCwd)", async () => {
|
|
308
|
+
setupExecFile();
|
|
240
309
|
const handle = makeHandle();
|
|
241
310
|
|
|
242
|
-
mgr.cleanup(handle);
|
|
311
|
+
await mgr.cleanup(handle);
|
|
243
312
|
|
|
244
|
-
expect(
|
|
313
|
+
expect(mockExecFile).toHaveBeenCalledWith(
|
|
245
314
|
"git",
|
|
246
315
|
["worktree", "remove", "--force", handle.path],
|
|
247
316
|
expect.objectContaining({ cwd: MAIN_CWD }),
|
|
317
|
+
expect.anything(),
|
|
248
318
|
);
|
|
249
|
-
expect(
|
|
319
|
+
expect(mockExecFile).toHaveBeenCalledWith(
|
|
250
320
|
"git",
|
|
251
321
|
["branch", "-D", handle.branch],
|
|
252
322
|
expect.objectContaining({ cwd: MAIN_CWD }),
|
|
323
|
+
expect.anything(),
|
|
253
324
|
);
|
|
254
325
|
expect(mockRemove).toHaveBeenCalledWith(handle.branch);
|
|
255
326
|
});
|
|
256
327
|
|
|
257
|
-
it("worktree remove 失败时 branch -D + 注册表移除仍执行(best-effort 分离)", () => {
|
|
258
|
-
// 第一条 git(worktree remove
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
return "";
|
|
328
|
+
it("worktree remove 失败时 branch -D + 注册表移除仍执行(best-effort 分离)", async () => {
|
|
329
|
+
// 第一条 git(worktree remove)失败,第二条(branch -D)应仍执行
|
|
330
|
+
setupExecFile((args) => {
|
|
331
|
+
if (args[1] === "remove") {
|
|
332
|
+
return { err: Object.assign(new Error("Command failed: git worktree remove"), { code: 128 }), stderr: "fatal: worktree locked" };
|
|
333
|
+
}
|
|
334
|
+
return { stdout: "" };
|
|
264
335
|
});
|
|
265
336
|
const handle = makeHandle();
|
|
266
337
|
|
|
267
338
|
// 不应抛错
|
|
268
|
-
expect(
|
|
339
|
+
await expect(mgr.cleanup(handle)).resolves.toBeUndefined();
|
|
269
340
|
|
|
270
341
|
// branch -D 仍被调用
|
|
271
|
-
expect(
|
|
342
|
+
expect(mockExecFile).toHaveBeenCalledWith(
|
|
272
343
|
"git",
|
|
273
344
|
["branch", "-D", handle.branch],
|
|
274
345
|
expect.anything(),
|
|
346
|
+
expect.anything(),
|
|
275
347
|
);
|
|
276
348
|
// 注册表仍被移除
|
|
277
349
|
expect(mockRemove).toHaveBeenCalledWith(handle.branch);
|
|
@@ -279,141 +351,272 @@ describe("WorktreeManager", () => {
|
|
|
279
351
|
});
|
|
280
352
|
|
|
281
353
|
describe("collectPatch", () => {
|
|
282
|
-
it("有改动返回 patch 文件", () => {
|
|
283
|
-
|
|
354
|
+
it("有改动返回 patch 文件", async () => {
|
|
355
|
+
setupExecFile((args) => (args[0] === "diff" ? { stdout: "diff --git a/src\n+// new" } : { stdout: "" }));
|
|
284
356
|
|
|
285
357
|
const handle = makeHandle();
|
|
286
358
|
const patchFile = path.join(os.tmpdir(), `outside-${RECORD_ID}.patch`);
|
|
287
359
|
|
|
288
|
-
const result = mgr.collectPatch(handle, patchFile);
|
|
360
|
+
const result = await mgr.collectPatch(handle, patchFile);
|
|
289
361
|
|
|
290
362
|
expect(result.failed).toBe(false);
|
|
291
363
|
expect(result.written).toBe(true);
|
|
292
|
-
expect(
|
|
293
|
-
expect(
|
|
364
|
+
expect(mockExecFile).toHaveBeenCalledWith("git", ["add", "-A"], expect.anything(), expect.anything());
|
|
365
|
+
expect(mockExecFile).toHaveBeenCalledWith(
|
|
294
366
|
"git",
|
|
295
367
|
["diff", "--cached", BASE_COMMIT],
|
|
296
368
|
expect.anything(),
|
|
369
|
+
expect.anything(),
|
|
297
370
|
);
|
|
298
371
|
});
|
|
299
372
|
|
|
300
|
-
it("无改动返回 failed=false, written=false(不写文件)", () => {
|
|
301
|
-
|
|
302
|
-
const result = mgr.collectPatch(makeHandle(), "/tmp/x.patch");
|
|
373
|
+
it("无改动返回 failed=false, written=false(不写文件)", async () => {
|
|
374
|
+
setupExecFile();
|
|
375
|
+
const result = await mgr.collectPatch(makeHandle(), "/tmp/x.patch");
|
|
303
376
|
expect(result.failed).toBe(false);
|
|
304
377
|
expect(result.written).toBe(false);
|
|
305
378
|
});
|
|
379
|
+
|
|
380
|
+
it("patch 内容 = git diff stdout 原文(含尾部换行不被裁剪),git apply --check 兼容", async () => {
|
|
381
|
+
// 真实 git diff 输出以 \n 结尾;旧实现 gitRunAsync resolve(stdout.trim())
|
|
382
|
+
// 裁掉尾换行 → 落盘 patch 在干净副本上 `git apply --check` 报
|
|
383
|
+
// "corrupt patch at line N"(2026-08-16 门 4 实测,exit 128)
|
|
384
|
+
const rawDiff =
|
|
385
|
+
"diff --git a/data.txt b/data.txt\n" +
|
|
386
|
+
"index c690e0d..f858a88 100644\n" +
|
|
387
|
+
"--- a/data.txt\n" +
|
|
388
|
+
"+++ b/data.txt\n" +
|
|
389
|
+
"@@ -1 +1,2 @@\n" +
|
|
390
|
+
" base content\n" +
|
|
391
|
+
"+appended\n";
|
|
392
|
+
setupExecFile((args) => (args[0] === "diff" ? { stdout: rawDiff } : { stdout: "" }));
|
|
393
|
+
|
|
394
|
+
await mgr.collectPatch(makeHandle(), "/tmp/x.patch");
|
|
395
|
+
|
|
396
|
+
// 写盘字节与 diff stdout 逐字节一致(保真 = 不裁剪两端空白)
|
|
397
|
+
const written = vi.mocked(fs.writeFileSync).mock.calls[0][1] as string;
|
|
398
|
+
expect(written).toBe(rawDiff);
|
|
399
|
+
expect(written.endsWith("\n")).toBe(true);
|
|
400
|
+
});
|
|
401
|
+
});
|
|
402
|
+
|
|
403
|
+
// ============================================================
|
|
404
|
+
// gitRunAsync 包装与 per-repo mutex(Phase 1 异步化新增)
|
|
405
|
+
// ============================================================
|
|
406
|
+
describe("gitRunAsync(经 collectPatch/cleanup 公共面触达)", () => {
|
|
407
|
+
it("非零退出:message 同构 + exitCode/stderr 属性(P-errshape:stderr 在 callback 第三参)", async () => {
|
|
408
|
+
setupExecFile((args) => {
|
|
409
|
+
if (args[0] === "diff") {
|
|
410
|
+
return {
|
|
411
|
+
err: Object.assign(new Error("Command failed: git diff --cached abc123def456\nfatal: bad object"), { code: 128 }),
|
|
412
|
+
stderr: "fatal: bad object",
|
|
413
|
+
};
|
|
414
|
+
}
|
|
415
|
+
return { stdout: "" };
|
|
416
|
+
});
|
|
417
|
+
|
|
418
|
+
// diff 无 try/catch,直接向上抛(与旧同步 gitRun 行为一致)
|
|
419
|
+
const err = await mgr.collectPatch(makeHandle(), "/tmp/x.patch").catch((e: unknown) => e);
|
|
420
|
+
expect(err).toBeInstanceOf(Error);
|
|
421
|
+
expect((err as Error).message).toContain("git diff failed: Command failed: git diff --cached");
|
|
422
|
+
expect((err as { exitCode?: number }).exitCode).toBe(128);
|
|
423
|
+
expect((err as { stderr?: string }).stderr).toBe("fatal: bad object");
|
|
424
|
+
});
|
|
425
|
+
|
|
426
|
+
it("超时路径:killed+SIGTERM 标记 timedOut,不挂误导性 exitCode", async () => {
|
|
427
|
+
setupExecFile((args) => {
|
|
428
|
+
if (args[0] === "diff") {
|
|
429
|
+
return { err: Object.assign(new Error("Command failed: git diff --cached"), { killed: true, signal: "SIGTERM", code: null }) };
|
|
430
|
+
}
|
|
431
|
+
return { stdout: "" };
|
|
432
|
+
});
|
|
433
|
+
|
|
434
|
+
const err = await mgr.collectPatch(makeHandle(), "/tmp/x.patch").catch((e: unknown) => e);
|
|
435
|
+
expect((err as Error).message).toContain("git diff failed:");
|
|
436
|
+
expect((err as { timedOut?: boolean }).timedOut).toBe(true);
|
|
437
|
+
expect((err as { exitCode?: number }).exitCode).toBeUndefined();
|
|
438
|
+
});
|
|
439
|
+
|
|
440
|
+
it("per-repo mutex:同 repo 写类命令串行(最大并发 1),全部完成无饥饿", async () => {
|
|
441
|
+
let active = 0;
|
|
442
|
+
let maxActive = 0;
|
|
443
|
+
// 异步完成 cb:若队列失效,后继命令的 impl 入口会先于前驱 cb 到达 → active=2
|
|
444
|
+
mockExecFile.mockImplementation(
|
|
445
|
+
(_cmd: string, args: readonly string[], _opts: unknown, cb: (err: Error | null, stdout?: string, stderr?: string) => void) => {
|
|
446
|
+
active++;
|
|
447
|
+
maxActive = Math.max(maxActive, active);
|
|
448
|
+
setTimeout(() => {
|
|
449
|
+
active--;
|
|
450
|
+
cb(null, "", "");
|
|
451
|
+
}, 5);
|
|
452
|
+
void args;
|
|
453
|
+
},
|
|
454
|
+
);
|
|
455
|
+
|
|
456
|
+
const distinct = [1, 2, 3].map((i) =>
|
|
457
|
+
Object.freeze({ ...makeHandle(path.join(os.tmpdir(), `pi-sub-mut-${i}`)), branch: `pi-sub-mut-${i}` }),
|
|
458
|
+
);
|
|
459
|
+
await Promise.all(distinct.map((h) => mgr.cleanup(h)));
|
|
460
|
+
|
|
461
|
+
// 同 repo 6 条写命令(3×(remove+branch -D))全部执行(无饥饿)
|
|
462
|
+
const writeCalls = mockExecFile.mock.calls.filter(
|
|
463
|
+
(c) => (c[1]?.[0] === "worktree" && c[1]?.[1] === "remove") || (c[1]?.[0] === "branch" && c[1]?.[1] === "-D"),
|
|
464
|
+
);
|
|
465
|
+
expect(writeCalls).toHaveLength(6);
|
|
466
|
+
// 队列生效:任何时刻至多 1 条写命令在途
|
|
467
|
+
expect(maxActive).toBe(1);
|
|
468
|
+
});
|
|
469
|
+
|
|
470
|
+
it("mutex 前驱失败不传染后继:第一个 remove 失败,后续同 repo 写命令正常执行", async () => {
|
|
471
|
+
let removeCount = 0;
|
|
472
|
+
setupExecFile((args) => {
|
|
473
|
+
if (args[0] === "worktree" && args[1] === "remove") {
|
|
474
|
+
removeCount++;
|
|
475
|
+
if (removeCount === 1) {
|
|
476
|
+
return { err: Object.assign(new Error("Command failed: git worktree remove"), { code: 128 }) };
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
return { stdout: "" };
|
|
480
|
+
});
|
|
481
|
+
|
|
482
|
+
const h1 = Object.freeze({ ...makeHandle(), branch: "pi-sub-fail-1" });
|
|
483
|
+
const h2 = Object.freeze({ ...makeHandle(), branch: "pi-sub-fail-2" });
|
|
484
|
+
await Promise.all([mgr.cleanup(h1), mgr.cleanup(h2)]);
|
|
485
|
+
|
|
486
|
+
// h2 的 remove 正常执行(第 2 次),h1/h2 的 branch -D 各自执行
|
|
487
|
+
expect(removeCount).toBe(2);
|
|
488
|
+
const branchCalls = mockExecFile.mock.calls.filter((c) => c[1]?.[0] === "branch" && c[1]?.[1] === "-D");
|
|
489
|
+
expect(branchCalls).toHaveLength(2);
|
|
490
|
+
// 两个注册表条目都移除(各自 cleanup 尾部执行)
|
|
491
|
+
expect(mockRemove).toHaveBeenCalledWith("pi-sub-fail-1");
|
|
492
|
+
expect(mockRemove).toHaveBeenCalledWith("pi-sub-fail-2");
|
|
493
|
+
});
|
|
306
494
|
});
|
|
307
495
|
|
|
308
496
|
// ============================================================
|
|
309
497
|
// scan:全局注册表 + pid 死活判据(核心重构)
|
|
310
498
|
// ============================================================
|
|
311
499
|
describe("scan", () => {
|
|
312
|
-
it("pid 已死的条目被清理(正常退出未 cleanup / 崩溃残留)", () => {
|
|
500
|
+
it("pid 已死的条目被清理(正常退出未 cleanup / 崩溃残留)", async () => {
|
|
501
|
+
setupExecFile();
|
|
313
502
|
injectEntry({ branch: "pi-sub-dead", pid: 11111 });
|
|
314
503
|
mockIsProcessAlive.mockReturnValue(false); // pid 死
|
|
315
504
|
|
|
316
|
-
mgr.scan();
|
|
505
|
+
await mgr.scan();
|
|
317
506
|
|
|
318
|
-
expect(
|
|
507
|
+
expect(mockExecFile).toHaveBeenCalledWith(
|
|
319
508
|
"git",
|
|
320
509
|
["worktree", "remove", "--force", expect.any(String)],
|
|
321
510
|
expect.objectContaining({ cwd: MAIN_CWD }),
|
|
511
|
+
expect.anything(),
|
|
322
512
|
);
|
|
323
|
-
expect(
|
|
513
|
+
expect(mockExecFile).toHaveBeenCalledWith(
|
|
324
514
|
"git",
|
|
325
515
|
["branch", "-D", "pi-sub-dead"],
|
|
326
516
|
expect.objectContaining({ cwd: MAIN_CWD }),
|
|
517
|
+
expect.anything(),
|
|
327
518
|
);
|
|
328
519
|
expect(mockRemove).toHaveBeenCalledWith("pi-sub-dead");
|
|
329
520
|
});
|
|
330
521
|
|
|
331
|
-
it("pid 活的条目不删(绝不删活进程)", () => {
|
|
522
|
+
it("pid 活的条目不删(绝不删活进程)", async () => {
|
|
523
|
+
setupExecFile();
|
|
332
524
|
injectEntry({ branch: "pi-sub-alive", pid: 22222 });
|
|
333
525
|
mockIsProcessAlive.mockReturnValue(true); // pid 活
|
|
334
526
|
|
|
335
|
-
mgr.scan();
|
|
527
|
+
await mgr.scan();
|
|
336
528
|
|
|
337
|
-
const removeCalls =
|
|
529
|
+
const removeCalls = mockExecFile.mock.calls.filter(
|
|
338
530
|
(c) => c[1]?.[0] === "worktree" && c[1]?.[1] === "remove",
|
|
339
531
|
);
|
|
340
532
|
expect(removeCalls).toHaveLength(0);
|
|
341
533
|
expect(mockRemove).not.toHaveBeenCalled();
|
|
342
534
|
});
|
|
343
535
|
|
|
344
|
-
it("崩溃残留:无终态 + pid 死 → 清理(原 P0 缺陷场景)", () => {
|
|
536
|
+
it("崩溃残留:无终态 + pid 死 → 清理(原 P0 缺陷场景)", async () => {
|
|
345
537
|
// 这是原 reaper 永远泄漏的场景:进程崩溃无人写终态 marker
|
|
538
|
+
setupExecFile();
|
|
346
539
|
injectEntry({ branch: "pi-sub-crash", pid: 33333 });
|
|
347
540
|
mockIsProcessAlive.mockReturnValue(false); // 崩溃后进程已死
|
|
348
541
|
|
|
349
|
-
mgr.scan();
|
|
542
|
+
await mgr.scan();
|
|
350
543
|
|
|
351
|
-
expect(
|
|
544
|
+
expect(mockExecFile).toHaveBeenCalledWith(
|
|
352
545
|
"git",
|
|
353
546
|
["branch", "-D", "pi-sub-crash"],
|
|
354
547
|
expect.anything(),
|
|
548
|
+
expect.anything(),
|
|
355
549
|
);
|
|
356
550
|
expect(mockRemove).toHaveBeenCalledWith("pi-sub-crash");
|
|
357
551
|
});
|
|
358
552
|
|
|
359
|
-
it("pid=0 + 未超 SPAWN_GRACE → 跳过(可能正在 spawn)", () => {
|
|
553
|
+
it("pid=0 + 未超 SPAWN_GRACE → 跳过(可能正在 spawn)", async () => {
|
|
554
|
+
setupExecFile();
|
|
360
555
|
injectEntry({ branch: "pi-sub-spawning", pid: 0, createdAt: Date.now() });
|
|
361
556
|
|
|
362
|
-
mgr.scan();
|
|
557
|
+
await mgr.scan();
|
|
363
558
|
|
|
364
|
-
const removeCalls =
|
|
559
|
+
const removeCalls = mockExecFile.mock.calls.filter(
|
|
365
560
|
(c) => c[1]?.[0] === "worktree" && c[1]?.[1] === "remove",
|
|
366
561
|
);
|
|
367
562
|
expect(removeCalls).toHaveLength(0);
|
|
368
563
|
});
|
|
369
564
|
|
|
370
|
-
it("pid=0 + 超 SPAWN_GRACE → 清理(create 后崩溃)", () => {
|
|
565
|
+
it("pid=0 + 超 SPAWN_GRACE → 清理(create 后崩溃)", async () => {
|
|
566
|
+
setupExecFile();
|
|
371
567
|
const sixtyOneMinAgo = Date.now() - 61 * 60 * 1000;
|
|
372
568
|
injectEntry({ branch: "pi-sub-spawn-crash", pid: 0, createdAt: sixtyOneMinAgo });
|
|
373
569
|
|
|
374
|
-
mgr.scan();
|
|
570
|
+
await mgr.scan();
|
|
375
571
|
|
|
376
|
-
expect(
|
|
572
|
+
expect(mockExecFile).toHaveBeenCalledWith(
|
|
377
573
|
"git",
|
|
378
574
|
["branch", "-D", "pi-sub-spawn-crash"],
|
|
379
575
|
expect.anything(),
|
|
576
|
+
expect.anything(),
|
|
380
577
|
);
|
|
381
578
|
expect(mockRemove).toHaveBeenCalledWith("pi-sub-spawn-crash");
|
|
382
579
|
});
|
|
383
580
|
|
|
384
|
-
it("跨 repo 清理:条目 repo 字段作为 git -C 目标", () => {
|
|
581
|
+
it("跨 repo 清理:条目 repo 字段作为 git -C 目标", async () => {
|
|
582
|
+
setupExecFile();
|
|
385
583
|
const OTHER_REPO = "/home/user/other-repo";
|
|
386
584
|
injectEntry({ branch: "pi-sub-cross", pid: 44444, repo: OTHER_REPO });
|
|
387
585
|
mockIsProcessAlive.mockReturnValue(false);
|
|
388
586
|
|
|
389
|
-
mgr.scan();
|
|
587
|
+
await mgr.scan();
|
|
390
588
|
|
|
391
589
|
// git 命令的 cwd 应为 OTHER_REPO,非 MAIN_CWD
|
|
392
|
-
expect(
|
|
590
|
+
expect(mockExecFile).toHaveBeenCalledWith(
|
|
393
591
|
"git",
|
|
394
592
|
expect.arrayContaining(["branch", "-D", "pi-sub-cross"]),
|
|
395
593
|
expect.objectContaining({ cwd: OTHER_REPO }),
|
|
594
|
+
expect.anything(),
|
|
396
595
|
);
|
|
397
596
|
});
|
|
398
597
|
|
|
399
|
-
it("空注册表时无操作", () => {
|
|
598
|
+
it("空注册表时无操作", async () => {
|
|
599
|
+
setupExecFile();
|
|
400
600
|
// registryEntries 已在 beforeEach 清空
|
|
401
|
-
mgr.scan();
|
|
402
|
-
expect(
|
|
601
|
+
await mgr.scan();
|
|
602
|
+
expect(mockExecFile).not.toHaveBeenCalled();
|
|
403
603
|
expect(mockRemove).not.toHaveBeenCalled();
|
|
404
604
|
});
|
|
405
605
|
|
|
406
|
-
it("worktree remove 失败时 branch -D + 注册表移除仍执行(best-effort)", () => {
|
|
606
|
+
it("worktree remove 失败时 branch -D + 注册表移除仍执行(best-effort)", async () => {
|
|
607
|
+
setupExecFile((args) => {
|
|
608
|
+
if (args[0] === "worktree") {
|
|
609
|
+
return { err: Object.assign(new Error("Command failed: git worktree remove"), { code: 128 }) };
|
|
610
|
+
}
|
|
611
|
+
return { stdout: "" };
|
|
612
|
+
});
|
|
407
613
|
injectEntry({ branch: "pi-sub-stubborn", pid: 55555 });
|
|
408
614
|
mockIsProcessAlive.mockReturnValue(false);
|
|
409
|
-
mockExec.mockImplementation(() => {
|
|
410
|
-
throw new Error("worktree locked");
|
|
411
|
-
});
|
|
412
615
|
|
|
413
|
-
mgr.scan();
|
|
616
|
+
await mgr.scan();
|
|
414
617
|
|
|
415
618
|
// branch -D 仍被调用(两次 git 调用各自独立)
|
|
416
|
-
const branchDeleteCalls =
|
|
619
|
+
const branchDeleteCalls = mockExecFile.mock.calls.filter(
|
|
417
620
|
(c) => c[1]?.[0] === "branch" && c[1]?.[1] === "-D",
|
|
418
621
|
);
|
|
419
622
|
expect(branchDeleteCalls).toHaveLength(1);
|