agent-dealer 0.2.0 → 1.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/bundle/server/dist/adapters/agent-deck-bind.js +443 -0
- package/bundle/server/dist/adapters/agent-deck-bind.test.js +454 -0
- package/bundle/server/dist/adapters/agent-deck.js +38 -50
- package/bundle/server/dist/adapters/agent-deck.test.js +63 -0
- package/bundle/server/dist/adapters/agent-health.js +230 -33
- package/bundle/server/dist/adapters/agent-health.test.js +237 -0
- package/bundle/server/dist/adapters/authority-lifecycle.js +289 -0
- package/bundle/server/dist/adapters/codex-scoped-config.js +103 -0
- package/bundle/server/dist/adapters/execution-authority.js +103 -0
- package/bundle/server/dist/adapters/execution-authority.test.js +166 -0
- package/bundle/server/dist/adapters/external.js +0 -1
- package/bundle/server/dist/adapters/git-worktree.js +514 -0
- package/bundle/server/dist/adapters/git-worktree.test.js +502 -0
- package/bundle/server/dist/adapters/github.js +170 -0
- package/bundle/server/dist/adapters/github.test.js +205 -0
- package/bundle/server/dist/adapters/linear-inbox.js +199 -30
- package/bundle/server/dist/adapters/linear-inbox.test.js +35 -0
- package/bundle/server/dist/adapters/linear-sync.js +3 -101
- package/bundle/server/dist/adapters/managed-repo.js +168 -0
- package/bundle/server/dist/adapters/managed-repo.test.js +63 -0
- package/bundle/server/dist/adapters/outbound-delivery.js +83 -0
- package/bundle/server/dist/adapters/outbound-delivery.test.js +53 -0
- package/bundle/server/dist/adapters/reflect-authority.js +43 -0
- package/bundle/server/dist/cli-env.js +26 -0
- package/bundle/server/dist/cli-env.test.js +14 -1
- package/bundle/server/dist/coordinator/abort.integration.test.js +74 -0
- package/bundle/server/dist/coordinator/admission.js +330 -0
- package/bundle/server/dist/coordinator/admission.test.js +338 -0
- package/bundle/server/dist/coordinator/args.js +128 -0
- package/bundle/server/dist/coordinator/args.test.js +132 -0
- package/bundle/server/dist/coordinator/authority-recovery.integration.test.js +661 -0
- package/bundle/server/dist/coordinator/auto-merge.integration.test.js +363 -0
- package/bundle/server/dist/coordinator/auto-merge.js +286 -0
- package/bundle/server/dist/coordinator/auto-merge.timeout.test.js +14 -0
- package/bundle/server/dist/coordinator/branch-progress.js +81 -0
- package/bundle/server/dist/coordinator/clock-jump.js +98 -0
- package/bundle/server/dist/coordinator/commands.js +1088 -0
- package/bundle/server/dist/coordinator/commands.test.js +554 -0
- package/bundle/server/dist/coordinator/deck-outage-config.js +32 -0
- package/bundle/server/dist/coordinator/deck-outage.integration.test.js +200 -0
- package/bundle/server/dist/coordinator/dependencies.js +242 -0
- package/bundle/server/dist/coordinator/dependency-readiness.integration.test.js +535 -0
- package/bundle/server/dist/coordinator/developer-effect.js +959 -0
- package/bundle/server/dist/coordinator/developer-effect.test.js +1143 -0
- package/bundle/server/dist/coordinator/dispatcher.js +58 -0
- package/bundle/server/dist/coordinator/dispatcher.test.js +81 -0
- package/bundle/server/dist/coordinator/effect-registry.js +17 -0
- package/bundle/server/dist/coordinator/execution-env.integration.test.js +122 -0
- package/bundle/server/dist/coordinator/failure-reason.js +273 -0
- package/bundle/server/dist/coordinator/failure-reason.test.js +234 -0
- package/bundle/server/dist/coordinator/guidance.js +33 -0
- package/bundle/server/dist/coordinator/guidance.test.js +110 -0
- package/bundle/server/dist/coordinator/human-resolution.js +88 -0
- package/bundle/server/dist/coordinator/human-resolution.test.js +72 -0
- package/bundle/server/dist/coordinator/intent-forecast.js +13 -0
- package/bundle/server/dist/coordinator/intent-forecast.test.js +34 -0
- package/bundle/server/dist/coordinator/latest-failure.js +100 -0
- package/bundle/server/dist/coordinator/latest-failure.test.js +125 -0
- package/bundle/server/dist/coordinator/metrics.js +35 -0
- package/bundle/server/dist/coordinator/metrics.test.js +52 -0
- package/bundle/server/dist/coordinator/permissions.js +127 -0
- package/bundle/server/dist/coordinator/permissions.test.js +184 -0
- package/bundle/server/dist/coordinator/presumed-dead-budget.integration.test.js +146 -0
- package/bundle/server/dist/coordinator/process-liveness.js +191 -0
- package/bundle/server/dist/coordinator/profile-snapshot.js +20 -0
- package/bundle/server/dist/coordinator/profile-snapshot.test.js +118 -0
- package/bundle/server/dist/coordinator/projection.js +147 -0
- package/bundle/server/dist/coordinator/projection.test.js +121 -0
- package/bundle/server/dist/coordinator/prompts.js +164 -0
- package/bundle/server/dist/coordinator/prompts.test.js +177 -0
- package/bundle/server/dist/coordinator/queue-by-default.integration.test.js +271 -0
- package/bundle/server/dist/coordinator/reclaim-republish.integration.test.js +372 -0
- package/bundle/server/dist/coordinator/recovery.js +466 -0
- package/bundle/server/dist/coordinator/recovery.test.js +164 -0
- package/bundle/server/dist/coordinator/reflect-trigger.js +218 -0
- package/bundle/server/dist/coordinator/reflect-trigger.test.js +324 -0
- package/bundle/server/dist/coordinator/repair-cycle.integration.test.js +234 -0
- package/bundle/server/dist/coordinator/restart-recovery.integration.test.js +363 -0
- package/bundle/server/dist/coordinator/reviewer-effect.js +507 -0
- package/bundle/server/dist/coordinator/reviewer-effect.test.js +612 -0
- package/bundle/server/dist/coordinator/reviewer-result.js +38 -0
- package/bundle/server/dist/coordinator/reviewer-result.test.js +45 -0
- package/bundle/server/dist/coordinator/routing.js +160 -0
- package/bundle/server/dist/coordinator/routing.test.js +336 -0
- package/bundle/server/dist/coordinator/session-lifecycle.integration.test.js +127 -0
- package/bundle/server/dist/coordinator/session-lifecycle.js +280 -0
- package/bundle/server/dist/coordinator/session-lifecycle.test.js +121 -0
- package/bundle/server/dist/coordinator/session-progress.js +351 -0
- package/bundle/server/dist/coordinator/session-progress.test.js +190 -0
- package/bundle/server/dist/coordinator/session-timeouts.js +33 -0
- package/bundle/server/dist/coordinator/sleep-recovery.integration.test.js +233 -0
- package/bundle/server/dist/coordinator/spawn.js +49 -0
- package/bundle/server/dist/coordinator/sync-issue-base-branch.js +18 -0
- package/bundle/server/dist/coordinator/usage-cap-config.js +10 -0
- package/bundle/server/dist/coordinator/usage-cap-defer.js +189 -0
- package/bundle/server/dist/coordinator/usage-cap.integration.test.js +193 -0
- package/bundle/server/dist/coordinator/usage.js +40 -0
- package/bundle/server/dist/coordinator/usage.test.js +69 -0
- package/bundle/server/dist/coordinator/verification-receipt.js +333 -0
- package/bundle/server/dist/coordinator/verification-receipt.test.js +382 -0
- package/bundle/server/dist/coordinator/worker-loop.js +363 -0
- package/bundle/server/dist/coordinator/worker-loop.test.js +327 -0
- package/bundle/server/dist/coordinator/workflow-instances.test.js +45 -0
- package/bundle/server/dist/coordinator/worktree-owner-liveness.js +82 -0
- package/bundle/server/dist/coordinator/worktree-owner-liveness.test.js +199 -0
- package/bundle/server/dist/db/artifacts-issue-columns.test.js +18 -0
- package/bundle/server/dist/db/authority-attempts-stale-at-migration.test.js +65 -0
- package/bundle/server/dist/db/idempotency-index-migration.test.js +57 -0
- package/bundle/server/dist/db/index.js +359 -7
- package/bundle/server/dist/db/linear-inbox-unlock.test.js +67 -0
- package/bundle/server/dist/db/migrate-to-issues.js +824 -0
- package/bundle/server/dist/db/migrate-to-issues.test.js +842 -0
- package/bundle/server/dist/db/schema.sql +311 -1
- package/bundle/server/dist/db/schema.test.js +66 -0
- package/bundle/server/dist/db/worker-session-pid-migration.test.js +112 -0
- package/bundle/server/dist/dev-review-cli-happy-path.integration.test.js +338 -0
- package/bundle/server/dist/direct-start-liveness.integration.test.js +350 -0
- package/bundle/server/dist/index.js +58 -5
- package/bundle/server/dist/paths.js +40 -4
- package/bundle/server/dist/paths.test.js +42 -0
- package/bundle/server/dist/pid-marker-claim-child.js +18 -0
- package/bundle/server/dist/pid-marker-hold-arbiter-child.js +24 -0
- package/bundle/server/dist/pid-marker.js +141 -0
- package/bundle/server/dist/pid-marker.test.js +208 -0
- package/bundle/server/dist/queue/approve-deliver.js +128 -28
- package/bundle/server/dist/queue/reflect-schedule.js +25 -0
- package/bundle/server/dist/queue/result-qa.js +1 -1
- package/bundle/server/dist/queue/send-gate.test.js +172 -36
- package/bundle/server/dist/repository/agents.js +55 -18
- package/bundle/server/dist/repository/artifacts-for-issue.js +42 -0
- package/bundle/server/dist/repository/artifacts-for-issue.test.js +48 -0
- package/bundle/server/dist/repository/artifacts.js +47 -0
- package/bundle/server/dist/repository/artifacts.test.js +48 -0
- package/bundle/server/dist/repository/authority-attempts.js +189 -0
- package/bundle/server/dist/repository/findings.js +70 -0
- package/bundle/server/dist/repository/findings.test.js +68 -0
- package/bundle/server/dist/repository/human-actions.js +142 -0
- package/bundle/server/dist/repository/human-actions.test.js +123 -0
- package/bundle/server/dist/repository/intake-settings.js +5 -59
- package/bundle/server/dist/repository/issues.js +305 -0
- package/bundle/server/dist/repository/issues.test.js +69 -0
- package/bundle/server/dist/repository/outbound-drafts.js +28 -0
- package/bundle/server/dist/repository/queue-entries.js +162 -0
- package/bundle/server/dist/repository/review-publications.js +153 -0
- package/bundle/server/dist/repository/review-publications.test.js +78 -0
- package/bundle/server/dist/repository/runs-external-id.test.js +5 -3
- package/bundle/server/dist/repository/runs.js +4 -211
- package/bundle/server/dist/repository/runtime-availability.js +54 -0
- package/bundle/server/dist/repository/usage-events.js +60 -0
- package/bundle/server/dist/repository/usage-events.test.js +56 -0
- package/bundle/server/dist/repository/work-items.js +305 -0
- package/bundle/server/dist/repository/work-items.test.js +142 -0
- package/bundle/server/dist/repository/worker-sessions.js +219 -0
- package/bundle/server/dist/repository/worker-sessions.test.js +60 -0
- package/bundle/server/dist/repository/workflow-events.js +193 -0
- package/bundle/server/dist/repository/workflow-events.test.js +89 -0
- package/bundle/server/dist/resolve-tsx-bin.js +19 -0
- package/bundle/server/dist/routes/human-actions.js +68 -0
- package/bundle/server/dist/routes/human-actions.test.js +284 -0
- package/bundle/server/dist/routes/index.js +29 -496
- package/bundle/server/dist/routes/issues.js +240 -0
- package/bundle/server/dist/routes/issues.test.js +451 -0
- package/bundle/server/dist/routes/queue.js +41 -0
- package/bundle/server/dist/routes/queue.test.js +78 -0
- package/bundle/server/dist/runners/claude-args.js +18 -30
- package/bundle/server/dist/runners/claude.js +19 -163
- package/bundle/server/dist/runners/claude.test.js +15 -70
- package/bundle/server/dist/runners/codex-args.js +59 -0
- package/bundle/server/dist/runners/codex-args.test.js +72 -0
- package/bundle/server/dist/runners/codex-jsonl.js +150 -0
- package/bundle/server/dist/runners/codex-jsonl.test.js +36 -0
- package/bundle/server/dist/runners/codex-resume.test.js +19 -0
- package/bundle/server/dist/runners/codex.js +46 -0
- package/bundle/server/dist/runners/models-codex.test.js +14 -0
- package/bundle/server/dist/runners/models.js +53 -1
- package/bundle/server/dist/runners/persist.js +4 -1
- package/bundle/server/dist/runners/process-registry.js +11 -0
- package/bundle/server/dist/runners/prompts.js +13 -280
- package/bundle/server/dist/runners/prompts.test.js +22 -153
- package/bundle/server/dist/runners/qa.js +17 -1
- package/bundle/server/dist/runners/reflect.js +4 -16
- package/bundle/server/dist/runners/spawn-cli.js +197 -0
- package/bundle/server/dist/runners/spawn-cli.test.js +130 -0
- package/bundle/server/dist/runners/stream-json.js +0 -141
- package/bundle/server/dist/runners/usage-cap.js +242 -0
- package/bundle/server/dist/runners/usage-cap.test.js +97 -0
- package/bundle/server/dist/server-liveness.js +37 -0
- package/bundle/server/dist/server-liveness.test.js +82 -0
- package/bundle/server/dist/static-ui.test.js +44 -0
- package/bundle/server/package.json +4 -2
- package/bundle/server/static-ui/assets/index-BTtPGYpY.js +60 -0
- package/bundle/server/static-ui/assets/index-BxTx4b1d.css +1 -0
- package/bundle/server/static-ui/index.html +2 -2
- package/bundle/shared/dist/agents.d.ts +262 -133
- package/bundle/shared/dist/agents.js +85 -19
- package/bundle/shared/dist/cursor-auth-health.d.ts +12 -0
- package/bundle/shared/dist/cursor-auth-health.js +36 -0
- package/bundle/shared/dist/cursor-auth-health.test.d.ts +1 -0
- package/bundle/shared/dist/cursor-auth-health.test.js +40 -0
- package/bundle/shared/dist/findings.d.ts +46 -0
- package/bundle/shared/dist/findings.js +17 -0
- package/bundle/shared/dist/findings.test.d.ts +1 -0
- package/bundle/shared/dist/findings.test.js +20 -0
- package/bundle/shared/dist/github-repo.d.ts +28 -0
- package/bundle/shared/dist/github-repo.js +98 -0
- package/bundle/shared/dist/github-repo.test.d.ts +1 -0
- package/bundle/shared/dist/github-repo.test.js +32 -0
- package/bundle/shared/dist/human-actions.d.ts +66 -0
- package/bundle/shared/dist/human-actions.js +51 -0
- package/bundle/shared/dist/human-actions.test.d.ts +1 -0
- package/bundle/shared/dist/human-actions.test.js +57 -0
- package/bundle/shared/dist/index.d.ts +567 -757
- package/bundle/shared/dist/index.js +22 -36
- package/bundle/shared/dist/issues.d.ts +385 -0
- package/bundle/shared/dist/issues.js +169 -0
- package/bundle/shared/dist/issues.test.d.ts +1 -0
- package/bundle/shared/dist/issues.test.js +72 -0
- package/bundle/shared/dist/outbound-draft.d.ts +6 -0
- package/bundle/shared/dist/outbound-draft.js +4 -0
- package/bundle/shared/dist/plan-triage.d.ts +6 -6
- package/bundle/shared/dist/profile-snapshot.d.ts +154 -0
- package/bundle/shared/dist/profile-snapshot.js +145 -0
- package/bundle/shared/dist/profile-snapshot.test.d.ts +1 -0
- package/bundle/shared/dist/profile-snapshot.test.js +67 -0
- package/bundle/shared/dist/queue-entries.d.ts +89 -0
- package/bundle/shared/dist/queue-entries.js +19 -0
- package/bundle/shared/dist/result-qa.d.ts +2 -2
- package/bundle/shared/dist/runtime-auth-health.d.ts +66 -0
- package/bundle/shared/dist/runtime-auth-health.js +186 -0
- package/bundle/shared/dist/runtime-auth-health.test.d.ts +1 -0
- package/bundle/shared/dist/runtime-auth-health.test.js +200 -0
- package/bundle/shared/dist/runtime-availability.d.ts +51 -0
- package/bundle/shared/dist/runtime-availability.js +13 -0
- package/bundle/shared/dist/runtime.d.ts +1 -1
- package/bundle/shared/dist/runtime.js +1 -1
- package/bundle/shared/dist/usage-events.d.ts +36 -0
- package/bundle/shared/dist/usage-events.js +13 -0
- package/bundle/shared/dist/worker-sessions.d.ts +140 -0
- package/bundle/shared/dist/worker-sessions.js +70 -0
- package/bundle/shared/dist/worker-sessions.test.d.ts +1 -0
- package/bundle/shared/dist/worker-sessions.test.js +39 -0
- package/bundle/shared/dist/workflow.d.ts +78 -0
- package/bundle/shared/dist/workflow.js +59 -0
- package/bundle/shared/dist/workflow.test.d.ts +1 -0
- package/bundle/shared/dist/workflow.test.js +61 -0
- package/bundle/shared/package.json +1 -1
- package/dist/action.d.ts +1 -0
- package/dist/action.js +50 -0
- package/dist/action.test.d.ts +1 -0
- package/dist/action.test.js +70 -0
- package/dist/agent.d.ts +1 -0
- package/dist/agent.js +17 -0
- package/dist/agent.test.d.ts +1 -0
- package/dist/agent.test.js +19 -0
- package/dist/cli-check.d.ts +7 -0
- package/dist/cli-check.js +40 -0
- package/dist/doctor.js +59 -1
- package/dist/http.d.ts +5 -0
- package/dist/http.js +19 -0
- package/dist/index.js +28 -0
- package/dist/issue.d.ts +35 -0
- package/dist/issue.js +143 -0
- package/dist/issue.test.d.ts +1 -0
- package/dist/issue.test.js +240 -0
- package/dist/lifecycle.contract.test.d.ts +1 -0
- package/dist/lifecycle.contract.test.js +135 -0
- package/dist/queue.d.ts +11 -0
- package/dist/queue.js +60 -0
- package/dist/queue.test.d.ts +1 -0
- package/dist/queue.test.js +46 -0
- package/dist/test-fetch-stub.d.ts +6 -0
- package/dist/test-fetch-stub.js +31 -0
- package/package.json +2 -1
- package/templates/prod.env.example +1 -1
- package/bundle/server/static-ui/assets/index-B_7S7xJb.js +0 -79
- package/bundle/server/static-ui/assets/index-QYv3zmE6.css +0 -1
|
@@ -0,0 +1,502 @@
|
|
|
1
|
+
// packages/server/src/adapters/git-worktree.test.ts
|
|
2
|
+
import { test, before, after } from "node:test";
|
|
3
|
+
import assert from "node:assert/strict";
|
|
4
|
+
import { execFileSync } from "node:child_process";
|
|
5
|
+
import fs from "node:fs";
|
|
6
|
+
import os from "node:os";
|
|
7
|
+
import path from "node:path";
|
|
8
|
+
// Worktrees live under `<repo>/.agent-dealer-worktrees/` so deck grants on the issue
|
|
9
|
+
// repo cover the worker cwd. realpathSync: macOS's /tmp is a symlink to /private/tmp,
|
|
10
|
+
// and `git worktree list` reports the resolved path — without this, path comparisons
|
|
11
|
+
// against findWorktreeForBranch/resolveDeveloperWorktree would fail.
|
|
12
|
+
const home = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "dealer-wt-home-")));
|
|
13
|
+
process.env.AGENT_DEALER_HOME = home;
|
|
14
|
+
const { createRoleWorktree, safeRemoveWorktree, inspectLeftoverWorktree, isWorktreeClean, withRepoLock, pushBranch, commitsAhead, findWorktreeForBranch, resolveDeveloperWorktree, worktreesRoot, WORKTREES_DIR_NAME, } = await import("./git-worktree.js");
|
|
15
|
+
let repo;
|
|
16
|
+
let remote;
|
|
17
|
+
function git(cwd, ...args) {
|
|
18
|
+
return execFileSync("git", args, { cwd, encoding: "utf8" }).trim();
|
|
19
|
+
}
|
|
20
|
+
before(() => {
|
|
21
|
+
repo = fs.mkdtempSync(path.join(os.tmpdir(), "dealer-wt-repo-"));
|
|
22
|
+
git(repo, "init", "-b", "main");
|
|
23
|
+
git(repo, "config", "user.email", "test@example.com");
|
|
24
|
+
git(repo, "config", "user.name", "Test");
|
|
25
|
+
fs.writeFileSync(path.join(repo, "README.md"), "hello\n");
|
|
26
|
+
git(repo, "add", ".");
|
|
27
|
+
git(repo, "commit", "-m", "init");
|
|
28
|
+
git(repo, "branch", "issue-1");
|
|
29
|
+
// A real `origin` so push-block tests exercise the pushurl override, not just a
|
|
30
|
+
// missing-remote error, while fetch/read stays meaningful.
|
|
31
|
+
remote = fs.mkdtempSync(path.join(os.tmpdir(), "dealer-wt-remote-"));
|
|
32
|
+
execFileSync("git", ["init", "-q", "--bare", "-b", "main", remote]);
|
|
33
|
+
git(repo, "remote", "add", "origin", remote);
|
|
34
|
+
git(repo, "push", "-q", "origin", "main", "issue-1");
|
|
35
|
+
});
|
|
36
|
+
after(() => {
|
|
37
|
+
fs.rmSync(home, { recursive: true, force: true });
|
|
38
|
+
fs.rmSync(repo, { recursive: true, force: true });
|
|
39
|
+
fs.rmSync(remote, { recursive: true, force: true });
|
|
40
|
+
});
|
|
41
|
+
test("createRoleWorktree gives the developer a branch checkout and the reviewer a detached one", async () => {
|
|
42
|
+
const dev = await createRoleWorktree({ repo, role: "developer", sessionId: "s-dev", ref: "issue-1" });
|
|
43
|
+
assert.ok(dev.path.startsWith(fs.realpathSync(worktreesRoot(repo)) + path.sep));
|
|
44
|
+
assert.ok(dev.path.includes(WORKTREES_DIR_NAME));
|
|
45
|
+
const head = git(repo, "rev-parse", "HEAD");
|
|
46
|
+
const rev = await createRoleWorktree({ repo, role: "reviewer", sessionId: "s-rev", ref: head });
|
|
47
|
+
assert.ok(fs.existsSync(dev.path));
|
|
48
|
+
assert.equal(dev.detached, false);
|
|
49
|
+
assert.equal(git(dev.path, "rev-parse", "--abbrev-ref", "HEAD"), "issue-1");
|
|
50
|
+
assert.ok(fs.existsSync(rev.path));
|
|
51
|
+
assert.equal(rev.detached, true);
|
|
52
|
+
assert.equal(git(rev.path, "rev-parse", "--abbrev-ref", "HEAD"), "HEAD");
|
|
53
|
+
await safeRemoveWorktree({ repo, path: dev.path, role: "developer", branchPushed: true });
|
|
54
|
+
await safeRemoveWorktree({ repo, path: rev.path, role: "reviewer" });
|
|
55
|
+
});
|
|
56
|
+
test("NOT-145: salvageDirtyWorktree auto-commits dirty WIP as a marked tip", async () => {
|
|
57
|
+
const { salvageDirtyWorktree, SALVAGE_TIMEOUT_MESSAGE, SALVAGE_CRASH_MESSAGE } = await import("./git-worktree.js");
|
|
58
|
+
const dev = await createRoleWorktree({ repo, role: "developer", sessionId: "s-salvage", ref: "issue-1" });
|
|
59
|
+
fs.writeFileSync(path.join(dev.path, "half-done.txt"), "wip\n");
|
|
60
|
+
assert.equal(await isWorktreeClean(dev.path), false);
|
|
61
|
+
const before = git(dev.path, "rev-parse", "HEAD");
|
|
62
|
+
const result = await salvageDirtyWorktree(dev.path, "timeout");
|
|
63
|
+
assert.equal(result.ok, true);
|
|
64
|
+
if (result.ok) {
|
|
65
|
+
assert.equal(result.message, SALVAGE_TIMEOUT_MESSAGE);
|
|
66
|
+
assert.notEqual(result.commitSha, before);
|
|
67
|
+
assert.equal(git(dev.path, "rev-parse", "HEAD"), result.commitSha);
|
|
68
|
+
assert.equal(git(dev.path, "log", "-1", "--pretty=%s"), SALVAGE_TIMEOUT_MESSAGE);
|
|
69
|
+
}
|
|
70
|
+
assert.equal(await isWorktreeClean(dev.path), true);
|
|
71
|
+
fs.writeFileSync(path.join(dev.path, "more.txt"), "again\n");
|
|
72
|
+
const crash = await salvageDirtyWorktree(dev.path, "crash");
|
|
73
|
+
assert.equal(crash.ok, true);
|
|
74
|
+
if (crash.ok)
|
|
75
|
+
assert.equal(crash.message, SALVAGE_CRASH_MESSAGE);
|
|
76
|
+
await safeRemoveWorktree({ repo, path: dev.path, role: "developer", branchPushed: true });
|
|
77
|
+
});
|
|
78
|
+
test("NOT-145: salvageDirtyWorktree reports failure without mutating when there is nothing to commit", async () => {
|
|
79
|
+
const { salvageDirtyWorktree } = await import("./git-worktree.js");
|
|
80
|
+
const dev = await createRoleWorktree({ repo, role: "developer", sessionId: "s-salvage-empty", ref: "issue-1" });
|
|
81
|
+
assert.equal(await isWorktreeClean(dev.path), true);
|
|
82
|
+
const before = git(dev.path, "rev-parse", "HEAD");
|
|
83
|
+
const result = await salvageDirtyWorktree(dev.path, "timeout");
|
|
84
|
+
assert.equal(result.ok, false);
|
|
85
|
+
assert.equal(git(dev.path, "rev-parse", "HEAD"), before);
|
|
86
|
+
await safeRemoveWorktree({ repo, path: dev.path, role: "developer", branchPushed: true });
|
|
87
|
+
});
|
|
88
|
+
test("safeRemoveWorktree removes a clean reviewer checkout but preserves a dirty developer one", async () => {
|
|
89
|
+
const rev = await createRoleWorktree({ repo, role: "reviewer", sessionId: "s-rev2", ref: "HEAD" });
|
|
90
|
+
const removed = await safeRemoveWorktree({ repo, path: rev.path, role: "reviewer" });
|
|
91
|
+
assert.deepEqual(removed, { removed: true });
|
|
92
|
+
assert.ok(!fs.existsSync(rev.path));
|
|
93
|
+
const dev = await createRoleWorktree({ repo, role: "developer", sessionId: "s-dev2", ref: "issue-1" });
|
|
94
|
+
fs.writeFileSync(path.join(dev.path, "scratch.txt"), "uncommitted work\n");
|
|
95
|
+
assert.equal(await isWorktreeClean(dev.path), false);
|
|
96
|
+
const result = await safeRemoveWorktree({ repo, path: dev.path, role: "developer", branchPushed: true });
|
|
97
|
+
assert.equal(result.removed, false);
|
|
98
|
+
assert.ok(fs.existsSync(dev.path), "dirty developer worktree must be preserved");
|
|
99
|
+
if (result.removed === false) {
|
|
100
|
+
assert.match(result.reason, /uncommitted/);
|
|
101
|
+
assert.ok(result.recoveryCommands.some((c) => c.includes(dev.path)));
|
|
102
|
+
}
|
|
103
|
+
// clean it up for real
|
|
104
|
+
fs.rmSync(dev.path, { recursive: true, force: true });
|
|
105
|
+
await withRepoLock(repo, async () => {
|
|
106
|
+
execFileSync("git", ["worktree", "prune"], { cwd: repo });
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
test("safeRemoveWorktree preserves a clean developer worktree whose branch is not pushed", async () => {
|
|
110
|
+
const dev = await createRoleWorktree({ repo, role: "developer", sessionId: "s-dev3", ref: "issue-1" });
|
|
111
|
+
const result = await safeRemoveWorktree({ repo, path: dev.path, role: "developer", branchPushed: false });
|
|
112
|
+
assert.equal(result.removed, false);
|
|
113
|
+
if (result.removed === false)
|
|
114
|
+
assert.match(result.reason, /not pushed/);
|
|
115
|
+
fs.rmSync(dev.path, { recursive: true, force: true });
|
|
116
|
+
await withRepoLock(repo, async () => execFileSync("git", ["worktree", "prune"], { cwd: repo }));
|
|
117
|
+
});
|
|
118
|
+
test("withRepoLock serializes concurrent worktree operations on one repo", async () => {
|
|
119
|
+
const order = [];
|
|
120
|
+
const slow = withRepoLock(repo, async () => {
|
|
121
|
+
order.push("a:start");
|
|
122
|
+
await new Promise((r) => setTimeout(r, 30));
|
|
123
|
+
order.push("a:end");
|
|
124
|
+
});
|
|
125
|
+
const fast = withRepoLock(repo, async () => {
|
|
126
|
+
order.push("b:start");
|
|
127
|
+
order.push("b:end");
|
|
128
|
+
});
|
|
129
|
+
await Promise.all([slow, fast]);
|
|
130
|
+
assert.deepEqual(order, ["a:start", "a:end", "b:start", "b:end"]);
|
|
131
|
+
});
|
|
132
|
+
test("reviewer worktrees redirect origin's pushurl as hygiene — an ordinary push fails, reads are unaffected", async () => {
|
|
133
|
+
const rev = await createRoleWorktree({ repo, role: "reviewer", sessionId: "s-rev-pushblock", ref: "HEAD" });
|
|
134
|
+
try {
|
|
135
|
+
assert.throws(() => git(rev.path, "push", "origin", "HEAD:refs/heads/reviewer-accident"));
|
|
136
|
+
assert.doesNotThrow(() => git(rev.path, "fetch", "origin"));
|
|
137
|
+
// Scoped to this worktree — the original checkout's own pushurl is untouched.
|
|
138
|
+
assert.equal(git(repo, "remote", "get-url", "--push", "origin"), remote);
|
|
139
|
+
}
|
|
140
|
+
finally {
|
|
141
|
+
await safeRemoveWorktree({ repo, path: rev.path, role: "reviewer" });
|
|
142
|
+
}
|
|
143
|
+
});
|
|
144
|
+
test("the pushurl redirect is NOT a security boundary — it does not survive a targeted bypass", async () => {
|
|
145
|
+
// Documents the exact bypasses a PR review round found, so this limitation can't
|
|
146
|
+
// silently regress into being treated as enforcement again: reviewer worktrees get no
|
|
147
|
+
// Bash/write tool grant at all (args.ts), which is the actual reason a reviewer can't
|
|
148
|
+
// push — this git-config redirect alone would not stop a session that could edit files.
|
|
149
|
+
const rev = await createRoleWorktree({ repo, role: "reviewer", sessionId: "s-rev-bypass", ref: "HEAD" });
|
|
150
|
+
try {
|
|
151
|
+
// Bypass 1: push straight to the remote URL instead of the configured name.
|
|
152
|
+
assert.doesNotThrow(() => git(rev.path, "push", remote, "HEAD:refs/heads/bypass-1"));
|
|
153
|
+
// Bypass 2: the config lives inside the worktree the same process can already write.
|
|
154
|
+
git(rev.path, "config", "--worktree", "--unset-all", "remote.origin.pushurl");
|
|
155
|
+
assert.doesNotThrow(() => git(rev.path, "push", "origin", "HEAD:refs/heads/bypass-2"));
|
|
156
|
+
}
|
|
157
|
+
finally {
|
|
158
|
+
// Throwaway bare remote is removed wholesale in after() — no branch cleanup needed.
|
|
159
|
+
await safeRemoveWorktree({ repo, path: rev.path, role: "reviewer" });
|
|
160
|
+
}
|
|
161
|
+
});
|
|
162
|
+
test("createRoleWorktree with newBranch creates a fresh branch off the given base ref (round 1)", async () => {
|
|
163
|
+
const dev = await createRoleWorktree({ repo, role: "developer", sessionId: "s-dev-new", ref: "main", newBranch: "issue-new-1" });
|
|
164
|
+
assert.equal(git(dev.path, "rev-parse", "--abbrev-ref", "HEAD"), "issue-new-1");
|
|
165
|
+
assert.equal(dev.ref, "issue-new-1");
|
|
166
|
+
fs.rmSync(dev.path, { recursive: true, force: true });
|
|
167
|
+
await withRepoLock(repo, async () => execFileSync("git", ["worktree", "prune"], { cwd: repo }));
|
|
168
|
+
execFileSync("git", ["branch", "-D", "issue-new-1"], { cwd: repo });
|
|
169
|
+
});
|
|
170
|
+
test("commitsAhead counts commits on HEAD not on the base ref, zero when there are none", async () => {
|
|
171
|
+
const dev = await createRoleWorktree({ repo, role: "developer", sessionId: "s-dev-ahead", ref: "main", newBranch: "issue-ahead" });
|
|
172
|
+
assert.equal(await commitsAhead({ worktreePath: dev.path, baseRef: "origin/main" }), 0);
|
|
173
|
+
fs.writeFileSync(path.join(dev.path, "feature.txt"), "work\n");
|
|
174
|
+
git(dev.path, "add", ".");
|
|
175
|
+
git(dev.path, "commit", "-m", "feature");
|
|
176
|
+
assert.equal(await commitsAhead({ worktreePath: dev.path, baseRef: "origin/main" }), 1);
|
|
177
|
+
fs.rmSync(dev.path, { recursive: true, force: true });
|
|
178
|
+
await withRepoLock(repo, async () => execFileSync("git", ["worktree", "prune"], { cwd: repo }));
|
|
179
|
+
execFileSync("git", ["branch", "-D", "issue-ahead"], { cwd: repo });
|
|
180
|
+
});
|
|
181
|
+
test("pushBranch pushes a clean commit and reports a real rejection distinctly from a tooling failure", async () => {
|
|
182
|
+
const dev = await createRoleWorktree({ repo, role: "developer", sessionId: "s-dev-push", ref: "main", newBranch: "issue-push" });
|
|
183
|
+
fs.writeFileSync(path.join(dev.path, "feature.txt"), "work\n");
|
|
184
|
+
git(dev.path, "add", ".");
|
|
185
|
+
git(dev.path, "commit", "-m", "feature");
|
|
186
|
+
const ok = await pushBranch({ worktreePath: dev.path, branch: "issue-push" });
|
|
187
|
+
assert.deepEqual(ok, { ok: true });
|
|
188
|
+
assert.equal(git(repo, "ls-remote", "origin", "refs/heads/issue-push").length > 0, true);
|
|
189
|
+
// Diverge the remote branch, then try to push again — a real rejection, not a crash.
|
|
190
|
+
const other = fs.mkdtempSync(path.join(os.tmpdir(), "dealer-wt-other-"));
|
|
191
|
+
execFileSync("git", ["clone", "-q", remote, other]);
|
|
192
|
+
git(other, "checkout", "issue-push");
|
|
193
|
+
git(other, "config", "user.email", "test@example.com");
|
|
194
|
+
git(other, "config", "user.name", "Test");
|
|
195
|
+
fs.writeFileSync(path.join(other, "elsewhere.txt"), "y");
|
|
196
|
+
git(other, "add", ".");
|
|
197
|
+
git(other, "commit", "-m", "elsewhere");
|
|
198
|
+
git(other, "push", "origin", "issue-push");
|
|
199
|
+
const remoteSha = git(other, "rev-parse", "HEAD");
|
|
200
|
+
fs.writeFileSync(path.join(dev.path, "feature2.txt"), "more\n");
|
|
201
|
+
git(dev.path, "add", ".");
|
|
202
|
+
git(dev.path, "commit", "-m", "feature2");
|
|
203
|
+
const localSha = git(dev.path, "rev-parse", "HEAD");
|
|
204
|
+
const rejected = await pushBranch({ worktreePath: dev.path, branch: "issue-push" });
|
|
205
|
+
assert.equal(rejected.ok, false);
|
|
206
|
+
if (!rejected.ok) {
|
|
207
|
+
assert.equal(rejected.rejected, true);
|
|
208
|
+
// NOT-137: rejection carries divergence facts — not opaque git stderr alone.
|
|
209
|
+
assert.ok(rejected.facts, "rejected push must attach divergence facts");
|
|
210
|
+
assert.equal(rejected.facts.localSha, localSha);
|
|
211
|
+
assert.equal(rejected.facts.remoteSha, remoteSha);
|
|
212
|
+
assert.equal(rejected.facts.ahead, 1);
|
|
213
|
+
assert.equal(rejected.facts.behind, 1);
|
|
214
|
+
assert.equal(rejected.facts.relationship, "diverged");
|
|
215
|
+
assert.match(rejected.reason, /diverged/i);
|
|
216
|
+
assert.doesNotMatch(rejected.reason, /use 'git pull'/i);
|
|
217
|
+
assert.ok(rejected.facts.recoveryCommands.some((c) => c.includes("force-with-lease")));
|
|
218
|
+
assert.ok(rejected.facts.recoveryCommands.some((c) => c.includes(remoteSha)));
|
|
219
|
+
}
|
|
220
|
+
const infra = await pushBranch({ worktreePath: "/no/such/worktree", branch: "issue-push" });
|
|
221
|
+
assert.equal(infra.ok, false);
|
|
222
|
+
if (!infra.ok)
|
|
223
|
+
assert.equal(infra.rejected, false);
|
|
224
|
+
fs.rmSync(dev.path, { recursive: true, force: true });
|
|
225
|
+
fs.rmSync(other, { recursive: true, force: true });
|
|
226
|
+
await withRepoLock(repo, async () => execFileSync("git", ["worktree", "prune"], { cwd: repo }));
|
|
227
|
+
execFileSync("git", ["branch", "-D", "issue-push"], { cwd: repo });
|
|
228
|
+
});
|
|
229
|
+
test("NOT-137: pushBranch rejection when local is strictly behind reports behind + rebase recovery, not force-with-lease", async () => {
|
|
230
|
+
// Local tip equals the last successful push; remote alone advanced. Resetting local to that
|
|
231
|
+
// tip after a remote-only commit yields ahead=0, behind=1 — the "behind" relationship.
|
|
232
|
+
const branch = "issue-push-behind";
|
|
233
|
+
const dev = await createRoleWorktree({
|
|
234
|
+
repo,
|
|
235
|
+
role: "developer",
|
|
236
|
+
sessionId: "s-dev-push-behind",
|
|
237
|
+
ref: "main",
|
|
238
|
+
newBranch: branch,
|
|
239
|
+
});
|
|
240
|
+
fs.writeFileSync(path.join(dev.path, "feature.txt"), "work\n");
|
|
241
|
+
git(dev.path, "add", ".");
|
|
242
|
+
git(dev.path, "commit", "-m", "feature");
|
|
243
|
+
assert.deepEqual(await pushBranch({ worktreePath: dev.path, branch }), { ok: true });
|
|
244
|
+
const pushedSha = git(dev.path, "rev-parse", "HEAD");
|
|
245
|
+
const other = fs.mkdtempSync(path.join(os.tmpdir(), "dealer-wt-behind-"));
|
|
246
|
+
execFileSync("git", ["clone", "-q", remote, other]);
|
|
247
|
+
git(other, "checkout", branch);
|
|
248
|
+
git(other, "config", "user.email", "test@example.com");
|
|
249
|
+
git(other, "config", "user.name", "Test");
|
|
250
|
+
fs.writeFileSync(path.join(other, "remote-only.txt"), "r");
|
|
251
|
+
git(other, "add", ".");
|
|
252
|
+
git(other, "commit", "-m", "remote-only");
|
|
253
|
+
git(other, "push", "origin", branch);
|
|
254
|
+
const remoteSha = git(other, "rev-parse", "HEAD");
|
|
255
|
+
// Local has no unique commits — reset to the previously pushed tip.
|
|
256
|
+
git(dev.path, "reset", "--hard", pushedSha);
|
|
257
|
+
// pushBranch always pushes HEAD; force a rejection by advancing... wait, ahead=0 means
|
|
258
|
+
// HEAD is an ancestor of remote, so push is a no-op / rejected as non-FF depending on git.
|
|
259
|
+
// `git push` of an ancestor tip is rejected as non-fast-forward when remote moved.
|
|
260
|
+
const rejected = await pushBranch({ worktreePath: dev.path, branch });
|
|
261
|
+
assert.equal(rejected.ok, false);
|
|
262
|
+
if (!rejected.ok) {
|
|
263
|
+
assert.equal(rejected.rejected, true);
|
|
264
|
+
assert.ok(rejected.facts);
|
|
265
|
+
assert.equal(rejected.facts.relationship, "behind");
|
|
266
|
+
assert.equal(rejected.facts.ahead, 0);
|
|
267
|
+
assert.equal(rejected.facts.behind, 1);
|
|
268
|
+
assert.equal(rejected.facts.localSha, pushedSha);
|
|
269
|
+
assert.equal(rejected.facts.remoteSha, remoteSha);
|
|
270
|
+
assert.match(rejected.reason, /behind/i);
|
|
271
|
+
assert.doesNotMatch(rejected.reason, /diverged/i);
|
|
272
|
+
assert.ok(rejected.facts.recoveryCommands.some((c) => /rebase/i.test(c)));
|
|
273
|
+
assert.ok(!rejected.facts.recoveryCommands.some((c) => c.includes("force-with-lease")));
|
|
274
|
+
}
|
|
275
|
+
fs.rmSync(dev.path, { recursive: true, force: true });
|
|
276
|
+
fs.rmSync(other, { recursive: true, force: true });
|
|
277
|
+
await withRepoLock(repo, async () => execFileSync("git", ["worktree", "prune"], { cwd: repo }));
|
|
278
|
+
execFileSync("git", ["branch", "-D", branch], { cwd: repo });
|
|
279
|
+
});
|
|
280
|
+
test("inspectLeftoverWorktree classifies missing / clean / dirty", async () => {
|
|
281
|
+
assert.equal(await inspectLeftoverWorktree("/no/such/path"), "missing");
|
|
282
|
+
const dev = await createRoleWorktree({ repo, role: "developer", sessionId: "s-dev4", ref: "issue-1" });
|
|
283
|
+
assert.equal(await inspectLeftoverWorktree(dev.path), "clean");
|
|
284
|
+
fs.writeFileSync(path.join(dev.path, "x.txt"), "y");
|
|
285
|
+
assert.equal(await inspectLeftoverWorktree(dev.path), "dirty_or_unpushed");
|
|
286
|
+
fs.rmSync(dev.path, { recursive: true, force: true });
|
|
287
|
+
await withRepoLock(repo, async () => execFileSync("git", ["worktree", "prune"], { cwd: repo }));
|
|
288
|
+
});
|
|
289
|
+
test("findWorktreeForBranch finds the checkout holding a branch and returns null for an untouched one", async () => {
|
|
290
|
+
assert.equal(await findWorktreeForBranch(repo, "issue-1"), null);
|
|
291
|
+
const dev = await createRoleWorktree({ repo, role: "developer", sessionId: "s-find1", ref: "issue-1" });
|
|
292
|
+
assert.equal(await findWorktreeForBranch(repo, "issue-1"), dev.path);
|
|
293
|
+
// A detached (reviewer) checkout carries no `branch` line and must never match.
|
|
294
|
+
const rev = await createRoleWorktree({ repo, role: "reviewer", sessionId: "s-find2", ref: "HEAD" });
|
|
295
|
+
assert.equal(await findWorktreeForBranch(repo, "issue-1"), dev.path);
|
|
296
|
+
await safeRemoveWorktree({ repo, path: rev.path, role: "reviewer" });
|
|
297
|
+
fs.rmSync(dev.path, { recursive: true, force: true });
|
|
298
|
+
await withRepoLock(repo, async () => execFileSync("git", ["worktree", "prune"], { cwd: repo }));
|
|
299
|
+
});
|
|
300
|
+
test("resolveDeveloperWorktree creates a fresh worktree when the branch isn't checked out anywhere", async () => {
|
|
301
|
+
const resolved = await resolveDeveloperWorktree({
|
|
302
|
+
repo,
|
|
303
|
+
sessionId: "s-resolve-new",
|
|
304
|
+
branchName: "issue-resolve-new",
|
|
305
|
+
baseBranch: "main",
|
|
306
|
+
reuseBranch: false,
|
|
307
|
+
});
|
|
308
|
+
assert.equal(resolved.kind, "created");
|
|
309
|
+
assert.ok(fs.existsSync(resolved.path));
|
|
310
|
+
fs.rmSync(resolved.path, { recursive: true, force: true });
|
|
311
|
+
await withRepoLock(repo, async () => execFileSync("git", ["worktree", "prune"], { cwd: repo }));
|
|
312
|
+
execFileSync("git", ["branch", "-D", "issue-resolve-new"], { cwd: repo });
|
|
313
|
+
});
|
|
314
|
+
test("resolveDeveloperWorktree reuses a clean leftover worktree from an earlier session on the same branch", async () => {
|
|
315
|
+
// Simulates the exact NOT-88 repro: an earlier round's `createRoleWorktree` (keyed by its
|
|
316
|
+
// OLD sessionId) is still on disk, clean, holding the branch — a plain `git worktree add`
|
|
317
|
+
// for a NEW session id would collide with it ("already used by worktree").
|
|
318
|
+
const leftover = await createRoleWorktree({ repo, role: "developer", sessionId: "s-leftover-clean", ref: "issue-1" });
|
|
319
|
+
const resolved = await resolveDeveloperWorktree({
|
|
320
|
+
repo,
|
|
321
|
+
sessionId: "s-new-session",
|
|
322
|
+
branchName: "issue-1",
|
|
323
|
+
baseBranch: "main",
|
|
324
|
+
reuseBranch: true,
|
|
325
|
+
});
|
|
326
|
+
assert.equal(resolved.kind, "reused");
|
|
327
|
+
assert.equal(resolved.path, leftover.path);
|
|
328
|
+
fs.rmSync(leftover.path, { recursive: true, force: true });
|
|
329
|
+
await withRepoLock(repo, async () => execFileSync("git", ["worktree", "prune"], { cwd: repo }));
|
|
330
|
+
});
|
|
331
|
+
test("resolveDeveloperWorktree reports a conflict — with recovery commands — for a dirty leftover, never force-removing it", async () => {
|
|
332
|
+
const leftover = await createRoleWorktree({ repo, role: "developer", sessionId: "s-leftover-dirty", ref: "issue-1" });
|
|
333
|
+
fs.writeFileSync(path.join(leftover.path, "uncommitted.txt"), "still here\n");
|
|
334
|
+
const resolved = await resolveDeveloperWorktree({
|
|
335
|
+
repo,
|
|
336
|
+
sessionId: "s-new-session-2",
|
|
337
|
+
branchName: "issue-1",
|
|
338
|
+
baseBranch: "main",
|
|
339
|
+
reuseBranch: true,
|
|
340
|
+
});
|
|
341
|
+
assert.equal(resolved.kind, "conflict");
|
|
342
|
+
if (resolved.kind === "conflict") {
|
|
343
|
+
assert.equal(resolved.path, leftover.path);
|
|
344
|
+
assert.match(resolved.reason, /uncommitted changes/);
|
|
345
|
+
assert.ok(resolved.recoveryCommands.some((c) => c.includes(leftover.path)));
|
|
346
|
+
}
|
|
347
|
+
assert.ok(fs.existsSync(leftover.path), "a dirty leftover must never be force-removed by resolution");
|
|
348
|
+
// Calling it again with nothing changed must report the SAME conflict, not loop into a
|
|
349
|
+
// different failure mode or silently clear it — this is what breaks the "Resume loops"
|
|
350
|
+
// bug into an actionable, stable escalation instead of a fresh crash each time.
|
|
351
|
+
const resolvedAgain = await resolveDeveloperWorktree({
|
|
352
|
+
repo,
|
|
353
|
+
sessionId: "s-new-session-3",
|
|
354
|
+
branchName: "issue-1",
|
|
355
|
+
baseBranch: "main",
|
|
356
|
+
reuseBranch: true,
|
|
357
|
+
});
|
|
358
|
+
assert.equal(resolvedAgain.kind, "conflict");
|
|
359
|
+
if (resolvedAgain.kind === "conflict")
|
|
360
|
+
assert.equal(resolvedAgain.path, leftover.path);
|
|
361
|
+
fs.rmSync(leftover.path, { recursive: true, force: true });
|
|
362
|
+
await withRepoLock(repo, async () => execFileSync("git", ["worktree", "prune"], { cwd: repo }));
|
|
363
|
+
});
|
|
364
|
+
test("resolveDeveloperWorktree prunes a stale (directory-deleted) leftover and creates a fresh worktree", async () => {
|
|
365
|
+
const leftover = await createRoleWorktree({ repo, role: "developer", sessionId: "s-leftover-stale", ref: "issue-1" });
|
|
366
|
+
// Delete the directory directly, bypassing `git worktree remove` — leaves a stale
|
|
367
|
+
// administrative entry in `.git/worktrees` that `git worktree list` still reports.
|
|
368
|
+
fs.rmSync(leftover.path, { recursive: true, force: true });
|
|
369
|
+
const resolved = await resolveDeveloperWorktree({
|
|
370
|
+
repo,
|
|
371
|
+
sessionId: "s-new-session-4",
|
|
372
|
+
branchName: "issue-1",
|
|
373
|
+
baseBranch: "main",
|
|
374
|
+
reuseBranch: true,
|
|
375
|
+
});
|
|
376
|
+
assert.equal(resolved.kind, "created");
|
|
377
|
+
assert.ok(fs.existsSync(resolved.path));
|
|
378
|
+
fs.rmSync(resolved.path, { recursive: true, force: true });
|
|
379
|
+
await withRepoLock(repo, async () => execFileSync("git", ["worktree", "prune"], { cwd: repo }));
|
|
380
|
+
});
|
|
381
|
+
test("resolveDeveloperWorktree treats an on-disk leftover whose git status can't be read as a conflict, never as a safe-to-recreate stale entry", async () => {
|
|
382
|
+
// A review round found: inspectLeftoverWorktree maps ANY `git status` failure to
|
|
383
|
+
// "missing" — including a leftover that is very much still on disk but whose checkout is
|
|
384
|
+
// corrupt/unreadable for some other reason. Treating that as a harmless stale entry would
|
|
385
|
+
// prune (a no-op, since the directory IS there) and fall through to `addWorktree`, which
|
|
386
|
+
// collides identically with the still-registered worktree — a narrower repeat of the exact
|
|
387
|
+
// loop this function exists to close.
|
|
388
|
+
const leftover = await createRoleWorktree({ repo, role: "developer", sessionId: "s-leftover-corrupt", ref: "issue-1" });
|
|
389
|
+
// Overwriting (not deleting) the worktree's `.git` file pointer keeps `git worktree
|
|
390
|
+
// prune` from reclaiming the entry (its own liveness check passes — the file exists) while
|
|
391
|
+
// `git status` inside the worktree now fails outright, reproducing "still registered and
|
|
392
|
+
// on disk, but status unreadable" without deleting anything prune itself would notice.
|
|
393
|
+
fs.writeFileSync(path.join(leftover.path, ".git"), "gitdir: /nonexistent/path/that/does/not/exist\n");
|
|
394
|
+
assert.equal(await inspectLeftoverWorktree(leftover.path), "missing", "sanity: a broken .git link reports as 'missing' too, not just a deleted directory");
|
|
395
|
+
const resolved = await resolveDeveloperWorktree({
|
|
396
|
+
repo,
|
|
397
|
+
sessionId: "s-new-session-5",
|
|
398
|
+
branchName: "issue-1",
|
|
399
|
+
baseBranch: "main",
|
|
400
|
+
reuseBranch: true,
|
|
401
|
+
});
|
|
402
|
+
assert.equal(resolved.kind, "conflict");
|
|
403
|
+
if (resolved.kind === "conflict") {
|
|
404
|
+
assert.equal(resolved.path, leftover.path);
|
|
405
|
+
assert.match(resolved.reason, /could not be determined/);
|
|
406
|
+
}
|
|
407
|
+
assert.ok(fs.existsSync(leftover.path), "never removed out from under an undetermined leftover");
|
|
408
|
+
fs.rmSync(leftover.path, { recursive: true, force: true });
|
|
409
|
+
await withRepoLock(repo, async () => execFileSync("git", ["worktree", "prune"], { cwd: repo }));
|
|
410
|
+
});
|
|
411
|
+
test("resolveDeveloperWorktree reports a conflict for a branch checked out outside the coordinator's managed worktrees, without touching it", async () => {
|
|
412
|
+
const external = fs.mkdtempSync(path.join(os.tmpdir(), "dealer-wt-external-"));
|
|
413
|
+
execFileSync("git", ["worktree", "add", external, "issue-1"], { cwd: repo });
|
|
414
|
+
try {
|
|
415
|
+
const resolved = await resolveDeveloperWorktree({
|
|
416
|
+
repo,
|
|
417
|
+
sessionId: "s-new-session-external",
|
|
418
|
+
branchName: "issue-1",
|
|
419
|
+
baseBranch: "main",
|
|
420
|
+
reuseBranch: true,
|
|
421
|
+
});
|
|
422
|
+
assert.equal(resolved.kind, "conflict");
|
|
423
|
+
if (resolved.kind === "conflict") {
|
|
424
|
+
assert.equal(fs.realpathSync(resolved.path), fs.realpathSync(external));
|
|
425
|
+
assert.match(resolved.reason, /outside the coordinator/);
|
|
426
|
+
}
|
|
427
|
+
assert.ok(fs.existsSync(external), "an externally managed worktree must never be touched or removed");
|
|
428
|
+
}
|
|
429
|
+
finally {
|
|
430
|
+
execFileSync("git", ["worktree", "remove", "--force", external], { cwd: repo });
|
|
431
|
+
}
|
|
432
|
+
});
|
|
433
|
+
// ---------------------------------------------------------------- NOT-127: owner liveness
|
|
434
|
+
test("NOT-127: resolveDeveloperWorktree never adopts a leftover whose owning session is still live (clean or dirty)", async () => {
|
|
435
|
+
const leftover = await createRoleWorktree({ repo, role: "developer", sessionId: "s-live-owner", ref: "issue-1" });
|
|
436
|
+
// Clean leftover — pre-NOT-127 this would have been silently reused.
|
|
437
|
+
const clean = await resolveDeveloperWorktree({
|
|
438
|
+
repo,
|
|
439
|
+
sessionId: "s-new-live-clean",
|
|
440
|
+
branchName: "issue-1",
|
|
441
|
+
baseBranch: "main",
|
|
442
|
+
reuseBranch: true,
|
|
443
|
+
ownerLiveness: () => ({ state: "alive", sessionId: "s-live-owner" }),
|
|
444
|
+
});
|
|
445
|
+
assert.equal(clean.kind, "live_owner");
|
|
446
|
+
if (clean.kind === "live_owner") {
|
|
447
|
+
assert.equal(clean.path, leftover.path);
|
|
448
|
+
assert.equal(clean.ownerSessionId, "s-live-owner");
|
|
449
|
+
assert.match(clean.reason, /live process/);
|
|
450
|
+
}
|
|
451
|
+
// Dirty leftover — pre-NOT-127 this would have escalated as worktree_conflict.
|
|
452
|
+
fs.writeFileSync(path.join(leftover.path, "wip.txt"), "owner still typing\n");
|
|
453
|
+
const dirty = await resolveDeveloperWorktree({
|
|
454
|
+
repo,
|
|
455
|
+
sessionId: "s-new-live-dirty",
|
|
456
|
+
branchName: "issue-1",
|
|
457
|
+
baseBranch: "main",
|
|
458
|
+
reuseBranch: true,
|
|
459
|
+
ownerLiveness: () => ({ state: "alive", sessionId: "s-live-owner" }),
|
|
460
|
+
});
|
|
461
|
+
assert.equal(dirty.kind, "live_owner");
|
|
462
|
+
if (dirty.kind === "live_owner")
|
|
463
|
+
assert.equal(dirty.path, leftover.path);
|
|
464
|
+
assert.ok(fs.existsSync(leftover.path), "live owner's worktree must never be removed");
|
|
465
|
+
fs.rmSync(leftover.path, { recursive: true, force: true });
|
|
466
|
+
await withRepoLock(repo, async () => execFileSync("git", ["worktree", "prune"], { cwd: repo }));
|
|
467
|
+
});
|
|
468
|
+
test("NOT-127: resolveDeveloperWorktree still reuses a clean leftover when the owning session is dead", async () => {
|
|
469
|
+
const leftover = await createRoleWorktree({ repo, role: "developer", sessionId: "s-dead-clean", ref: "issue-1" });
|
|
470
|
+
const resolved = await resolveDeveloperWorktree({
|
|
471
|
+
repo,
|
|
472
|
+
sessionId: "s-new-dead-clean",
|
|
473
|
+
branchName: "issue-1",
|
|
474
|
+
baseBranch: "main",
|
|
475
|
+
reuseBranch: true,
|
|
476
|
+
ownerLiveness: () => ({ state: "dead" }),
|
|
477
|
+
});
|
|
478
|
+
assert.equal(resolved.kind, "reused");
|
|
479
|
+
assert.equal(resolved.path, leftover.path);
|
|
480
|
+
fs.rmSync(leftover.path, { recursive: true, force: true });
|
|
481
|
+
await withRepoLock(repo, async () => execFileSync("git", ["worktree", "prune"], { cwd: repo }));
|
|
482
|
+
});
|
|
483
|
+
test("NOT-127: resolveDeveloperWorktree still escalates a dirty leftover when the owning session is dead", async () => {
|
|
484
|
+
const leftover = await createRoleWorktree({ repo, role: "developer", sessionId: "s-dead-dirty", ref: "issue-1" });
|
|
485
|
+
fs.writeFileSync(path.join(leftover.path, "abandoned.txt"), "orphan dirt\n");
|
|
486
|
+
const resolved = await resolveDeveloperWorktree({
|
|
487
|
+
repo,
|
|
488
|
+
sessionId: "s-new-dead-dirty",
|
|
489
|
+
branchName: "issue-1",
|
|
490
|
+
baseBranch: "main",
|
|
491
|
+
reuseBranch: true,
|
|
492
|
+
ownerLiveness: () => ({ state: "dead" }),
|
|
493
|
+
});
|
|
494
|
+
assert.equal(resolved.kind, "conflict");
|
|
495
|
+
if (resolved.kind === "conflict") {
|
|
496
|
+
assert.equal(resolved.path, leftover.path);
|
|
497
|
+
assert.match(resolved.reason, /uncommitted changes/);
|
|
498
|
+
}
|
|
499
|
+
assert.ok(fs.existsSync(leftover.path), "dead-and-dirty leftover must still never be force-removed");
|
|
500
|
+
fs.rmSync(leftover.path, { recursive: true, force: true });
|
|
501
|
+
await withRepoLock(repo, async () => execFileSync("git", ["worktree", "prune"], { cwd: repo }));
|
|
502
|
+
});
|