@gajae-code/coding-agent 0.16.7 → 0.17.1
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/CHANGELOG.md +180 -1
- package/dist/types/capability/fs.d.ts +37 -4
- package/dist/types/capability/index.d.ts +19 -0
- package/dist/types/capability/types.d.ts +9 -0
- package/dist/types/cli/doctor/args.d.ts +36 -0
- package/dist/types/cli/doctor/checks.d.ts +18 -0
- package/dist/types/cli/doctor/config-repairs.d.ts +38 -0
- package/dist/types/cli/doctor/context.d.ts +89 -0
- package/dist/types/cli/doctor/files.d.ts +27 -0
- package/dist/types/cli/doctor/ids.d.ts +20 -0
- package/dist/types/cli/doctor/install-repairs.d.ts +94 -0
- package/dist/types/cli/doctor/journal.d.ts +43 -0
- package/dist/types/cli/doctor/managed-link.d.ts +39 -0
- package/dist/types/cli/doctor/native-probe.d.ts +2 -0
- package/dist/types/cli/doctor/permission-repairs.d.ts +25 -0
- package/dist/types/cli/doctor/plugin-quarantine.d.ts +77 -0
- package/dist/types/cli/doctor/plugin-restore.d.ts +40 -0
- package/dist/types/cli/doctor/plugin-targets.d.ts +11 -0
- package/dist/types/cli/doctor/probe-client.d.ts +9 -0
- package/dist/types/cli/doctor/probe-types.d.ts +18 -0
- package/dist/types/cli/doctor/probe.d.ts +3 -0
- package/dist/types/cli/doctor/projection-probe.d.ts +2 -0
- package/dist/types/cli/doctor/repairs.d.ts +28 -0
- package/dist/types/cli/doctor/report.d.ts +27 -0
- package/dist/types/cli/doctor/runner.d.ts +4 -0
- package/dist/types/cli/doctor/safety.d.ts +10 -0
- package/dist/types/cli/doctor/service-repairs.d.ts +8 -0
- package/dist/types/cli/doctor/service-targets.d.ts +41 -0
- package/dist/types/cli/doctor/types.d.ts +145 -0
- package/dist/types/cli/doctor-cli.d.ts +7 -0
- package/dist/types/cli/doctor-supervisor.d.ts +121 -0
- package/dist/types/cli/doctor-worker.d.ts +29 -0
- package/dist/types/cli/install-activation.d.ts +158 -0
- package/dist/types/cli/macos-community-app.d.ts +41 -0
- package/dist/types/cli/mcp-cli.d.ts +0 -11
- package/dist/types/cli/update-cli.d.ts +41 -4
- package/dist/types/cli-main.d.ts +174 -0
- package/dist/types/cli.d.ts +1 -171
- package/dist/types/commands/doctor.d.ts +7 -0
- package/dist/types/commands/sdk.d.ts +17 -8
- package/dist/types/config/autorouting-tier-map.d.ts +19 -0
- package/dist/types/config/file-lock.d.ts +27 -3
- package/dist/types/config/model-preset-registry.d.ts +1 -0
- package/dist/types/config/model-registry.d.ts +12 -5
- package/dist/types/config/model-resolver.d.ts +16 -3
- package/dist/types/config/models-config-schema.d.ts +2 -0
- package/dist/types/config/settings-schema.d.ts +1 -1
- package/dist/types/coordinator-mcp/question-gate-codec.d.ts +1 -1
- package/dist/types/coordinator-mcp/session-reaper.d.ts +15 -5
- package/dist/types/cursor.d.ts +9 -9
- package/dist/types/discovery/agents-md.d.ts +6 -1
- package/dist/types/discovery/helpers.d.ts +36 -7
- package/dist/types/discovery/index.d.ts +1 -1
- package/dist/types/discovery/mcp-json.d.ts +2 -0
- package/dist/types/extensibility/extensions/loader.d.ts +22 -1
- package/dist/types/extensibility/gjc-plugins/lifecycle.d.ts +14 -1
- package/dist/types/extensibility/gjc-plugins/paths.d.ts +2 -1
- package/dist/types/extensibility/gjc-plugins/types.d.ts +25 -0
- package/dist/types/extensibility/plugins/manager.d.ts +33 -4
- package/dist/types/extensibility/plugins/marketplace/cache.d.ts +6 -0
- package/dist/types/extensibility/plugins/marketplace/manager.d.ts +186 -0
- package/dist/types/extensibility/plugins/marketplace/registry.d.ts +27 -0
- package/dist/types/extensibility/plugins/marketplace/source-resolver.d.ts +16 -1
- package/dist/types/extensibility/runtime-skill-discovery.d.ts +16 -3
- package/dist/types/gjc-runtime/autoresearch-runtime.d.ts +6 -0
- package/dist/types/gjc-runtime/launch-worktree.d.ts +53 -0
- package/dist/types/gjc-runtime/session-layout.d.ts +8 -0
- package/dist/types/gjc-runtime/session-state-lock.d.ts +15 -0
- package/dist/types/gjc-runtime/session-state-sidecar.d.ts +13 -0
- package/dist/types/gjc-runtime/ultragoal-runtime.d.ts +6 -0
- package/dist/types/main.d.ts +19 -1
- package/dist/types/modes/acp/acp-agent.d.ts +40 -0
- package/dist/types/modes/components/agent-dashboard.d.ts +1 -0
- package/dist/types/modes/components/model-selector.d.ts +2 -0
- package/dist/types/modes/interactive-mode.d.ts +1 -1
- package/dist/types/runtime-mcp/config-writer.d.ts +47 -0
- package/dist/types/runtime-mcp/policy-json-edit.d.ts +60 -0
- package/dist/types/runtime-mcp/startup-policy.d.ts +4 -0
- package/dist/types/runtime-mcp/types.d.ts +2 -2
- package/dist/types/sdk/acp/adapter.d.ts +5 -1
- package/dist/types/sdk/broker/broker.d.ts +51 -0
- package/dist/types/sdk/broker/daemon-entry.d.ts +39 -0
- package/dist/types/sdk/broker/discovery.d.ts +38 -0
- package/dist/types/sdk/broker/doctor-restart.d.ts +79 -0
- package/dist/types/sdk/broker/endpoint-authority.d.ts +7 -0
- package/dist/types/sdk/broker/ensure.d.ts +14 -0
- package/dist/types/sdk/broker/lifecycle.d.ts +2 -0
- package/dist/types/sdk/broker/process-incarnation.d.ts +50 -0
- package/dist/types/sdk/broker/runtime.d.ts +26 -0
- package/dist/types/sdk/broker/session-index.d.ts +13 -4
- package/dist/types/sdk/broker/worktree-occupancy.d.ts +14 -0
- package/dist/types/sdk/bus/chat-daemon-cli.d.ts +16 -0
- package/dist/types/sdk/bus/chat-daemon-control.d.ts +101 -6
- package/dist/types/sdk/bus/chat-daemon-runtime.d.ts +16 -0
- package/dist/types/sdk/bus/daemon-startup-exclusion.d.ts +43 -0
- package/dist/types/sdk/bus/doctor-daemon-restart-client.d.ts +51 -0
- package/dist/types/sdk/bus/doctor-daemon-restart.d.ts +39 -0
- package/dist/types/sdk/bus/index.d.ts +2 -0
- package/dist/types/sdk/bus/notification-service.d.ts +3 -0
- package/dist/types/sdk/bus/telegram-daemon-cli.d.ts +11 -0
- package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +1 -1
- package/dist/types/sdk/bus/telegram-daemon-control.d.ts +5 -0
- package/dist/types/sdk/bus/telegram-daemon-owner-registry.d.ts +1 -0
- package/dist/types/sdk/bus/telegram-daemon.d.ts +36 -5
- package/dist/types/sdk/cli/session-cli.d.ts +1 -0
- package/dist/types/sdk/host/session-runtime.d.ts +4 -0
- package/dist/types/sdk/host/surface-policy.d.ts +3 -1
- package/dist/types/sdk/lifecycle/service.d.ts +44 -10
- package/dist/types/sdk/prompt-deadline-lease.d.ts +6 -3
- package/dist/types/sdk/prompt-deadline-manager.d.ts +2 -2
- package/dist/types/sdk/prompt-failure.d.ts +47 -0
- package/dist/types/sdk/prompt-status.d.ts +19 -0
- package/dist/types/sdk/router/session-router.d.ts +3 -4
- package/dist/types/sdk/service-artifact-paths.d.ts +16 -0
- package/dist/types/sdk/session-authority.d.ts +9 -0
- package/dist/types/sdk/session.d.ts +3 -1
- package/dist/types/session/agent-session.d.ts +1 -1
- package/dist/types/session/blob-store.d.ts +1 -1
- package/dist/types/session/fallback-chain-controller.d.ts +3 -6
- package/dist/types/session/internal/managed-session-scope.d.ts +44 -0
- package/dist/types/session/primary-control-surface.d.ts +4 -0
- package/dist/types/session/session-manager.d.ts +6 -0
- package/dist/types/system-prompt.d.ts +6 -0
- package/dist/types/tools/ask.d.ts +25 -0
- package/dist/types/tools/image-gen.d.ts +0 -4
- package/dist/types/tools/skill-discovery.d.ts +7 -5
- package/dist/types/tools/ultragoal-ask-guard.d.ts +7 -4
- package/dist/types/utils/changelog.d.ts +0 -9
- package/dist/types/utils/mupdf-wasm.d.ts +1 -0
- package/package.json +9 -8
- package/scripts/compile-args.ts +8 -4
- package/src/capability/fs.ts +395 -17
- package/src/capability/index.ts +273 -10
- package/src/capability/types.ts +9 -0
- package/src/cli/customize-doctor.ts +18 -12
- package/src/cli/doctor/args.ts +236 -0
- package/src/cli/doctor/checks.ts +431 -0
- package/src/cli/doctor/config-repairs.ts +517 -0
- package/src/cli/doctor/context.ts +149 -0
- package/src/cli/doctor/files.ts +149 -0
- package/src/cli/doctor/ids.ts +115 -0
- package/src/cli/doctor/install-repairs.ts +335 -0
- package/src/cli/doctor/journal.ts +423 -0
- package/src/cli/doctor/managed-link.ts +514 -0
- package/src/cli/doctor/native-probe.ts +13 -0
- package/src/cli/doctor/permission-repairs.ts +242 -0
- package/src/cli/doctor/plugin-quarantine.ts +377 -0
- package/src/cli/doctor/plugin-restore.ts +59 -0
- package/src/cli/doctor/plugin-targets.ts +373 -0
- package/src/cli/doctor/probe-client.ts +195 -0
- package/src/cli/doctor/probe-types.ts +21 -0
- package/src/cli/doctor/probe.ts +42 -0
- package/src/cli/doctor/projection-probe.ts +249 -0
- package/src/cli/doctor/repairs.ts +1157 -0
- package/src/cli/doctor/report.ts +176 -0
- package/src/cli/doctor/runner.ts +342 -0
- package/src/cli/doctor/safety.ts +40 -0
- package/src/cli/doctor/service-repairs.ts +452 -0
- package/src/cli/doctor/service-targets.ts +274 -0
- package/src/cli/doctor/types.ts +164 -0
- package/src/cli/doctor-cli.ts +62 -0
- package/src/cli/doctor-supervisor.ts +571 -0
- package/src/cli/doctor-worker.ts +181 -0
- package/src/cli/install-activation.ts +666 -0
- package/src/cli/macos-community-app.ts +1449 -0
- package/src/cli/mcp-cli.ts +11 -21
- package/src/cli/nofile-limit.ts +5 -1
- package/src/cli/plugin-cli.ts +6 -1
- package/src/cli/update-cli.ts +362 -264
- package/src/cli-main.ts +517 -0
- package/src/cli.ts +64 -433
- package/src/commands/autoresearch.ts +11 -5
- package/src/commands/completion.ts +1 -1
- package/src/commands/doctor.ts +13 -0
- package/src/commands/sdk.ts +58 -10
- package/src/commands/ultragoal.ts +11 -5
- package/src/config/autorouting-tier-map.ts +7 -0
- package/src/config/file-lock.ts +736 -71
- package/src/config/mcp-schema.json +3 -3
- package/src/config/model-preset-registry.ts +1 -0
- package/src/config/model-registry.ts +80 -27
- package/src/config/model-resolver.ts +33 -4
- package/src/config/models-config-schema.ts +2 -0
- package/src/config/settings-schema.ts +6 -2
- package/src/coordinator-mcp/question-gate-codec.ts +1 -0
- package/src/coordinator-mcp/server.ts +534 -51
- package/src/coordinator-mcp/session-reaper.ts +64 -8
- package/src/cursor.ts +223 -53
- package/src/discovery/agents-md.ts +74 -8
- package/src/discovery/agents.ts +32 -7
- package/src/discovery/builtin.ts +184 -75
- package/src/discovery/claude-plugins.ts +85 -17
- package/src/discovery/cline.ts +38 -10
- package/src/discovery/cursor.ts +19 -6
- package/src/discovery/gemini.ts +62 -22
- package/src/discovery/github.ts +13 -3
- package/src/discovery/helpers.ts +483 -89
- package/src/discovery/index.ts +1 -0
- package/src/discovery/mcp-json.ts +36 -10
- package/src/discovery/opencode.ts +36 -19
- package/src/discovery/ssh.ts +11 -8
- package/src/discovery/windsurf.ts +12 -3
- package/src/exec/bash-executor.ts +10 -3
- package/src/exec/isolated-shell.ts +15 -2
- package/src/extensibility/extensions/loader.ts +88 -26
- package/src/extensibility/gjc-plugins/lifecycle.ts +189 -0
- package/src/extensibility/gjc-plugins/paths.ts +4 -1
- package/src/extensibility/gjc-plugins/types.ts +27 -0
- package/src/extensibility/plugins/manager.ts +200 -22
- package/src/extensibility/plugins/marketplace/cache.ts +55 -2
- package/src/extensibility/plugins/marketplace/manager.ts +1262 -162
- package/src/extensibility/plugins/marketplace/registry.ts +153 -0
- package/src/extensibility/plugins/marketplace/source-resolver.ts +116 -10
- package/src/extensibility/runtime-skill-discovery.ts +61 -9
- package/src/extensibility/skill-management.ts +7 -1
- package/src/gjc-runtime/autoresearch-runtime.ts +10 -1
- package/src/gjc-runtime/launch-worktree.test.ts +248 -0
- package/src/gjc-runtime/launch-worktree.ts +275 -38
- package/src/gjc-runtime/repository-binding.ts +26 -1
- package/src/gjc-runtime/session-layout.ts +10 -0
- package/src/gjc-runtime/session-state-lock.ts +330 -23
- package/src/gjc-runtime/session-state-sidecar.ts +328 -80
- package/src/gjc-runtime/ultragoal-runtime.ts +10 -1
- package/src/harness-control-plane/session-lease.ts +2 -2
- package/src/internal-urls/docs-index.generated.ts +28 -26
- package/src/main.ts +150 -29
- package/src/memories/index.ts +24 -2
- package/src/modes/acp/acp-agent.ts +522 -68
- package/src/modes/components/agent-dashboard.ts +3 -1
- package/src/modes/components/model-selector.ts +28 -19
- package/src/modes/components/plugin-settings.ts +2 -1
- package/src/modes/controllers/selector-controller.ts +15 -9
- package/src/modes/interactive-mode.ts +21 -3
- package/src/prompts/system/system-prompt.md +5 -1
- package/src/prompts/tools/browser.md +1 -1
- package/src/prompts/tools/move-session.md +4 -0
- package/src/prompts/tools/read.md +2 -1
- package/src/prompts/tools/skill-discovery.md +6 -5
- package/src/prompts/tools/task.md +1 -0
- package/src/runtime-mcp/config-writer.ts +228 -0
- package/src/runtime-mcp/oauth-discovery.ts +114 -14
- package/src/runtime-mcp/policy-json-edit.ts +129 -0
- package/src/runtime-mcp/startup-policy.ts +14 -0
- package/src/runtime-mcp/types.ts +2 -2
- package/src/sdk/acp/adapter.ts +27 -12
- package/src/sdk/broker/broker.ts +273 -44
- package/src/sdk/broker/daemon-entry.ts +119 -0
- package/src/sdk/broker/discovery.ts +173 -20
- package/src/sdk/broker/doctor-restart.ts +206 -0
- package/src/sdk/broker/endpoint-authority.ts +36 -0
- package/src/sdk/broker/ensure.ts +49 -14
- package/src/sdk/broker/lifecycle.ts +174 -42
- package/src/sdk/broker/process-incarnation.ts +86 -2
- package/src/sdk/broker/runtime.ts +145 -21
- package/src/sdk/broker/session-index.ts +136 -22
- package/src/sdk/broker/spawn-substrate.ts +4 -1
- package/src/sdk/broker/transport.ts +5 -0
- package/src/sdk/broker/worktree-occupancy.ts +21 -1
- package/src/sdk/bus/chat-daemon-cli.ts +63 -0
- package/src/sdk/bus/chat-daemon-control.ts +260 -64
- package/src/sdk/bus/chat-daemon-runtime.ts +141 -25
- package/src/sdk/bus/daemon-startup-exclusion.ts +119 -0
- package/src/sdk/bus/doctor-daemon-restart-client.ts +291 -0
- package/src/sdk/bus/doctor-daemon-restart.ts +92 -0
- package/src/sdk/bus/index.ts +252 -51
- package/src/sdk/bus/kind-aware-reconciliation.ts +76 -41
- package/src/sdk/bus/notification-service.ts +21 -3
- package/src/sdk/bus/reconciliation-store.ts +17 -11
- package/src/sdk/bus/telegram-daemon-cli.ts +91 -5
- package/src/sdk/bus/telegram-daemon-contract.ts +1 -1
- package/src/sdk/bus/telegram-daemon-control.ts +47 -0
- package/src/sdk/bus/telegram-daemon-owner-registry.ts +1 -1
- package/src/sdk/bus/telegram-daemon.ts +178 -13
- package/src/sdk/cli/session-cli.ts +100 -12
- package/src/sdk/host/host.ts +2 -7
- package/src/sdk/host/session-runtime.test.ts +329 -8
- package/src/sdk/host/session-runtime.ts +202 -50
- package/src/sdk/host/surface-policy.ts +8 -1
- package/src/sdk/lifecycle/service.ts +199 -22
- package/src/sdk/mcp/server.ts +1 -1
- package/src/sdk/prompt-deadline-lease.ts +12 -4
- package/src/sdk/prompt-deadline-manager.ts +5 -7
- package/src/sdk/prompt-failure.ts +148 -0
- package/src/sdk/prompt-status.ts +30 -1
- package/src/sdk/prompt-watchdog.ts +11 -3
- package/src/sdk/protocol/operation-inventory.generated.json +18 -0
- package/src/sdk/protocol/operation-registry.ts +20 -8
- package/src/sdk/router/session-router.ts +8 -3
- package/src/sdk/service-artifact-paths.ts +26 -0
- package/src/sdk/session-authority.ts +10 -0
- package/src/sdk/session.ts +361 -194
- package/src/session/agent-session.ts +398 -93
- package/src/session/blob-store.ts +9 -9
- package/src/session/fallback-chain-controller.ts +3 -7
- package/src/session/internal/managed-session-scope.ts +445 -29
- package/src/session/internal/managed-session-storage.ts +38 -6
- package/src/session/primary-control-surface.ts +12 -0
- package/src/session/session-manager.ts +11 -3
- package/src/session/session-storage.ts +1 -1
- package/src/session-import/codex.ts +6 -1
- package/src/session-import/redact.ts +7 -0
- package/src/skill-state/workflow-mutation-guard.ts +645 -39
- package/src/slash-commands/builtin-registry.ts +7 -3
- package/src/system-prompt.ts +8 -0
- package/src/task/executor.ts +36 -3
- package/src/task/types.ts +27 -5
- package/src/tools/ask.ts +114 -34
- package/src/tools/browser/actions.ts +2 -2
- package/src/tools/browser/tab-worker.ts +11 -8
- package/src/tools/descriptors.ts +6 -1
- package/src/tools/fetch.ts +7 -2
- package/src/tools/image-gen.ts +33 -34
- package/src/tools/read.ts +78 -32
- package/src/tools/skill-discovery.ts +17 -7
- package/src/tools/tool-catalog.generated.ts +8 -8
- package/src/tools/ultragoal-ask-guard.ts +16 -26
- package/src/utils/changelog.ts +7 -1
- package/src/utils/git.ts +3 -1
- package/src/utils/markit.ts +24 -2
- package/src/utils/mupdf-wasm.ts +26 -0
- package/src/web/search/providers/openai-compatible.ts +191 -14
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
import { afterEach, describe, expect, test } from "bun:test";
|
|
2
|
+
import * as fs from "node:fs";
|
|
3
|
+
import * as os from "node:os";
|
|
4
|
+
import * as path from "node:path";
|
|
5
|
+
import type { EvacuationHandle, GjcLaunchWorktreePlan } from "./launch-worktree";
|
|
6
|
+
import {
|
|
7
|
+
acquireTargetLock,
|
|
8
|
+
commitPreWorktreeRestore,
|
|
9
|
+
ensureLaunchWorktree,
|
|
10
|
+
evacuatePreWorktreeTarget,
|
|
11
|
+
isReplaceableWorktreeTarget,
|
|
12
|
+
restorePreWorktreeGjc,
|
|
13
|
+
} from "./launch-worktree";
|
|
14
|
+
|
|
15
|
+
const tempDirs: string[] = [];
|
|
16
|
+
|
|
17
|
+
function makeTempDir(prefix: string): string {
|
|
18
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), prefix));
|
|
19
|
+
tempDirs.push(dir);
|
|
20
|
+
return dir;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function git(cwd: string, args: string[]): void {
|
|
24
|
+
const result = Bun.spawnSync(["git", ...args], { cwd, stdout: "pipe", stderr: "pipe" });
|
|
25
|
+
if (result.exitCode !== 0) throw new Error(`git ${args.join(" ")} failed: ${result.stderr.toString().trim()}`);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function initRepo(prefix: string): string {
|
|
29
|
+
const repo = makeTempDir(prefix);
|
|
30
|
+
git(repo, ["init", "-q"]);
|
|
31
|
+
git(repo, ["config", "user.email", "test@example.com"]);
|
|
32
|
+
git(repo, ["config", "user.name", "Test"]);
|
|
33
|
+
fs.writeFileSync(path.join(repo, "README.md"), "seed");
|
|
34
|
+
git(repo, ["add", "."]);
|
|
35
|
+
git(repo, ["commit", "-q", "-m", "init"]);
|
|
36
|
+
return repo;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function countRegisteredWorktrees(repo: string): number {
|
|
40
|
+
const raw = Bun.spawnSync(["git", "worktree", "list", "--porcelain"], { cwd: repo }).stdout.toString();
|
|
41
|
+
return raw.split(/\r?\n/).filter(line => line.startsWith("worktree ")).length;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
afterEach(() => {
|
|
45
|
+
for (const dir of tempDirs.splice(0)) {
|
|
46
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
47
|
+
}
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
describe("isReplaceableWorktreeTarget", () => {
|
|
51
|
+
test("accepts a real directory holding only .gjc", () => {
|
|
52
|
+
const root = makeTempDir("gjc-replaceable-");
|
|
53
|
+
const target = path.join(root, "wt");
|
|
54
|
+
fs.mkdirSync(path.join(target, ".gjc"), { recursive: true });
|
|
55
|
+
expect(isReplaceableWorktreeTarget(target)).toBe(true);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test("rejects other content", () => {
|
|
59
|
+
const root = makeTempDir("gjc-replaceable-");
|
|
60
|
+
const target = path.join(root, "wt");
|
|
61
|
+
fs.mkdirSync(target, { recursive: true });
|
|
62
|
+
fs.writeFileSync(path.join(target, "file.txt"), "x");
|
|
63
|
+
expect(isReplaceableWorktreeTarget(target)).toBe(false);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test("rejects a symlinked target even when the resolved dir holds only .gjc", () => {
|
|
67
|
+
const root = makeTempDir("gjc-replaceable-");
|
|
68
|
+
const real = path.join(root, "real");
|
|
69
|
+
fs.mkdirSync(path.join(real, ".gjc"), { recursive: true });
|
|
70
|
+
const link = path.join(root, "link");
|
|
71
|
+
fs.symlinkSync(real, link, "dir");
|
|
72
|
+
expect(isReplaceableWorktreeTarget(link)).toBe(false);
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
describe("evacuatePreWorktreeTarget", () => {
|
|
77
|
+
test("rejects a symlinked .gjc before evacuation", () => {
|
|
78
|
+
const root = makeTempDir("gjc-evacuate-");
|
|
79
|
+
const target = path.join(root, "wt");
|
|
80
|
+
fs.mkdirSync(target, { recursive: true });
|
|
81
|
+
const outside = path.join(root, "outside");
|
|
82
|
+
fs.mkdirSync(outside, { recursive: true });
|
|
83
|
+
fs.symlinkSync(outside, path.join(target, ".gjc"), "dir");
|
|
84
|
+
|
|
85
|
+
expect(() => evacuatePreWorktreeTarget(target)).toThrow(/worktree_path_conflict/);
|
|
86
|
+
// The symlink target must be left untouched — evacuation must not follow it.
|
|
87
|
+
expect(fs.existsSync(outside)).toBe(true);
|
|
88
|
+
expect(fs.existsSync(path.join(root, ".gjc-pre-wt-wt"))).toBe(false);
|
|
89
|
+
});
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
describe("restorePreWorktreeGjc", () => {
|
|
93
|
+
test("rejects a symlinked overlay destination", () => {
|
|
94
|
+
const root = makeTempDir("gjc-restore-");
|
|
95
|
+
const worktree = path.join(root, "wt");
|
|
96
|
+
fs.mkdirSync(worktree, { recursive: true });
|
|
97
|
+
const outside = path.join(root, "outside");
|
|
98
|
+
fs.mkdirSync(outside, { recursive: true });
|
|
99
|
+
// A tracked `.gjc` symlink that `git worktree add` would have checked out.
|
|
100
|
+
fs.symlinkSync(outside, path.join(worktree, ".gjc"), "dir");
|
|
101
|
+
|
|
102
|
+
const stash = path.join(root, ".gjc-pre-wt-wt");
|
|
103
|
+
fs.mkdirSync(stash, { recursive: true });
|
|
104
|
+
fs.writeFileSync(path.join(stash, "seed.txt"), "seed");
|
|
105
|
+
|
|
106
|
+
expect(() => restorePreWorktreeGjc(stash, worktree)).toThrow(/worktree_path_conflict/);
|
|
107
|
+
// The overlay must not have leaked into the symlink target.
|
|
108
|
+
expect(fs.existsSync(path.join(outside, "seed.txt"))).toBe(false);
|
|
109
|
+
});
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
describe("commitPreWorktreeRestore", () => {
|
|
113
|
+
test("rolls back the created worktree, preserves the stash, and refuses to report success", () => {
|
|
114
|
+
const repo = initRepo("gjc-tx-repo-");
|
|
115
|
+
const bucket = makeTempDir("gjc-tx-bucket-");
|
|
116
|
+
const worktreePath = path.join(bucket, "wt");
|
|
117
|
+
git(repo, ["worktree", "add", "-q", "--detach", worktreePath, "HEAD"]);
|
|
118
|
+
expect(fs.existsSync(path.join(worktreePath, ".git"))).toBe(true);
|
|
119
|
+
expect(countRegisteredWorktrees(repo)).toBe(2);
|
|
120
|
+
|
|
121
|
+
// A stash that the (failing) restore left behind for recovery.
|
|
122
|
+
const stashPath = path.join(bucket, ".gjc-pre-wt-wt");
|
|
123
|
+
fs.mkdirSync(stashPath, { recursive: true });
|
|
124
|
+
fs.writeFileSync(path.join(stashPath, "launcher.txt"), "config");
|
|
125
|
+
|
|
126
|
+
const plan: GjcLaunchWorktreePlan = {
|
|
127
|
+
enabled: true,
|
|
128
|
+
repoRoot: repo,
|
|
129
|
+
worktreePath,
|
|
130
|
+
detached: true,
|
|
131
|
+
baseRef: "HEAD",
|
|
132
|
+
branchName: null,
|
|
133
|
+
};
|
|
134
|
+
const handle: EvacuationHandle = {
|
|
135
|
+
restore: () => {
|
|
136
|
+
throw new Error("overlay exploded");
|
|
137
|
+
},
|
|
138
|
+
stashPath,
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
let message = "";
|
|
142
|
+
try {
|
|
143
|
+
commitPreWorktreeRestore(handle, plan);
|
|
144
|
+
throw new Error("expected commitPreWorktreeRestore to throw");
|
|
145
|
+
} catch (error) {
|
|
146
|
+
message = error instanceof Error ? error.message : String(error);
|
|
147
|
+
}
|
|
148
|
+
expect(message).toMatch(/worktree_gjc_restore_failed/);
|
|
149
|
+
// Stash location is surfaced for manual recovery.
|
|
150
|
+
expect(message).toContain(stashPath);
|
|
151
|
+
|
|
152
|
+
// The worktree was rolled back: its directory is gone and Git no longer registers it.
|
|
153
|
+
expect(fs.existsSync(worktreePath)).toBe(false);
|
|
154
|
+
expect(countRegisteredWorktrees(repo)).toBe(1);
|
|
155
|
+
// The stash survives so the pre-seeded launcher config can be recovered.
|
|
156
|
+
expect(fs.existsSync(path.join(stashPath, "launcher.txt"))).toBe(true);
|
|
157
|
+
|
|
158
|
+
// A subsequent retry starts from a clean slate.
|
|
159
|
+
git(repo, ["worktree", "add", "-q", "--detach", worktreePath, "HEAD"]);
|
|
160
|
+
expect(fs.existsSync(path.join(worktreePath, ".git"))).toBe(true);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
test("removes the stash and reports success when restore completes", () => {
|
|
164
|
+
const repo = initRepo("gjc-tx-repo-");
|
|
165
|
+
const bucket = makeTempDir("gjc-tx-bucket-");
|
|
166
|
+
const worktreePath = path.join(bucket, "wt");
|
|
167
|
+
git(repo, ["worktree", "add", "-q", "--detach", worktreePath, "HEAD"]);
|
|
168
|
+
|
|
169
|
+
const stashPath = path.join(bucket, ".gjc-pre-wt-wt");
|
|
170
|
+
fs.mkdirSync(stashPath, { recursive: true });
|
|
171
|
+
fs.writeFileSync(path.join(stashPath, "launcher.txt"), "config");
|
|
172
|
+
|
|
173
|
+
const plan: GjcLaunchWorktreePlan = {
|
|
174
|
+
enabled: true,
|
|
175
|
+
repoRoot: repo,
|
|
176
|
+
worktreePath,
|
|
177
|
+
detached: true,
|
|
178
|
+
baseRef: "HEAD",
|
|
179
|
+
branchName: null,
|
|
180
|
+
};
|
|
181
|
+
const handle: EvacuationHandle = {
|
|
182
|
+
restore: () => restorePreWorktreeGjc(stashPath, worktreePath),
|
|
183
|
+
stashPath,
|
|
184
|
+
};
|
|
185
|
+
|
|
186
|
+
expect(() => commitPreWorktreeRestore(handle, plan)).not.toThrow();
|
|
187
|
+
// Overlay landed inside the worktree and the stash was consumed.
|
|
188
|
+
expect(fs.existsSync(path.join(worktreePath, ".gjc", "launcher.txt"))).toBe(true);
|
|
189
|
+
expect(fs.existsSync(stashPath)).toBe(false);
|
|
190
|
+
expect(countRegisteredWorktrees(repo)).toBe(2);
|
|
191
|
+
});
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
describe("acquireTargetLock", () => {
|
|
195
|
+
test("a second concurrent acquire fails while the first holds the lock", () => {
|
|
196
|
+
const bucket = makeTempDir("gjc-lock-");
|
|
197
|
+
const worktreePath = path.join(bucket, "wt");
|
|
198
|
+
fs.mkdirSync(path.join(worktreePath, ".gjc"), { recursive: true });
|
|
199
|
+
|
|
200
|
+
const release = acquireTargetLock(worktreePath);
|
|
201
|
+
expect(() => acquireTargetLock(worktreePath)).toThrow(/worktree_target_locked/);
|
|
202
|
+
// The contended target is left intact — the loser touched nothing.
|
|
203
|
+
expect(fs.readdirSync(worktreePath)).toEqual([".gjc"]);
|
|
204
|
+
|
|
205
|
+
release();
|
|
206
|
+
// After release the lockfile is gone and the lock is reusable.
|
|
207
|
+
expect(fs.existsSync(path.join(bucket, ".gjc-lock-wt"))).toBe(false);
|
|
208
|
+
acquireTargetLock(worktreePath)();
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
test("release is idempotent", () => {
|
|
212
|
+
const bucket = makeTempDir("gjc-lock-");
|
|
213
|
+
const worktreePath = path.join(bucket, "wt");
|
|
214
|
+
fs.mkdirSync(worktreePath, { recursive: true });
|
|
215
|
+
const release = acquireTargetLock(worktreePath);
|
|
216
|
+
release();
|
|
217
|
+
expect(() => release()).not.toThrow();
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
test("ensureLaunchWorktree refuses a target already locked by another launcher", () => {
|
|
221
|
+
const repo = initRepo("gjc-lock-repo-");
|
|
222
|
+
const bucket = makeTempDir("gjc-lock-bucket-");
|
|
223
|
+
const worktreePath = path.join(bucket, "wt");
|
|
224
|
+
const plan: GjcLaunchWorktreePlan = {
|
|
225
|
+
enabled: true,
|
|
226
|
+
repoRoot: repo,
|
|
227
|
+
worktreePath,
|
|
228
|
+
detached: true,
|
|
229
|
+
baseRef: "HEAD",
|
|
230
|
+
branchName: null,
|
|
231
|
+
};
|
|
232
|
+
|
|
233
|
+
const release = acquireTargetLock(worktreePath);
|
|
234
|
+
try {
|
|
235
|
+
expect(() => ensureLaunchWorktree(plan)).toThrow(/worktree_target_locked/);
|
|
236
|
+
// The losing launcher never created the worktree.
|
|
237
|
+
expect(fs.existsSync(worktreePath)).toBe(false);
|
|
238
|
+
expect(countRegisteredWorktrees(repo)).toBe(1);
|
|
239
|
+
} finally {
|
|
240
|
+
release();
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// Once the lock is released the same launch succeeds cleanly.
|
|
244
|
+
const result = ensureLaunchWorktree(plan);
|
|
245
|
+
expect(result.enabled).toBe(true);
|
|
246
|
+
expect(fs.existsSync(path.join(worktreePath, ".git"))).toBe(true);
|
|
247
|
+
});
|
|
248
|
+
});
|
|
@@ -510,6 +510,221 @@ export function planLaunchWorktree(
|
|
|
510
510
|
return { enabled: true, repoRoot, worktreePath, detached: mode.detached, baseRef, branchName };
|
|
511
511
|
}
|
|
512
512
|
|
|
513
|
+
/**
|
|
514
|
+
* Config subdirectory a launcher (e.g. paseo, #5570) may seed into the worktree target
|
|
515
|
+
* before handing the path to gjc. `git worktree add` refuses any non-empty target, so
|
|
516
|
+
* this artifact is evacuated and restored around the add rather than treated as a conflict.
|
|
517
|
+
*/
|
|
518
|
+
const PRE_WORKTREE_GJC_DIR = ".gjc";
|
|
519
|
+
|
|
520
|
+
/**
|
|
521
|
+
* Assert that `target` resolves to a location contained within `parent`.
|
|
522
|
+
*
|
|
523
|
+
* Both paths are resolved through {@link fs.realpathSync} so a symlink anywhere along the
|
|
524
|
+
* chain cannot smuggle a write outside the worktree bucket. Throws `worktree_path_conflict`
|
|
525
|
+
* when the resolved target escapes the resolved parent.
|
|
526
|
+
*/
|
|
527
|
+
function assertRealpathContained(target: string, parent: string): void {
|
|
528
|
+
const realParent = fs.realpathSync(parent);
|
|
529
|
+
const realTarget = fs.realpathSync(target);
|
|
530
|
+
if (realTarget === realParent || realTarget.startsWith(realParent + path.sep)) return;
|
|
531
|
+
throw new Error(`worktree_path_conflict:${target}`);
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
/**
|
|
535
|
+
* Whether an existing worktree target can be materialized in place. An empty directory,
|
|
536
|
+
* or one holding nothing but a pre-seeded {@link PRE_WORKTREE_GJC_DIR}, is safe to replace;
|
|
537
|
+
* anything else is a genuine `worktree_path_conflict`. A missing path is trivially usable.
|
|
538
|
+
*
|
|
539
|
+
* A symlinked target is never replaceable: evacuation and overlay writes would follow the
|
|
540
|
+
* link out of the worktree bucket, so it is reported as a conflict instead.
|
|
541
|
+
*/
|
|
542
|
+
export function isReplaceableWorktreeTarget(worktreePath: string): boolean {
|
|
543
|
+
let stat: fs.Stats;
|
|
544
|
+
try {
|
|
545
|
+
stat = fs.lstatSync(worktreePath);
|
|
546
|
+
} catch (error) {
|
|
547
|
+
if (fileSystemErrorCode(error) === "ENOENT") return true;
|
|
548
|
+
throw error;
|
|
549
|
+
}
|
|
550
|
+
if (stat.isSymbolicLink() || !stat.isDirectory()) return false;
|
|
551
|
+
const entries = fs.readdirSync(worktreePath);
|
|
552
|
+
return entries.every(entry => entry === PRE_WORKTREE_GJC_DIR);
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
/**
|
|
556
|
+
* Restore a stashed pre-seeded `.gjc/` into `worktreePath`.
|
|
557
|
+
*
|
|
558
|
+
* `git worktree add` materializes any tracked `.gjc/**` content (e.g. `.gjc/qa/**`) into the
|
|
559
|
+
* fresh worktree before this runs, so a bare rename would collide with EEXIST. When the target
|
|
560
|
+
* already exists, the stashed launcher config is overlaid onto it — stash entries win on name
|
|
561
|
+
* collision, tracked-only entries survive — and the stash is removed. When it does not exist
|
|
562
|
+
* (the untracked case, or a rolled-back add that never checked out), the stash is renamed in.
|
|
563
|
+
*/
|
|
564
|
+
export function restorePreWorktreeGjc(gjcStash: string, worktreePath: string): void {
|
|
565
|
+
fs.mkdirSync(worktreePath, { recursive: true });
|
|
566
|
+
const target = path.join(worktreePath, PRE_WORKTREE_GJC_DIR);
|
|
567
|
+
let targetStat: fs.Stats | null;
|
|
568
|
+
try {
|
|
569
|
+
targetStat = fs.lstatSync(target);
|
|
570
|
+
} catch (error) {
|
|
571
|
+
if (fileSystemErrorCode(error) === "ENOENT") targetStat = null;
|
|
572
|
+
else throw error;
|
|
573
|
+
}
|
|
574
|
+
if (targetStat) {
|
|
575
|
+
// `git worktree add` may have checked out a tracked `.gjc` symlink pointing outside the
|
|
576
|
+
// worktree; a recursive cpSync into it would redirect the overlay off-tree. Reject it and
|
|
577
|
+
// require the resolved destination to stay inside the worktree.
|
|
578
|
+
if (targetStat.isSymbolicLink()) throw new Error(`worktree_path_conflict:${target}`);
|
|
579
|
+
assertRealpathContained(target, worktreePath);
|
|
580
|
+
fs.cpSync(gjcStash, target, { recursive: true, force: true });
|
|
581
|
+
fs.rmSync(gjcStash, { recursive: true, force: true });
|
|
582
|
+
return;
|
|
583
|
+
}
|
|
584
|
+
fs.renameSync(gjcStash, target);
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
/**
|
|
588
|
+
* Handle returned by {@link evacuatePreWorktreeTarget}: a hook that restores the pre-seeded
|
|
589
|
+
* `.gjc/` into the freshly created worktree, plus the sibling stash path it was moved to
|
|
590
|
+
* (`null` when there was nothing to preserve). The stash path lets a failed restore report
|
|
591
|
+
* where the pre-seeded config can be recovered by hand.
|
|
592
|
+
*/
|
|
593
|
+
export interface EvacuationHandle {
|
|
594
|
+
restore: () => void;
|
|
595
|
+
stashPath: string | null;
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
/**
|
|
599
|
+
* Clear a {@link isReplaceableWorktreeTarget replaceable} target so `git worktree add` can
|
|
600
|
+
* create it, moving any pre-seeded `.gjc/` to a sibling stash. Returns a hook that restores
|
|
601
|
+
* `.gjc/` into the freshly created worktree; a no-op when there was nothing to preserve.
|
|
602
|
+
*/
|
|
603
|
+
export function evacuatePreWorktreeTarget(worktreePath: string): EvacuationHandle {
|
|
604
|
+
if (!fs.existsSync(worktreePath)) return { restore: () => {}, stashPath: null };
|
|
605
|
+
const gjcSource = path.join(worktreePath, PRE_WORKTREE_GJC_DIR);
|
|
606
|
+
let gjcStat: fs.Stats | null;
|
|
607
|
+
try {
|
|
608
|
+
gjcStat = fs.lstatSync(gjcSource);
|
|
609
|
+
} catch (error) {
|
|
610
|
+
if (fileSystemErrorCode(error) === "ENOENT") gjcStat = null;
|
|
611
|
+
else throw error;
|
|
612
|
+
}
|
|
613
|
+
if (!gjcStat) {
|
|
614
|
+
fs.rmdirSync(worktreePath);
|
|
615
|
+
return { restore: () => {}, stashPath: null };
|
|
616
|
+
}
|
|
617
|
+
// A symlinked `.gjc` would let the rename below (and the later overlay) escape the worktree
|
|
618
|
+
// bucket. Refuse it as a path conflict rather than following the link off-tree.
|
|
619
|
+
if (gjcStat.isSymbolicLink()) throw new Error(`worktree_path_conflict:${gjcSource}`);
|
|
620
|
+
assertRealpathContained(gjcSource, worktreePath);
|
|
621
|
+
const gjcStash = path.join(path.dirname(worktreePath), `.gjc-pre-wt-${path.basename(worktreePath)}`);
|
|
622
|
+
// A lingering stash means a prior evacuation was interrupted before its restore ran, or the
|
|
623
|
+
// path holds unrelated user data. Either way it is not ours to destroy: fail closed so the
|
|
624
|
+
// operator can inspect and remove it, rather than overwriting it before the worktree add.
|
|
625
|
+
if (fs.existsSync(gjcStash)) {
|
|
626
|
+
throw new Error(`stash path already occupied: ${gjcStash} — remove it manually before retrying`);
|
|
627
|
+
}
|
|
628
|
+
fs.renameSync(gjcSource, gjcStash);
|
|
629
|
+
fs.rmdirSync(worktreePath);
|
|
630
|
+
return { restore: () => restorePreWorktreeGjc(gjcStash, worktreePath), stashPath: gjcStash };
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
/** Best-effort restore that never masks the failure that triggered it. */
|
|
634
|
+
function tryRestore(restore: () => void): void {
|
|
635
|
+
try {
|
|
636
|
+
restore();
|
|
637
|
+
} catch {
|
|
638
|
+
// The original worktree-add failure is the actionable one; leave restore best-effort.
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
/** Best-effort removal of a worktree GJC just created, used when a later step must roll it back. */
|
|
643
|
+
function rollbackCreatedWorktree(plan: GjcLaunchWorktreePlan): void {
|
|
644
|
+
try {
|
|
645
|
+
runGit(plan.repoRoot, ["worktree", "remove", "--force", plan.worktreePath]);
|
|
646
|
+
} catch {
|
|
647
|
+
try {
|
|
648
|
+
runGit(plan.repoRoot, ["worktree", "prune"]);
|
|
649
|
+
} catch {
|
|
650
|
+
// Best-effort rollback; the restore failure below is the actionable error.
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
/**
|
|
656
|
+
* Finalize the pre-seeded `.gjc/` overlay after a successful `git worktree add`, transactionally.
|
|
657
|
+
*
|
|
658
|
+
* `git worktree add` has already registered and materialized the worktree by the time this runs,
|
|
659
|
+
* so a restore/overlay failure cannot simply propagate: the half-prepared worktree would be
|
|
660
|
+
* reused verbatim on the next launch (never restored, stash stranded). On failure this rolls the
|
|
661
|
+
* newly created worktree back so a retry starts clean, preserves the stash for manual recovery,
|
|
662
|
+
* and throws — success is never reported when restoration did not complete.
|
|
663
|
+
*/
|
|
664
|
+
export function commitPreWorktreeRestore(handle: EvacuationHandle, plan: GjcLaunchWorktreePlan): void {
|
|
665
|
+
try {
|
|
666
|
+
handle.restore();
|
|
667
|
+
} catch (error) {
|
|
668
|
+
rollbackCreatedWorktree(plan);
|
|
669
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
670
|
+
const recovery = handle.stashPath
|
|
671
|
+
? `The stashed launcher .gjc was preserved at ${handle.stashPath} for manual recovery.`
|
|
672
|
+
: "No launcher .gjc stash was created.";
|
|
673
|
+
throw new Error(
|
|
674
|
+
[
|
|
675
|
+
`worktree_gjc_restore_failed:${plan.worktreePath}`,
|
|
676
|
+
`GJC created the worktree but could not restore the pre-seeded .gjc overlay: ${detail}`,
|
|
677
|
+
recovery,
|
|
678
|
+
"The newly created worktree was rolled back; resolve the obstruction and relaunch.",
|
|
679
|
+
].join("\n"),
|
|
680
|
+
);
|
|
681
|
+
}
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
/**
|
|
685
|
+
* Acquire a per-target filesystem lock so the replaceability check, evacuation, `git worktree
|
|
686
|
+
* add`, and restore run as one critical section. Without it a second launcher racing on the same
|
|
687
|
+
* target could add files between the scan and the evacuation, or move `.gjc` first and strand the
|
|
688
|
+
* partially modified target. The lock is an atomic `O_EXCL` lockfile beside the target (inside the
|
|
689
|
+
* git-ignored bucket); a concurrent holder fails fast with `worktree_target_locked`.
|
|
690
|
+
*/
|
|
691
|
+
export function acquireTargetLock(worktreePath: string): () => void {
|
|
692
|
+
const lockPath = path.join(path.dirname(worktreePath), `.gjc-lock-${path.basename(worktreePath)}`);
|
|
693
|
+
try {
|
|
694
|
+
fs.closeSync(fs.openSync(lockPath, "wx"));
|
|
695
|
+
} catch (error) {
|
|
696
|
+
if (fileSystemErrorCode(error) === "EEXIST") {
|
|
697
|
+
throw new Error(
|
|
698
|
+
`worktree_target_locked:${worktreePath} — another launch is preparing this worktree (lock: ${lockPath})`,
|
|
699
|
+
);
|
|
700
|
+
}
|
|
701
|
+
throw error;
|
|
702
|
+
}
|
|
703
|
+
let released = false;
|
|
704
|
+
return () => {
|
|
705
|
+
if (released) return;
|
|
706
|
+
released = true;
|
|
707
|
+
try {
|
|
708
|
+
fs.rmSync(lockPath, { force: true });
|
|
709
|
+
} catch {
|
|
710
|
+
// Best-effort release; a stranded lockfile is surfaced as worktree_target_locked next time.
|
|
711
|
+
}
|
|
712
|
+
};
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
function buildWorktreeAddArgs(plan: GjcLaunchWorktreePlan, branchAlreadyExisted: boolean): string[] {
|
|
716
|
+
const args = ["worktree", "add"];
|
|
717
|
+
if (plan.detached) args.push("--detach", plan.worktreePath, plan.baseRef);
|
|
718
|
+
else if (branchAlreadyExisted) args.push(plan.worktreePath, plan.branchName ?? "");
|
|
719
|
+
else args.push("-b", plan.branchName ?? "", plan.worktreePath, plan.baseRef);
|
|
720
|
+
return args;
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
function classifyWorktreeAddFailure(plan: GjcLaunchWorktreePlan, args: string[], stderr: string): Error {
|
|
724
|
+
if (plan.branchName && BRANCH_IN_USE_PATTERN.test(stderr)) return new Error(`branch_in_use:${plan.branchName}`);
|
|
725
|
+
return new Error(stderr || `worktree_add_failed:${args.join(" ")}`);
|
|
726
|
+
}
|
|
727
|
+
|
|
513
728
|
export function ensureLaunchWorktree(
|
|
514
729
|
plan: GjcLaunchWorktreePlan | { enabled: false },
|
|
515
730
|
options?: LaunchWorktreeAbortOptions,
|
|
@@ -573,32 +788,43 @@ function ensureLaunchWorktreeSync(
|
|
|
573
788
|
};
|
|
574
789
|
}
|
|
575
790
|
|
|
576
|
-
if (
|
|
791
|
+
if (!isReplaceableWorktreeTarget(plan.worktreePath)) throw new Error(`worktree_path_conflict:${plan.worktreePath}`);
|
|
577
792
|
if (plan.branchName && hasBranchInUse(allWorktrees, plan.branchName, plan.worktreePath)) {
|
|
578
793
|
throw new Error(`branch_in_use:${plan.branchName}`);
|
|
579
794
|
}
|
|
580
795
|
|
|
581
796
|
ensureBucketDirUsable(path.dirname(plan.worktreePath));
|
|
582
|
-
const
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
797
|
+
const releaseLock = acquireTargetLock(plan.worktreePath);
|
|
798
|
+
try {
|
|
799
|
+
// Re-check under the lock: a concurrent launcher may have populated the target between the
|
|
800
|
+
// scan above and now. From here the check, evacuation, add, and restore are serialized.
|
|
801
|
+
if (!isReplaceableWorktreeTarget(plan.worktreePath)) {
|
|
802
|
+
throw new Error(`worktree_path_conflict:${plan.worktreePath}`);
|
|
803
|
+
}
|
|
804
|
+
const branchAlreadyExisted = plan.branchName ? branchExists(plan.repoRoot, plan.branchName) : false;
|
|
805
|
+
const args = buildWorktreeAddArgs(plan, branchAlreadyExisted);
|
|
806
|
+
const evacuation = evacuatePreWorktreeTarget(plan.worktreePath);
|
|
807
|
+
try {
|
|
808
|
+
const result = Bun.spawnSync(["git", ...args], { cwd: plan.repoRoot, stdout: "pipe", stderr: "pipe" });
|
|
809
|
+
if (result.exitCode !== 0) {
|
|
810
|
+
throw classifyWorktreeAddFailure(plan, args, sanitizeWorktreeDiagnostic(result.stderr.toString().trim()));
|
|
811
|
+
}
|
|
812
|
+
} catch (error) {
|
|
813
|
+
tryRestore(evacuation.restore);
|
|
814
|
+
throw error;
|
|
815
|
+
}
|
|
816
|
+
commitPreWorktreeRestore(evacuation, plan);
|
|
587
817
|
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
818
|
+
return {
|
|
819
|
+
...plan,
|
|
820
|
+
worktreePath: path.resolve(plan.worktreePath),
|
|
821
|
+
created: true,
|
|
822
|
+
reused: false,
|
|
823
|
+
createdBranch: Boolean(plan.branchName && !branchAlreadyExisted),
|
|
824
|
+
};
|
|
825
|
+
} finally {
|
|
826
|
+
releaseLock();
|
|
593
827
|
}
|
|
594
|
-
|
|
595
|
-
return {
|
|
596
|
-
...plan,
|
|
597
|
-
worktreePath: path.resolve(plan.worktreePath),
|
|
598
|
-
created: true,
|
|
599
|
-
reused: false,
|
|
600
|
-
createdBranch: Boolean(plan.branchName && !branchAlreadyExisted),
|
|
601
|
-
};
|
|
602
828
|
}
|
|
603
829
|
|
|
604
830
|
export async function ensureLaunchWorktreeCancellable(
|
|
@@ -658,33 +884,44 @@ export async function ensureLaunchWorktreeCancellable(
|
|
|
658
884
|
};
|
|
659
885
|
}
|
|
660
886
|
|
|
661
|
-
if (
|
|
887
|
+
if (!isReplaceableWorktreeTarget(plan.worktreePath)) throw new Error(`worktree_path_conflict:${plan.worktreePath}`);
|
|
662
888
|
if (plan.branchName && hasBranchInUse(allWorktrees, plan.branchName, plan.worktreePath)) {
|
|
663
889
|
throw new Error(`branch_in_use:${plan.branchName}`);
|
|
664
890
|
}
|
|
665
891
|
|
|
666
892
|
ensureBucketDirUsable(path.dirname(plan.worktreePath));
|
|
667
893
|
throwIfAborted(options, timeout);
|
|
668
|
-
const
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
894
|
+
const releaseLock = acquireTargetLock(plan.worktreePath);
|
|
895
|
+
try {
|
|
896
|
+
// Re-check under the lock: a concurrent launcher may have populated the target between the
|
|
897
|
+
// scan above and now. From here the check, evacuation, add, and restore are serialized.
|
|
898
|
+
if (!isReplaceableWorktreeTarget(plan.worktreePath)) {
|
|
899
|
+
throw new Error(`worktree_path_conflict:${plan.worktreePath}`);
|
|
900
|
+
}
|
|
901
|
+
const branchAlreadyExisted = plan.branchName ? branchExists(plan.repoRoot, plan.branchName) : false;
|
|
902
|
+
const args = buildWorktreeAddArgs(plan, branchAlreadyExisted);
|
|
903
|
+
const evacuation = evacuatePreWorktreeTarget(plan.worktreePath);
|
|
904
|
+
try {
|
|
905
|
+
const result = await spawnProcessGroup(["git", ...args], plan.repoRoot, options, timeout);
|
|
906
|
+
if (result.exitCode !== 0) {
|
|
907
|
+
throw classifyWorktreeAddFailure(plan, args, sanitizeWorktreeDiagnostic(result.stderr.trim()));
|
|
908
|
+
}
|
|
909
|
+
} catch (error) {
|
|
910
|
+
tryRestore(evacuation.restore);
|
|
911
|
+
throw error;
|
|
912
|
+
}
|
|
913
|
+
commitPreWorktreeRestore(evacuation, plan);
|
|
673
914
|
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
915
|
+
return {
|
|
916
|
+
...plan,
|
|
917
|
+
worktreePath: path.resolve(plan.worktreePath),
|
|
918
|
+
created: true,
|
|
919
|
+
reused: false,
|
|
920
|
+
createdBranch: Boolean(plan.branchName && !branchAlreadyExisted),
|
|
921
|
+
};
|
|
922
|
+
} finally {
|
|
923
|
+
releaseLock();
|
|
679
924
|
}
|
|
680
|
-
|
|
681
|
-
return {
|
|
682
|
-
...plan,
|
|
683
|
-
worktreePath: path.resolve(plan.worktreePath),
|
|
684
|
-
created: true,
|
|
685
|
-
reused: false,
|
|
686
|
-
createdBranch: Boolean(plan.branchName && !branchAlreadyExisted),
|
|
687
|
-
};
|
|
688
925
|
}
|
|
689
926
|
|
|
690
927
|
interface WorkspacePackageManifest {
|
|
@@ -177,7 +177,7 @@ export async function assertCwdMatchesRepositoryBinding(
|
|
|
177
177
|
if (!repositoryBindingsMatch(active, binding)) {
|
|
178
178
|
throw new RepositoryBindingError(
|
|
179
179
|
"identity_mismatch",
|
|
180
|
-
`Active worktree does not match plan/task repository binding. active=${active.worktreeRoot} (commonDir=${active.commonDir ?? "none"}) bound=${binding.worktreeRoot} (commonDir=${binding.commonDir ?? "none"}).`,
|
|
180
|
+
`Active worktree does not match plan/task repository binding. A task cannot authorize a foreign repository by changing tasks[].repositoryBinding; start a new session in the approved worktree (for example, gjc --cwd <approved-worktree>) to establish task authority. active=${active.worktreeRoot} (commonDir=${active.commonDir ?? "none"}) bound=${binding.worktreeRoot} (commonDir=${binding.commonDir ?? "none"}).`,
|
|
181
181
|
);
|
|
182
182
|
}
|
|
183
183
|
return active;
|
|
@@ -233,6 +233,31 @@ export async function resolveTaskRepositoryBinding(
|
|
|
233
233
|
}
|
|
234
234
|
const taskBinding = parseRepositoryBinding(declared);
|
|
235
235
|
await assertCwdMatchesRepositoryBinding(sessionCwd, taskBinding);
|
|
236
|
+
const declaredRoot = await captureRepositoryBinding(taskBinding.worktreeRoot);
|
|
237
|
+
if (path.resolve(declaredRoot.worktreeRoot) !== path.resolve(taskBinding.worktreeRoot)) {
|
|
238
|
+
throw new RepositoryBindingError(
|
|
239
|
+
"identity_mismatch",
|
|
240
|
+
`Declared repository binding worktreeRoot is stale or forged: live=${declaredRoot.worktreeRoot} bound=${taskBinding.worktreeRoot}.`,
|
|
241
|
+
);
|
|
242
|
+
}
|
|
243
|
+
if (declaredRoot.commonDir !== taskBinding.commonDir) {
|
|
244
|
+
throw new RepositoryBindingError(
|
|
245
|
+
"identity_mismatch",
|
|
246
|
+
`Declared repository binding commonDir does not match the target worktree: live=${declaredRoot.commonDir ?? "none"} bound=${taskBinding.commonDir ?? "none"}.`,
|
|
247
|
+
);
|
|
248
|
+
}
|
|
249
|
+
if (taskBinding.head !== undefined && taskBinding.head !== declaredRoot.head) {
|
|
250
|
+
throw new RepositoryBindingError(
|
|
251
|
+
"identity_mismatch",
|
|
252
|
+
`Declared repository binding HEAD does not match the target worktree: live=${declaredRoot.head ?? "none"} bound=${taskBinding.head}.`,
|
|
253
|
+
);
|
|
254
|
+
}
|
|
255
|
+
if (taskBinding.branch !== undefined && taskBinding.branch !== declaredRoot.branch) {
|
|
256
|
+
throw new RepositoryBindingError(
|
|
257
|
+
"identity_mismatch",
|
|
258
|
+
`Declared repository binding branch does not match the target worktree: live=${declaredRoot.branch ?? "none"} bound=${taskBinding.branch}.`,
|
|
259
|
+
);
|
|
260
|
+
}
|
|
236
261
|
if (taskBinding.relativeSubdir) {
|
|
237
262
|
assertPathUnderRepositoryBinding(taskBinding, ".");
|
|
238
263
|
}
|
|
@@ -125,6 +125,16 @@ export function sessionLogsDir(cwd: string, gjcSessionId: string): string {
|
|
|
125
125
|
export function sessionRuntimeDir(cwd: string, gjcSessionId: string): string {
|
|
126
126
|
return path.join(sessionRoot(cwd, gjcSessionId), "runtime");
|
|
127
127
|
}
|
|
128
|
+
/**
|
|
129
|
+
* The session-derived runtime-state marker owned by `gjcSessionId` — the marker a session
|
|
130
|
+
* writes when the process carries no coordinator pin for it, and the one a committed cwd move
|
|
131
|
+
* relocates. The coordinator pin is a separate path resolved from
|
|
132
|
+
* `GJC_COORDINATOR_SESSION_STATE_FILE`, so only the sidecar may decide which of the two a
|
|
133
|
+
* session owns; callers use this for the derived half of that decision.
|
|
134
|
+
*/
|
|
135
|
+
export function sessionRuntimeStatePath(cwd: string, gjcSessionId: string): string {
|
|
136
|
+
return path.join(sessionRuntimeDir(cwd, gjcSessionId), "runtime-state.json");
|
|
137
|
+
}
|
|
128
138
|
export function sessionIpykernelsDir(cwd: string, gjcSessionId: string): string {
|
|
129
139
|
return path.join(sessionRoot(cwd, gjcSessionId), "ipykernels");
|
|
130
140
|
}
|