@afokapu/atdd-bun 0.10.99 → 0.10.101
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/integrity.json +3 -3
- package/package.json +1 -1
- package/src/cli.ts +5 -2
- package/src/hooks.ts +184 -18
package/integrity.json
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
{
|
|
2
|
-
"version": "0.10.
|
|
2
|
+
"version": "0.10.101",
|
|
3
3
|
"files": {
|
|
4
4
|
"HOOK_AUDIT.md": "5329d840db37671b1918f688ead26865473b87db73dbc75f7c8b2a8bbe8d6d43",
|
|
5
5
|
"PLANNER_PORT.md": "fb5935bac8b7ac18994de21e43ace3a5ef8cd55f85b0e3349fca261280054f11",
|
|
@@ -1258,10 +1258,10 @@
|
|
|
1258
1258
|
"relationships.yaml": "fccaf745b383fe527b7db9022233037bdf5b5cc3d495bb40da3cca4746f5e241",
|
|
1259
1259
|
"src/agent.ts": "6d6c6e75908af8fbab13432f0001b0374bb25cf3dee46ba218dfd67e0090cb56",
|
|
1260
1260
|
"src/ci.ts": "b5c57dd87fc780b2b272156b68830d14e695a1a28300dec46a06f71864e0c59e",
|
|
1261
|
-
"src/cli.ts": "
|
|
1261
|
+
"src/cli.ts": "395638ab87565bb10c2beb35988ff1acf686924d13515795156a2e8893b3da88",
|
|
1262
1262
|
"src/docs-capability.ts": "bdeeb414649049b1cd4fa8008cada3a576318be51c43bec91bfa46a8e250c270",
|
|
1263
1263
|
"src/enforce.ts": "e0c10519aeecd7080f2900ea230fad2f09482af47c1cee885d5de71573e29732",
|
|
1264
|
-
"src/hooks.ts": "
|
|
1264
|
+
"src/hooks.ts": "556a1c58699653c6c435b2cea705d74670afdc9ea0c99ee84c9cdd1342c9fef8",
|
|
1265
1265
|
"src/index.ts": "4081f8b08d1d3ee9e15a9964143e53d9cfb94a5616ab3e58a180d24cc16dc381",
|
|
1266
1266
|
"src/integrity.ts": "f1ede7cb04924ae88e11917047277e264af6596b9759e381bd9c497cd34a797d",
|
|
1267
1267
|
"src/journey-docs.ts": "994d229376244c84212cff951032823068a76565dd2fd427b9d44e341bd14853",
|
package/package.json
CHANGED
package/src/cli.ts
CHANGED
|
@@ -22,7 +22,7 @@ const usage = {
|
|
|
22
22
|
"atdd-bun [profile ...] [--root <path>] [--ratchet --base <full-sha> [--ratchet-activate-profiles]]",
|
|
23
23
|
"atdd-bun init [--replace]",
|
|
24
24
|
"atdd-bun hooks <install|uninstall|status> [--replace]",
|
|
25
|
-
"atdd-bun worktree <start|finish|status>",
|
|
25
|
+
"atdd-bun worktree <start|finish|status> [branch] [--vcs <git|jj>]",
|
|
26
26
|
"atdd-bun ci <init|status> [--replace]",
|
|
27
27
|
"atdd-bun agent <init|status> [--replace]",
|
|
28
28
|
"atdd-bun profiles registry [--check]",
|
|
@@ -86,7 +86,10 @@ if (args[0] === "hook") {
|
|
|
86
86
|
if (result.message) console[result.ok ? "log" : "error"](result.message); process.exit(result.ok ? 0 : 1);
|
|
87
87
|
}
|
|
88
88
|
if (args[0] === "worktree") {
|
|
89
|
-
const
|
|
89
|
+
const vcsAt = args.indexOf("--vcs"), vcsValue = vcsAt === -1 ? "git" : args[vcsAt + 1];
|
|
90
|
+
if (vcsValue !== "git" && vcsValue !== "jj") fail("--vcs requires git or jj");
|
|
91
|
+
const branch = args[1] === "start" ? args.slice(2).find((arg, index, rest) => !arg.startsWith("-") && rest[index - 1] !== "--vcs") ?? "" : "";
|
|
92
|
+
const result = args[1] === "start" ? await startWorktree(process.cwd(), branch, vcsValue) : args[1] === "finish" ? await finishWorktree(process.cwd(), args.includes("--delete-branch"), vcsValue) : args[1] === "status" ? await worktreeStatus(process.cwd(), vcsValue) : fail("worktree requires start, finish, or status");
|
|
90
93
|
console[result.ok ? "log" : "error"](result.message); process.exit(result.ok ? 0 : 1);
|
|
91
94
|
}
|
|
92
95
|
if (args[0] === "ci") {
|
package/src/hooks.ts
CHANGED
|
@@ -61,44 +61,49 @@ async function ratchetCommitGate(root: string, staged: string[], registryChanged
|
|
|
61
61
|
try { candidateConfig = Bun.YAML.parse(stagedConfig.out) as Record<string, unknown>; policy = parseRatchetPolicy(candidateConfig); }
|
|
62
62
|
catch (error) { return bad(`ratchet activation commit gate configuration is invalid: ${String(error)}`); }
|
|
63
63
|
if (policy?.mode !== "reject-new" || !Array.isArray(candidateConfig.profiles) || !candidateConfig.profiles.every(profile => typeof profile === "string")) return null;
|
|
64
|
-
if ((await git(root, ["diff", "--quiet"])).code !== 0 || (await git(root, ["ls-files", "--others", "--exclude-standard"])).out) return bad("ratchet activation commit gate requires no unstaged or untracked candidate state");
|
|
65
64
|
const head = await git(root, ["rev-parse", "--verify", "HEAD^{commit}"]);
|
|
66
65
|
if (head.code || !/^[0-9a-f]{40}$/i.test(head.out)) return bad("ratchet activation commit gate requires an exact committed HEAD base");
|
|
67
|
-
|
|
66
|
+
let scratch: string | undefined;
|
|
68
67
|
try {
|
|
68
|
+
scratch = await mkdtemp(join(tmpdir(), "atdd-ratchet-commit-gate-"));
|
|
69
69
|
const added = await git(root, ["worktree", "add", "--detach", "--no-checkout", scratch, head.out]);
|
|
70
|
-
if (added.code)
|
|
70
|
+
if (added.code) throw new Error(`could not materialize ratchet activation base: ${added.err || added.out}`);
|
|
71
71
|
const checkedOut = await git(scratch, ["checkout", "--detach", head.out]);
|
|
72
|
-
if (checkedOut.code)
|
|
72
|
+
if (checkedOut.code) throw new Error(`could not checkout ratchet activation base: ${checkedOut.err || checkedOut.out}`);
|
|
73
73
|
const baseConfig = await configAt(scratch), candidateProfiles = stable(candidateConfig.profiles.map(normalizeProfileName));
|
|
74
74
|
const baseProfiles = stable(await enabledProfiles(scratch));
|
|
75
75
|
const policyMatches = stable(policy.profiles.map(normalizeProfileName)).join(",") === candidateProfiles.join(",");
|
|
76
76
|
const activation = isStrictProfileExpansion(baseProfiles, candidateProfiles) && activationContextDigest(baseConfig) === activationContextDigest(candidateConfig);
|
|
77
77
|
const unchanged = baseProfiles.join(",") === candidateProfiles.join(",") && contextDigest(baseConfig, baseProfiles) === contextDigest(candidateConfig, candidateProfiles);
|
|
78
78
|
if (candidateProfiles.some(profile => !concreteProfiles.includes(profile as typeof concreteProfiles[number])) || !policyMatches || (!activation && !unchanged)) return bad("ratchet reject-new requires either unchanged explicit profiles/context or an explicit monotonic profile expansion with identical non-profile context");
|
|
79
|
-
const selectedProfiles = activation || staged.some(needsFullCommitRatchetProfiles) ? candidateProfiles : (await affectedProfiles(root, staged, registryChanged)).filter(profile => candidateProfiles.includes(profile));
|
|
80
79
|
const tree = await git(root, ["write-tree"]);
|
|
81
80
|
if (tree.code || !/^[0-9a-f]{40}$/i.test(tree.out)) return bad(`could not identify staged ratchet activation candidate: ${tree.err || tree.out}`);
|
|
82
81
|
const baseTree = await git(root, ["rev-parse", "--verify", `${head.out}^{tree}`]);
|
|
83
82
|
if (baseTree.code || !/^[0-9a-f]{40}$/i.test(baseTree.out)) return bad(`could not identify ratchet activation base tree: ${baseTree.err || baseTree.out}`);
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
if (applied.code) return bad(`could not materialize staged ratchet activation candidate: ${applied.err || applied.out}`);
|
|
83
|
+
// Scan only exact Git trees in private scratch: live unstaged/untracked bytes never enter, and cache keys stay tree IDs.
|
|
84
|
+
const materialize = async (treeId: string, label: string) => {
|
|
85
|
+
const reset = await git(scratch!, ["read-tree", "--reset", "-u", treeId]);
|
|
86
|
+
if (reset.code) throw new Error(`could not materialize ${label}: ${reset.err || reset.out}`);
|
|
87
|
+
const exact = await git(scratch!, ["write-tree"]);
|
|
88
|
+
if (exact.code || exact.out !== treeId) throw new Error(`${label} did not materialize exactly: ${exact.err || exact.out}`);
|
|
89
|
+
};
|
|
90
|
+
await materialize(tree.out, "staged ratchet activation candidate");
|
|
93
91
|
const materialized = await configAt(scratch), materializedProfiles = stable(await enabledProfiles(scratch));
|
|
94
92
|
const materializedContext = activation ? activationContextDigest(materialized) === activationContextDigest(candidateConfig) : materializedProfiles.join(",") === candidateProfiles.join(",") && contextDigest(materialized, materializedProfiles) === contextDigest(candidateConfig, candidateProfiles);
|
|
95
93
|
if (!materializedContext) return bad("staged ratchet candidate did not materialize exactly");
|
|
94
|
+
const selectedProfiles = activation || staged.some(needsFullCommitRatchetProfiles) ? candidateProfiles : (await affectedProfiles(scratch, staged, registryChanged)).filter(profile => candidateProfiles.includes(profile));
|
|
95
|
+
const normalize = (findings: Awaited<ReturnType<typeof enforce>>) => findings.map(finding => ({ ...finding, file: canonicalFindingPath(scratch!, finding.file) }));
|
|
96
|
+
const cacheDirectory = await findingsCacheDirectory(root), toolkit = await toolkitIdentity().catch(() => null);
|
|
97
|
+
const findingsAt = async (treeId: string) => cachedFindings(cacheDirectory, treeId, toolkit, selectedProfiles, async () => normalize(await enforce({ root: scratch, profiles: selectedProfiles as any })));
|
|
98
|
+
await materialize(baseTree.out, "ratchet activation base tree");
|
|
99
|
+
const baseFindings = selectedProfiles.length ? await findingsAt(baseTree.out) : [];
|
|
100
|
+
await materialize(tree.out, "staged ratchet activation candidate");
|
|
96
101
|
const candidateFindings = selectedProfiles.length ? await findingsAt(tree.out) : [];
|
|
97
102
|
const delta = compareFindings(baseFindings, candidateFindings);
|
|
98
103
|
const report = JSON.stringify({ schema: "atdd-bun.ratchet-report/v1", mode: policy.mode, base: head.out, candidate: tree.out, profiles: selectedProfiles, context: contextDigest(candidateConfig, candidateProfiles), ...delta });
|
|
99
104
|
return { ok: delta.new.length === 0, message: report };
|
|
100
105
|
} catch (error) { return bad(`ratchet activation commit gate failed closed: ${String(error)}`); }
|
|
101
|
-
finally { await git(root, ["worktree", "remove", "--force", scratch]); await rm(scratch, { recursive: true, force: true }); }
|
|
106
|
+
finally { if (scratch) { await git(root, ["worktree", "remove", "--force", scratch]); await rm(scratch, { recursive: true, force: true }); } }
|
|
102
107
|
}
|
|
103
108
|
|
|
104
109
|
/** A pushed reject-new candidate is judged from its immutable object IDs, never the hook's checkout or index. */
|
|
@@ -376,10 +381,171 @@ export async function hooksStatus(root = process.cwd()) {
|
|
|
376
381
|
}
|
|
377
382
|
|
|
378
383
|
const branchPath = (branch: string) => branch.replace(/[^A-Za-z0-9._-]+/g, "-").replace(/^-+|-+$/g, "");
|
|
379
|
-
|
|
384
|
+
const jj = async (root: string, args: string[]) => { const child = Bun.spawn({ cmd: ["jj", ...args], cwd: root, stdout: "pipe", stderr: "pipe" }); const out = await new Response(child.stdout).text(), err = await new Response(child.stderr).text(); return { code: await child.exited, out: out.trim(), err: err.trim() }; };
|
|
385
|
+
async function requireJj46(cwd: string) {
|
|
386
|
+
const result = await jj(cwd, ["version"]);
|
|
387
|
+
if (result.code) return `Jujutsu is unavailable: ${result.err || result.out}`;
|
|
388
|
+
const match = result.out.match(/\b(\d+)\.(\d+)\.(\d+)\b/);
|
|
389
|
+
if (!match) return `could not parse Jujutsu version: ${result.out}`;
|
|
390
|
+
const major = Number(match[1]), minor = Number(match[2]);
|
|
391
|
+
if (major === 0 && minor < 46) return `Jujutsu 0.46.0 or newer is required; found ${match[0]}`;
|
|
392
|
+
return null;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
async function jjPrimaryRoot(root: string) {
|
|
396
|
+
const common = await git(root, ["rev-parse", "--git-common-dir"]);
|
|
397
|
+
if (common.code || !common.out) return null;
|
|
398
|
+
return realpath(dirname(resolve(root, common.out))).catch(() => null);
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
async function currentCommit(root: string) {
|
|
402
|
+
await jj(root, ["status"]);
|
|
403
|
+
const result = await jj(root, ["log", "-r", "@", "--no-graph", "-T", "commit_id"]);
|
|
404
|
+
return result.code ? null : result.out;
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
async function branchCommit(root: string, branch: string) {
|
|
408
|
+
const result = await git(root, ["rev-parse", "--verify", `refs/heads/${branch}^{commit}`]);
|
|
409
|
+
return result.code ? null : result.out;
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
// A delivery commit must have one unambiguous non-protected exported bookmark.
|
|
413
|
+
// Ref ordering is never used as ownership authority.
|
|
414
|
+
async function branchForCommit(root: string, commit: string, protectedBranches: string[]) {
|
|
415
|
+
const refs = await git(root, ["for-each-ref", "--format=%(refname:short)", "--points-at", commit, "refs/heads/"]);
|
|
416
|
+
if (refs.code) return { error: refs.err || refs.out };
|
|
417
|
+
const candidates = refs.out.split("\n").map(line => line.trim()).filter(Boolean)
|
|
418
|
+
.filter(name => !protectedBranches.includes(name));
|
|
419
|
+
return candidates.length === 1
|
|
420
|
+
? { branch: candidates[0]! }
|
|
421
|
+
: { error: `current Jujutsu revision must have exactly one non-protected exported bookmark; found ${candidates.length}` };
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
type Layout = { primary: string; worktreeRoot: string; current: string };
|
|
425
|
+
async function jjLayout(root: string, cfg: HookPolicy): Promise<Layout | { error: string }> {
|
|
426
|
+
const current = await realpath(resolve(root));
|
|
427
|
+
const primary = await jjPrimaryRoot(current);
|
|
428
|
+
if (!primary) return { error: "not inside a colocated Git-backed Jujutsu workspace" };
|
|
429
|
+
const expectedPrimary = cfg.worktrees.primary_directory === "." ? primary : join(dirname(primary), cfg.worktrees.primary_directory);
|
|
430
|
+
if (primary !== expectedPrimary) return { error: `primary checkout must be ${expectedPrimary}, not ${primary}` };
|
|
431
|
+
const primaryTip = await branchCommit(primary, cfg.worktrees.primary_branch);
|
|
432
|
+
if (!primaryTip) return { error: `primary bookmark ${cfg.worktrees.primary_branch} is not exported to Git` };
|
|
433
|
+
return { primary, worktreeRoot: resolve(primary, cfg.worktrees.root.replaceAll("{repo}", basename(primary))), current };
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
async function noConflicts(root: string) {
|
|
437
|
+
const result = await jj(root, ["log", "-r", "@ & conflicts()", "--no-graph", "-T", "commit_id"]);
|
|
438
|
+
return result.code ? { error: result.err || result.out } : result.out ? { error: "workspace has unresolved conflicts" } : {};
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
async function preflight(root: string, cfg: HookPolicy): Promise<Layout | { error: string }> {
|
|
442
|
+
if (!cfg.worktrees.enabled) return { error: "worktree policy is disabled; set worktrees.enabled: true in atdd-bun.yaml" };
|
|
443
|
+
const version = await requireJj46(root);
|
|
444
|
+
return version ? { error: version } : jjLayout(root, cfg);
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
async function startTarget(root: string, branch: string, state: Layout, cfg: HookPolicy) {
|
|
448
|
+
if (state.current !== state.primary) return { error: `start must run from the primary checkout ${state.primary}` };
|
|
449
|
+
if (!branch) return { error: "branch is required: atdd-bun worktree start <branch>" };
|
|
450
|
+
if ((await git(root, ["check-ref-format", "--branch", branch])).code) return { error: `invalid branch name ${JSON.stringify(branch)}` };
|
|
451
|
+
if (cfg.protected_branches.includes(branch)) return { error: `cannot create a worktree for protected branch ${branch}` };
|
|
452
|
+
if (await branchCommit(state.primary, branch)) return { error: `branch ${branch} already exists` };
|
|
453
|
+
const leaf = branchPath(branch);
|
|
454
|
+
if (!leaf) return { error: `branch ${JSON.stringify(branch)} cannot form a safe worktree directory` };
|
|
455
|
+
const target = join(state.worktreeRoot, leaf);
|
|
456
|
+
return existsSync(target) ? { error: `worktree path already exists: ${target}` } : { leaf, target };
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
// Workspace creation is two-phase. If bookmark export fails, both JJ artifacts
|
|
460
|
+
// are rolled back so a failed start leaves no reusable partial delivery state.
|
|
461
|
+
async function createWorkspace(state: Layout, target: { leaf: string; target: string }, branch: string, cfg: HookPolicy) {
|
|
462
|
+
await mkdir(state.worktreeRoot, { recursive: true });
|
|
463
|
+
const created = await jj(state.primary, ["workspace", "add", "--colocate", "--name", target.leaf, "-r", cfg.worktrees.primary_branch, target.target]);
|
|
464
|
+
if (created.code) return bad(created.err || created.out);
|
|
465
|
+
const bookmark = await jj(target.target, ["bookmark", "set", branch, "-r", "@"]);
|
|
466
|
+
const id = await currentCommit(target.target);
|
|
467
|
+
const exported = await branchCommit(target.target, branch);
|
|
468
|
+
if (!bookmark.code && id && exported === id) return { ok: true, message: `created linked Jujutsu workspace ${target.target} on ${branch}` };
|
|
469
|
+
await jj(state.primary, ["workspace", "remove", target.leaf]);
|
|
470
|
+
await jj(state.primary, ["bookmark", "delete", branch]);
|
|
471
|
+
return bad(bookmark.err || bookmark.out || `bookmark ${branch} was not exported to the colocated Git repository`);
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
async function finishCandidate(state: Layout, cfg: HookPolicy) {
|
|
475
|
+
if (state.current === state.primary || !inside(state.worktreeRoot, state.current)) return { error: `finish must run from a linked workspace beneath ${state.worktreeRoot}` };
|
|
476
|
+
const conflict = await noConflicts(state.current);
|
|
477
|
+
if ("error" in conflict) return conflict;
|
|
478
|
+
const current = await currentCommit(state.current);
|
|
479
|
+
if (!current) return { error: "could not resolve the current Jujutsu revision" };
|
|
480
|
+
let delivery = current, found = await branchForCommit(state.current, current, cfg.protected_branches);
|
|
481
|
+
if ("error" in found) {
|
|
482
|
+
const changed = await jj(state.current, ["diff", "-r", "@", "--name-only"]);
|
|
483
|
+
const parent = await jj(state.current, ["log", "-r", "@-", "--no-graph", "-T", "commit_id"]);
|
|
484
|
+
if (changed.code || changed.out || parent.code || !parent.out) return found;
|
|
485
|
+
const parentFound = await branchForCommit(state.current, parent.out, cfg.protected_branches);
|
|
486
|
+
if ("error" in parentFound) return found;
|
|
487
|
+
delivery = parent.out;
|
|
488
|
+
found = parentFound;
|
|
489
|
+
}
|
|
490
|
+
const tip = await branchCommit(state.primary, found.branch);
|
|
491
|
+
if (!tip || tip !== delivery) return { error: `bookmark ${found.branch} must point at the delivery revision before finish` };
|
|
492
|
+
if ((await git(state.primary, ["merge-base", "--is-ancestor", tip, cfg.worktrees.primary_branch])).code) return { error: `branch ${found.branch} is not merged into ${cfg.worktrees.primary_branch} in the primary checkout` };
|
|
493
|
+
return { branch: found.branch, tip, name: branchPath(found.branch) };
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
// The sanitized bookmark name is not sufficient authority to remove a workspace;
|
|
497
|
+
// verify the JJ registry resolves that name back to the exact current path.
|
|
498
|
+
async function verifyWorkspace(state: Layout, candidate: { name: string }) {
|
|
499
|
+
const recorded = await jj(state.primary, ["workspace", "root", "--name", candidate.name]);
|
|
500
|
+
if (recorded.code) return `workspace ${candidate.name} is not registered: ${recorded.err || recorded.out}`;
|
|
501
|
+
const recordedPath = await realpath(recorded.out).catch(() => null);
|
|
502
|
+
return recordedPath === state.current ? null : `workspace ${candidate.name} resolves to ${recorded.out}, not ${state.current}`;
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
async function deleteBookmark(state: Layout, candidate: { branch: string; tip: string }, cfg: HookPolicy) {
|
|
506
|
+
const now = await branchCommit(state.primary, candidate.branch);
|
|
507
|
+
const merged = !(await git(state.primary, ["merge-base", "--is-ancestor", candidate.tip, cfg.worktrees.primary_branch])).code;
|
|
508
|
+
if (now !== candidate.tip || !merged) return `workspace removed, but bookmark ${candidate.branch} was not deleted: its tip changed or is no longer merged into ${cfg.worktrees.primary_branch}`;
|
|
509
|
+
const deleted = await jj(state.primary, ["bookmark", "delete", candidate.branch]);
|
|
510
|
+
return deleted.code ? `workspace removed, but bookmark deletion failed: ${deleted.err || deleted.out}` : null;
|
|
511
|
+
}
|
|
512
|
+
async function startJjWorktree(root: string, branch: string, cfg: HookPolicy) {
|
|
513
|
+
const state = await preflight(root, cfg);
|
|
514
|
+
if ("error" in state) return bad(state.error);
|
|
515
|
+
const target = await startTarget(root, branch, state, cfg);
|
|
516
|
+
if ("error" in target) return bad(target.error);
|
|
517
|
+
return createWorkspace(state, target, branch, cfg);
|
|
518
|
+
}
|
|
519
|
+
async function statusJjWorktrees(root: string, cfg: HookPolicy) {
|
|
520
|
+
if (!cfg.worktrees.enabled) return bad("worktree policy is disabled");
|
|
521
|
+
const version = await requireJj46(root);
|
|
522
|
+
if (version) return bad(version);
|
|
523
|
+
const state = await jjLayout(root, cfg);
|
|
524
|
+
if ("error" in state) return bad(state.error);
|
|
525
|
+
const result = await jj(state.primary, ["workspace", "list"]);
|
|
526
|
+
return result.code ? bad(result.err || result.out) : { ok: true, message: result.out };
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
async function finishJjWorktree(root: string, deleteBranch: boolean, cfg: HookPolicy) {
|
|
530
|
+
const state = await preflight(root, cfg);
|
|
531
|
+
if ("error" in state) return bad(state.error);
|
|
532
|
+
const candidate = await finishCandidate(state, cfg);
|
|
533
|
+
if ("error" in candidate) return bad(candidate.error);
|
|
534
|
+
const mismatch = await verifyWorkspace(state, candidate);
|
|
535
|
+
if (mismatch) return bad(mismatch);
|
|
536
|
+
const removed = await jj(state.primary, ["workspace", "remove", candidate.name]);
|
|
537
|
+
if (removed.code) return bad(removed.err || removed.out);
|
|
538
|
+
if (!deleteBranch) return { ok: true, message: `removed merged Jujutsu workspace ${state.current}` };
|
|
539
|
+
const deletionError = await deleteBookmark(state, candidate, cfg);
|
|
540
|
+
return deletionError ? bad(deletionError) : { ok: true, message: `removed merged Jujutsu workspace ${state.current} and bookmark ${candidate.branch}` };
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
|
|
544
|
+
export type VcsMode = "git" | "jj";
|
|
545
|
+
export async function startWorktree(root = process.cwd(), branch = "", vcs: VcsMode = "git") { const cfg = await policy(root); if (vcs === "jj") return startJjWorktree(root, branch, cfg); if (!cfg.worktrees.enabled) return bad("worktree policy is disabled; set worktrees.enabled: true in atdd-bun.yaml"); const state = await layout(root, cfg.worktrees); if ("error" in state) return bad(state.error); if (state.current !== state.primary) return bad(`start must run from the primary checkout ${state.primary}`); if (!branch) return bad("branch is required: atdd-bun worktree start <branch>"); if ((await git(root, ["check-ref-format", "--branch", branch])).code) return bad(`invalid branch name ${JSON.stringify(branch)}`); if (cfg.protected_branches.includes(branch)) return bad(`cannot create a worktree for protected branch ${branch}`); const leaf = branchPath(branch); if (!leaf) return bad(`branch ${JSON.stringify(branch)} cannot form a safe worktree directory`); const target = join(state.worktreeRoot, leaf); if (existsSync(target)) return bad(`worktree path already exists: ${target}`); await mkdir(state.worktreeRoot, { recursive: true }); const created = await git(state.primary, ["worktree", "add", target, "-b", branch]); if (created.code) return bad(created.err || created.out); const hooks = await installHooks(target); if (!hooks.ok) return bad(`created ${target}, but could not install hooks: ${hooks.message}`); return { ok: true, message: `created linked worktree ${target} on ${branch}` }; }
|
|
380
546
|
|
|
381
|
-
export async function worktreeStatus(root = process.cwd()) { const { policy: cfg, source } = await primaryPolicy(root); if (!cfg.worktrees.enabled) return disabledWorktreePolicy(source); const state = await layout(root, cfg.worktrees); if ("error" in state) return bad(state.error); const result = await git(state.primary, ["worktree", "list", "--porcelain"]); return result.code ? bad(result.err) : { ok: true, message: result.out }; }
|
|
547
|
+
export async function worktreeStatus(root = process.cwd(), vcs: VcsMode = "git") { const { policy: cfg, source } = await primaryPolicy(root); if (!cfg.worktrees.enabled) return disabledWorktreePolicy(source); if (vcs === "jj") return statusJjWorktrees(root, cfg); const state = await layout(root, cfg.worktrees); if ("error" in state) return bad(state.error); const result = await git(state.primary, ["worktree", "list", "--porcelain"]); return result.code ? bad(result.err) : { ok: true, message: result.out }; }
|
|
382
548
|
|
|
383
549
|
async function generatedHooksOnly(root: string): Promise<boolean> { const directory = join(root, ".githooks"); if (!existsSync(directory)) return false; const names = (await readdir(directory)).sort(); if (names.join("\n") !== [...hookEvents].sort().join("\n")) return false; return (await Promise.all(hookEvents.map(async event => (await readFile(join(directory, event), "utf8")) === await renderHook(event, root)))).every(Boolean); }
|
|
384
550
|
|
|
385
|
-
export async function finishWorktree(root = process.cwd(), deleteBranch = false) { const { policy: cfg, source } = await primaryPolicy(root); if (!cfg.worktrees.enabled) return disabledWorktreePolicy(source); const state = await layout(root, cfg.worktrees); if ("error" in state) return bad(state.error); if (state.current === state.primary || !inside(state.worktreeRoot, state.current)) return bad(`finish must run from a linked worktree beneath ${state.worktreeRoot}`); if (!state.branch || cfg.protected_branches.includes(state.branch)) return bad("finish requires a non-protected named branch"); const status = (await git(state.current, ["status", "--porcelain"])).out, generatedHooks = await generatedHooksOnly(state.current), onlyGeneratedHooks = Boolean(status) && status.split("\n").every(line => line.endsWith(" .githooks/") || line.endsWith(" .githooks")); if (status && !onlyGeneratedHooks) return bad("worktree is dirty; commit, stash, or discard changes before finish"); const tip = (await git(state.primary, ["rev-parse", "--verify", `refs/heads/${state.branch}`])).out.trim(); if (!tip || (await git(state.primary, ["merge-base", "--is-ancestor", tip, cfg.worktrees.primary_branch])).code) return bad(`branch ${state.branch} is not merged into ${cfg.worktrees.primary_branch} in the primary checkout`); const removed = await git(state.primary, ["worktree", "remove", ...(onlyGeneratedHooks && generatedHooks ? ["--force"] : []), state.current]); if (removed.code) return bad(removed.err || removed.out); if (deleteBranch) { const now = (await git(state.primary, ["rev-parse", "--verify", `refs/heads/${state.branch}`])).out.trim(); if (now !== tip || (await git(state.primary, ["merge-base", "--is-ancestor", tip, cfg.worktrees.primary_branch])).code) return bad(`worktree removed, but branch ${state.branch} was not deleted: its tip changed or is no longer merged into ${cfg.worktrees.primary_branch}`); const deleted = await git(state.primary, ["branch", "-D", state.branch]); if (deleted.code) return bad(`worktree removed, but branch deletion failed: ${deleted.err || deleted.out}`); } return { ok: true, message: `removed merged worktree ${state.current}${deleteBranch ? ` and branch ${state.branch}` : ""}` }; }
|
|
551
|
+
export async function finishWorktree(root = process.cwd(), deleteBranch = false, vcs: VcsMode = "git") { const { policy: cfg, source } = await primaryPolicy(root); if (!cfg.worktrees.enabled) return disabledWorktreePolicy(source); if (vcs === "jj") return finishJjWorktree(root, deleteBranch, cfg); const state = await layout(root, cfg.worktrees); if ("error" in state) return bad(state.error); if (state.current === state.primary || !inside(state.worktreeRoot, state.current)) return bad(`finish must run from a linked worktree beneath ${state.worktreeRoot}`); if (!state.branch || cfg.protected_branches.includes(state.branch)) return bad("finish requires a non-protected named branch"); const status = (await git(state.current, ["status", "--porcelain"])).out, generatedHooks = await generatedHooksOnly(state.current), onlyGeneratedHooks = Boolean(status) && status.split("\n").every(line => line.endsWith(" .githooks/") || line.endsWith(" .githooks")); if (status && !onlyGeneratedHooks) return bad("worktree is dirty; commit, stash, or discard changes before finish"); const tip = (await git(state.primary, ["rev-parse", "--verify", `refs/heads/${state.branch}`])).out.trim(); if (!tip || (await git(state.primary, ["merge-base", "--is-ancestor", tip, cfg.worktrees.primary_branch])).code) return bad(`branch ${state.branch} is not merged into ${cfg.worktrees.primary_branch} in the primary checkout`); const removed = await git(state.primary, ["worktree", "remove", ...(onlyGeneratedHooks && generatedHooks ? ["--force"] : []), state.current]); if (removed.code) return bad(removed.err || removed.out); if (deleteBranch) { const now = (await git(state.primary, ["rev-parse", "--verify", `refs/heads/${state.branch}`])).out.trim(); if (now !== tip || (await git(state.primary, ["merge-base", "--is-ancestor", tip, cfg.worktrees.primary_branch])).code) return bad(`worktree removed, but branch ${state.branch} was not deleted: its tip changed or is no longer merged into ${cfg.worktrees.primary_branch}`); const deleted = await git(state.primary, ["branch", "-D", state.branch]); if (deleted.code) return bad(`worktree removed, but branch deletion failed: ${deleted.err || deleted.out}`); } return { ok: true, message: `removed merged worktree ${state.current}${deleteBranch ? ` and branch ${state.branch}` : ""}` }; }
|