craftar 0.33.0 → 0.34.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/README.md +23 -7
- package/dist/cli.js +162 -31
- package/dist/cli.js.map +1 -1
- package/dist/core/forge.js +6 -6
- package/dist/core/forge.js.map +1 -1
- package/dist/core/init-flow.js +457 -14
- package/dist/core/init-flow.js.map +1 -1
- package/dist/core/init.js +72 -0
- package/dist/core/init.js.map +1 -1
- package/dist/core/new-profile-gate.js +65 -0
- package/dist/core/new-profile-gate.js.map +1 -0
- package/dist/core/new-profile-write.js +148 -0
- package/dist/core/new-profile-write.js.map +1 -0
- package/dist/core/new-profile.js +183 -0
- package/dist/core/new-profile.js.map +1 -0
- package/dist/core/promote.js +7 -2
- package/dist/core/promote.js.map +1 -1
- package/dist/core/registry.js +2 -16
- package/dist/core/registry.js.map +1 -1
- package/dist/core/remote.js +88 -3
- package/dist/core/remote.js.map +1 -1
- package/dist/importers/drift-promote.js +5 -1
- package/dist/importers/drift-promote.js.map +1 -1
- package/package.json +1 -1
package/dist/cli.js
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
import { Command } from "commander";
|
|
2
2
|
import pc from "picocolors";
|
|
3
3
|
import path from "node:path";
|
|
4
|
-
import { promises as fs } from "node:fs";
|
|
4
|
+
import { promises as fs, statSync } from "node:fs";
|
|
5
5
|
import YAML from "yaml";
|
|
6
6
|
import { importClaudeCode } from "./importers/claude-code.js";
|
|
7
7
|
import { parsePreferKiro } from "./importers/prefer-kiro.js";
|
|
8
8
|
import { planPromote, applyPromote, UnheldError } from "./importers/drift-promote.js";
|
|
9
9
|
import { providedRefs } from "./importers/provided.js";
|
|
10
10
|
import { holdsBaseCitations, rendersServers } from "./core/mcp-server.js";
|
|
11
|
-
import { classifyForge } from "./core/remote.js";
|
|
11
|
+
import { assertNotCacheTree, classifyForge } from "./core/remote.js";
|
|
12
12
|
import { forget, listWorkspaces, prune, readRegistry, register, registryFile } from "./core/registry.js";
|
|
13
13
|
import { forgeWorkspaces, planAll, nextSync, impactOf, concerned, refineRegistryState, countStates, countStatesObject, NEXT_SYNC_STATES } from "./core/impact.js";
|
|
14
14
|
import { runDoctor } from "./core/doctor.js";
|
|
@@ -16,7 +16,7 @@ import { launchRefusal, pinOf } from "./core/mcp-protocol.js";
|
|
|
16
16
|
import { serve } from "./mcp.js";
|
|
17
17
|
import { pruneCache } from "./core/cache.js";
|
|
18
18
|
import { resolveHome } from "./core/home-lock.js";
|
|
19
|
-
import { loadWorkspace, readWorkspaceConfig, plan, readLock, status, apply, unsetDeclared, unsetRefusal, unsetRefused, agentsMdOverBudget, agentsMdRefusal, resolveForge, resolveForgeSource, WORKSPACE_FILE, LOCAL_FILE } from "./core/sync.js";
|
|
19
|
+
import { loadWorkspace, loadForgeSource, readWorkspaceConfig, plan, readLock, status, apply, unsetDeclared, unsetRefusal, unsetRefused, agentsMdOverBudget, agentsMdRefusal, resolveForge, resolveForgeSource, WORKSPACE_FILE, LOCAL_FILE } from "./core/sync.js";
|
|
20
20
|
import { sectionKey } from "./core/resolve.js";
|
|
21
21
|
import { catalogueContext, listRecipes, listIngredients, checkType, withLoadWarnings } from "./core/catalogue.js";
|
|
22
22
|
import { canonicalValue } from "./core/sections.js";
|
|
@@ -30,7 +30,10 @@ import { applyBasePromotion, planBasePromotion, proveBasePromotion } from "./cor
|
|
|
30
30
|
import { fingerprintDir } from "./core/fingerprint.js";
|
|
31
31
|
import { hunkAt, planFrom, applyPlan, writeUnified, checkRecipeCascade, rewriteRecipes, pruneCandidates, pruneRecipes, note, } from "./core/unify.js";
|
|
32
32
|
import { checkParamWrites, writeParamFile } from "./core/param-writes.js";
|
|
33
|
-
import { checkInitFlags, initLine, planInit } from "./core/init.js";
|
|
33
|
+
import { checkInitFlags, checkLocalKeys, initLine, planInit, forgeSource } from "./core/init.js";
|
|
34
|
+
import { profileName } from "./core/new-profile.js";
|
|
35
|
+
import { newProfileGate } from "./core/new-profile-gate.js";
|
|
36
|
+
import { writeNewProfile, reproveFromDisk, NewProfileRaceLost, NewProfileLateFailure } from "./core/new-profile-write.js";
|
|
34
37
|
import { askInit, askClone, confirmInit, againLine, NOT_CLONED_REFUSED } from "./core/init-flow.js";
|
|
35
38
|
import { findGit, cloneRepos, listRepos, planClone } from "./core/clone.js";
|
|
36
39
|
import { byHand, profileRepos } from "./core/repos.js";
|
|
@@ -54,7 +57,7 @@ process.stdout.on("error", (e) => { if (e.code === "EPIPE")
|
|
|
54
57
|
*/
|
|
55
58
|
const DIFF_EXIT_CODE_FETCH_MODE = "sync";
|
|
56
59
|
const program = new Command();
|
|
57
|
-
program.name("craftar").description("Craft, sync and convert AI-coding workspace harnesses.").version("0.
|
|
60
|
+
program.name("craftar").description("Craft, sync and convert AI-coding workspace harnesses.").version("0.34.1");
|
|
58
61
|
const SAVE_PLAN_GATE = { what: "plan", who: "unify", outside: "save plans outside the Forge", never: "unify never overwrites" };
|
|
59
62
|
const REPORT_GATE = { what: "report", who: "import", outside: "write the report outside the Forge", never: "import never overwrites" };
|
|
60
63
|
program
|
|
@@ -73,8 +76,10 @@ program
|
|
|
73
76
|
// import writes a local Forge; a URL would become a directory named after it (spec 13 §4.4).
|
|
74
77
|
if (classifyForge(o.forge) === "url")
|
|
75
78
|
fail("--forge takes a directory; to read a remote Forge, run inside a workspace that names it");
|
|
79
|
+
// A cache tree is refused first: it reads only the real paths of --forge and of the cache, nothing of the workspace.
|
|
80
|
+
await assertNotCacheTree(path.resolve(o.forge), craftarHome()).catch((e) => fail(e instanceof Error ? e.message : String(e)));
|
|
76
81
|
// Spec 29 §4.2: the report's path is refused before the import reads anything, so a refusal leaves the Forge untouched.
|
|
77
|
-
// Spec 29 §4.3: PK5, before
|
|
82
|
+
// Spec 29 §4.3: PK5, before the workspace is read.
|
|
78
83
|
const preferKiro = o.preferKiro === undefined ? undefined : parsePreferKiro(o.preferKiro);
|
|
79
84
|
const reportAbs = o.report === undefined ? null : await gateOutsideForge(o.report, o.forge, REPORT_GATE);
|
|
80
85
|
const r = await importClaudeCode({ workspaceRoot: o.workspace, forgeRoot: o.forge, profileName: o.profile, writeWorkspaceConfig: o.writeConfig, report: reportAbs !== null, preferKiro });
|
|
@@ -294,13 +299,15 @@ async function runCloneStep(root, profile, entries, git, again) {
|
|
|
294
299
|
/* ---------------------------------------------------------------- init */
|
|
295
300
|
program
|
|
296
301
|
.command("init")
|
|
297
|
-
.description("Start a workspace from a Forge and a profile: write a craftar.yaml proved to resolve and plan, then run the first sync; with --clone, clone the profile's repositories into the workspace (after the first sync, or with --no-sync after writing craftar.yaml); in a terminal, asks for the Forge and the profile not given as flags; refused when craftar.yaml already exists")
|
|
302
|
+
.description("Start a workspace from a Forge and a profile: write a craftar.yaml proved to resolve and plan, then run the first sync; with --new-profile, create the profile in the Forge directory first (recipes and targets from --like and the recipe/target flags), then start the workspace on it — nothing is committed; with --clone, clone the profile's repositories into the workspace (after the first sync, or with --no-sync after writing craftar.yaml); in a terminal, asks for the Forge and the profile not given as flags; refused when craftar.yaml already exists")
|
|
298
303
|
.option("--forge <dir|url>", "the Forge: a directory (written relative to the workspace) or a git URL; asked for in a terminal when omitted")
|
|
299
304
|
.option("--profile <name>", "the client profile in the Forge; asked for in a terminal when omitted")
|
|
305
|
+
.option("--new-profile <name>", "create this profile in the Forge directory (recipes and targets from --like and the recipe flags), then start the workspace on it; nothing is committed")
|
|
306
|
+
.option("--like <profile>", "with --new-profile: start the new profile's recipes and targets from this profile's — nothing else of it is copied")
|
|
300
307
|
.option("--ref <ref>", "branch, tag or full SHA of a remote Forge")
|
|
301
|
-
.option("--targets <a,b>", "targets to write in craftar.yaml (claude-code, kiro, agents-md); omitted, the workspace follows the profile's")
|
|
302
|
-
.option("--add-recipe <name>", "add a recipe to the profile's (repeatable)", collect, [])
|
|
303
|
-
.option("--remove-recipe <name>", "remove a recipe
|
|
308
|
+
.option("--targets <a,b>", "targets to write in craftar.yaml, or of the new profile with --new-profile (claude-code, kiro, agents-md); omitted, the workspace follows the profile's")
|
|
309
|
+
.option("--add-recipe <name>", "add a recipe to the profile's, or to the new profile's with --new-profile (repeatable)", collect, [])
|
|
310
|
+
.option("--remove-recipe <name>", "remove a recipe from the profile's, or from the new profile's with --new-profile (repeatable)", collect, [])
|
|
304
311
|
.option("--replace", "let --add-recipe take a slot another recipe holds", false)
|
|
305
312
|
.option("--no-sync", "write craftar.yaml only, and say what the first sync would do")
|
|
306
313
|
.option("--clone", "after the first sync, clone the profile's repositories (its repos list) into the workspace")
|
|
@@ -310,7 +317,8 @@ program
|
|
|
310
317
|
.action(async (o, cmd) => {
|
|
311
318
|
const root = path.resolve(o.workspace);
|
|
312
319
|
// N1: craftar.yaml already exists
|
|
313
|
-
const
|
|
320
|
+
const n1Text = `${WORKSPACE_FILE} already exists in ${root} — change recipes with craftar add recipe / remove recipe, or edit it`;
|
|
321
|
+
const n1 = () => fail(n1Text);
|
|
314
322
|
const file = path.join(root, WORKSPACE_FILE);
|
|
315
323
|
if (await exists(file))
|
|
316
324
|
n1();
|
|
@@ -351,15 +359,29 @@ program
|
|
|
351
359
|
if (o.clone === true && git === null) {
|
|
352
360
|
fail("cloning needs git — install it, or leave the clone out");
|
|
353
361
|
}
|
|
362
|
+
// P1–P4: --new-profile checks (before anything is read)
|
|
363
|
+
if (o.newProfile !== undefined && o.profile !== undefined) {
|
|
364
|
+
fail("pass --profile or --new-profile, not both");
|
|
365
|
+
}
|
|
366
|
+
if (o.like !== undefined && o.newProfile === undefined) {
|
|
367
|
+
fail("--like goes with --new-profile");
|
|
368
|
+
}
|
|
369
|
+
if (o.newProfile !== undefined) {
|
|
370
|
+
profileName(o.newProfile);
|
|
371
|
+
}
|
|
372
|
+
if (o.newProfile !== undefined && o.forge !== undefined && classifyForge(o.forge) === "url") {
|
|
373
|
+
fail("a new profile is written into a Forge directory — clone the Forge, pass that directory as --forge, then commit and push the profile");
|
|
374
|
+
}
|
|
354
375
|
// Mode: flags when both given; else interactive when both streams are TTYs; else N2
|
|
355
|
-
|
|
376
|
+
// --new-profile counts as --profile for bothGiven and N2
|
|
377
|
+
const bothGiven = o.forge !== undefined && (o.profile !== undefined || o.newProfile !== undefined);
|
|
356
378
|
const isTTY = process.stdin.isTTY && process.stdout.isTTY;
|
|
357
379
|
if (!bothGiven && !isTTY) {
|
|
358
380
|
// N2: refuse with our message
|
|
359
|
-
if (o.forge === undefined && o.profile === undefined) {
|
|
381
|
+
if (o.forge === undefined && o.profile === undefined && o.newProfile === undefined) {
|
|
360
382
|
fail("--forge and --profile are required when craftar init does not run in a terminal — pass them, or run craftar init in a terminal to be asked");
|
|
361
383
|
}
|
|
362
|
-
else if (o.profile === undefined) {
|
|
384
|
+
else if (o.profile === undefined && o.newProfile === undefined) {
|
|
363
385
|
fail("--profile is required when craftar init does not run in a terminal — pass it, or run craftar init in a terminal to be asked");
|
|
364
386
|
}
|
|
365
387
|
else {
|
|
@@ -375,6 +397,8 @@ program
|
|
|
375
397
|
const result = await askInit(root, {
|
|
376
398
|
forge: o.forge,
|
|
377
399
|
profile: o.profile,
|
|
400
|
+
newProfile: o.newProfile,
|
|
401
|
+
like: o.like,
|
|
378
402
|
ref: o.ref,
|
|
379
403
|
targets: o.targets === undefined ? undefined : o.targets.split(",").map((t) => t.trim()),
|
|
380
404
|
addRecipes: o.addRecipe,
|
|
@@ -387,6 +411,32 @@ program
|
|
|
387
411
|
input = result.input;
|
|
388
412
|
answers = result.answers;
|
|
389
413
|
}
|
|
414
|
+
else if (o.newProfile !== undefined) {
|
|
415
|
+
// --forge and --new-profile given (flags mode with new profile)
|
|
416
|
+
// N10 (spec 38 §4.2): the one check, before the load — --new-profile counts as --profile there
|
|
417
|
+
checkLocalKeys(local.keys, {
|
|
418
|
+
ref: o.ref !== undefined,
|
|
419
|
+
recipes: o.addRecipe.length > 0 || o.removeRecipe.length > 0,
|
|
420
|
+
targets: o.targets !== undefined,
|
|
421
|
+
});
|
|
422
|
+
// Load the Forge
|
|
423
|
+
const loaded = await loadForgeSource(root, forgeSource(root, o.forge), o.ref ?? null, { ...load(o, "sync") });
|
|
424
|
+
// Gate: P5-P8
|
|
425
|
+
await newProfileGate(loaded.forge, resolveHome(process.env.CRAFTAR_HOME || undefined), o.newProfile);
|
|
426
|
+
// Build input
|
|
427
|
+
input = {
|
|
428
|
+
forge: o.forge,
|
|
429
|
+
profile: o.newProfile,
|
|
430
|
+
newProfile: { name: o.newProfile, like: o.like },
|
|
431
|
+
ref: o.ref,
|
|
432
|
+
targets: o.targets === undefined ? undefined : o.targets.split(",").map((t) => t.trim()),
|
|
433
|
+
addRecipes: o.addRecipe,
|
|
434
|
+
removeRecipes: o.removeRecipe,
|
|
435
|
+
replace: o.replace,
|
|
436
|
+
clone: o.clone === true,
|
|
437
|
+
loaded,
|
|
438
|
+
};
|
|
439
|
+
}
|
|
390
440
|
else {
|
|
391
441
|
input = {
|
|
392
442
|
forge: o.forge,
|
|
@@ -399,7 +449,7 @@ program
|
|
|
399
449
|
clone: o.clone === true,
|
|
400
450
|
};
|
|
401
451
|
}
|
|
402
|
-
|
|
452
|
+
let init = await planInit(root, input, { ...load(o, "sync"), local });
|
|
403
453
|
// Interactive mode: when o.clone is undefined, ask about cloning between planInit and confirmInit
|
|
404
454
|
// --clone typed: planInit has checked C3–C5 and returned the entries, in either mode
|
|
405
455
|
let cloneEntries = o.clone === true ? init.repos : [];
|
|
@@ -422,26 +472,84 @@ program
|
|
|
422
472
|
if (confirm === "cancelled")
|
|
423
473
|
fail("init cancelled — nothing written");
|
|
424
474
|
}
|
|
425
|
-
|
|
475
|
+
let { ws, plan: p } = init;
|
|
476
|
+
const forgeRoot = ws.forge.root;
|
|
426
477
|
const workspaceGiven = cmd.getOptionValueSource("workspace") === "cli";
|
|
427
478
|
const again = () => {
|
|
428
479
|
if (answers !== null) {
|
|
429
480
|
console.log(againLine(answers, { workspace: workspaceGiven ? o.workspace : undefined, sync: o.sync, offline: o.offline, clone: cloneEntries.length > 0 }));
|
|
430
481
|
}
|
|
431
482
|
};
|
|
483
|
+
// When init.newProfile: write the profile, then reprove from disk
|
|
484
|
+
let journal = [];
|
|
485
|
+
// Step 1's notes are printed after the initLine line (spec 38), so keep them before reprove
|
|
486
|
+
const step1Notes = init.notes;
|
|
487
|
+
if (init.newProfile) {
|
|
488
|
+
try {
|
|
489
|
+
journal = await writeNewProfile(forgeRoot, init);
|
|
490
|
+
}
|
|
491
|
+
catch (e) {
|
|
492
|
+
if (e instanceof NewProfileRaceLost) {
|
|
493
|
+
// P7's first message, no recover lines
|
|
494
|
+
fail(e.message);
|
|
495
|
+
}
|
|
496
|
+
if (e instanceof NewProfileLateFailure) {
|
|
497
|
+
fail(lateFailure(e, forgeRoot, e.journal, "init"));
|
|
498
|
+
}
|
|
499
|
+
throw e;
|
|
500
|
+
}
|
|
501
|
+
// Reprove from disk (step 4)
|
|
502
|
+
try {
|
|
503
|
+
init = await reproveFromDisk(root, input.forge, init, { ...load(o, "sync") }, journal, input.ref);
|
|
504
|
+
}
|
|
505
|
+
catch (e) {
|
|
506
|
+
if (e instanceof NewProfileLateFailure) {
|
|
507
|
+
fail(lateFailure(e, forgeRoot, e.journal, "init"));
|
|
508
|
+
}
|
|
509
|
+
throw e;
|
|
510
|
+
}
|
|
511
|
+
// Update ws and p from the second init
|
|
512
|
+
ws = init.ws;
|
|
513
|
+
p = init.plan;
|
|
514
|
+
// Print the profile creation lines
|
|
515
|
+
console.log(pc.bold(`craftar init — created profile ${ws.config.profile} in ${forgeRoot}`));
|
|
516
|
+
console.log(` + profiles/${ws.config.profile}/profile.yaml (not committed — commit it in the Forge, on a branch, before anyone else syncs)`);
|
|
517
|
+
}
|
|
432
518
|
// Every refusal is above: only now does the directory, and craftar.yaml, come to exist
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
519
|
+
// When journal is not empty: mkdir inside the try, with late failure handling
|
|
520
|
+
if (journal.length > 0) {
|
|
521
|
+
try {
|
|
522
|
+
await fs.mkdir(root, { recursive: true });
|
|
523
|
+
}
|
|
524
|
+
catch (e) {
|
|
525
|
+
fail(lateFailure(e, forgeRoot, journal, "init"));
|
|
526
|
+
}
|
|
527
|
+
try {
|
|
528
|
+
await fs.writeFile(file, init.text, { flag: "wx" });
|
|
529
|
+
}
|
|
530
|
+
catch (e) {
|
|
531
|
+
if (e.code === "EEXIST") {
|
|
532
|
+
// N1's text, then lateFailure
|
|
533
|
+
fail(`${n1Text}\n${lateFailure(e, forgeRoot, journal, "init")}`);
|
|
534
|
+
}
|
|
535
|
+
fail(lateFailure(e, forgeRoot, journal, "init"));
|
|
536
|
+
}
|
|
436
537
|
}
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
538
|
+
else {
|
|
539
|
+
// No journal: existing behaviour unchanged
|
|
540
|
+
await fs.mkdir(root, { recursive: true });
|
|
541
|
+
try {
|
|
542
|
+
await fs.writeFile(file, init.text, { flag: "wx" });
|
|
543
|
+
}
|
|
544
|
+
catch (e) {
|
|
545
|
+
if (e.code === "EEXIST")
|
|
546
|
+
n1();
|
|
547
|
+
throw e;
|
|
548
|
+
}
|
|
441
549
|
}
|
|
442
550
|
console.log(pc.bold(`craftar init — wrote ${WORKSPACE_FILE} in ${root}`));
|
|
443
551
|
console.log(` ${initLine(init)}`);
|
|
444
|
-
for (const n of
|
|
552
|
+
for (const n of step1Notes)
|
|
445
553
|
console.log(` note ${n}`);
|
|
446
554
|
const unset = unsetDeclared(p);
|
|
447
555
|
const cloning = cloneEntries.length > 0;
|
|
@@ -1966,8 +2074,8 @@ forge
|
|
|
1966
2074
|
fail("pass exactly one of --take, --plan or --save-plan");
|
|
1967
2075
|
if (o.take !== undefined && o.take !== "base" && o.take !== "variant")
|
|
1968
2076
|
fail(`--take must be "base" or "variant"`);
|
|
1969
|
-
// Never through the cache:
|
|
1970
|
-
// would be lost at the next cleanup without reaching the remote (spec 13 §4.4).
|
|
2077
|
+
// Never through the cache: the URL form is refused by refuseRemote and the directory form by assertNotCacheTree;
|
|
2078
|
+
// a tree there is clean and tracked, so gitUnheld would pass and the edit would be lost at the next cleanup without reaching the remote (spec 13 §4.4).
|
|
1971
2079
|
const f = await resolveForge({
|
|
1972
2080
|
forge: o.forge,
|
|
1973
2081
|
workspace: o.workspace,
|
|
@@ -1984,6 +2092,7 @@ forge
|
|
|
1984
2092
|
fail(`${ref} has no variant for profile ${o.profile}`);
|
|
1985
2093
|
// Only when about to write — --save-plan touches nothing inside the Forge, so it is exempt.
|
|
1986
2094
|
if (!o.savePlan) {
|
|
2095
|
+
await assertNotCacheTree(f.root, craftarHome()).catch((e) => fail(e instanceof Error ? e.message : String(e)));
|
|
1987
2096
|
if (f.commit === null) {
|
|
1988
2097
|
// `gitHead` (and so `f.commit`) is also `null` for a real git repo with no commits yet —
|
|
1989
2098
|
// that is not "not a git repository" (Ruling 22), so tell the two apart before wording it.
|
|
@@ -2448,12 +2557,13 @@ forge
|
|
|
2448
2557
|
.option("--dry-run", "run every check and the first impact pass, print what would be written, and write nothing", false)
|
|
2449
2558
|
.option("--json", "machine-readable output", false)
|
|
2450
2559
|
.action(async (ref, o) => {
|
|
2451
|
-
// Never through the cache, as `forge unify` (spec 13 §4.4).
|
|
2560
|
+
// Never through the cache, as `forge unify` (spec 13 §4.4): the URL form is refused by refuseRemote and the directory form by assertNotCacheTree.
|
|
2452
2561
|
const f = await resolveForge({
|
|
2453
2562
|
forge: o.forge,
|
|
2454
2563
|
workspace: o.workspace,
|
|
2455
2564
|
refuseRemote: (url) => `the Forge of this workspace is remote (${url}) — clone it and pass --forge <dir>`,
|
|
2456
2565
|
});
|
|
2566
|
+
await assertNotCacheTree(f.root, craftarHome()).catch((e) => fail(e instanceof Error ? e.message : String(e)));
|
|
2457
2567
|
// P2 (spec 29 §4.4): git is the undo, so it must be there, hold a commit and be clean — before anything is planned.
|
|
2458
2568
|
if (f.commit === null) {
|
|
2459
2569
|
if (await gitIsRepo(f.root))
|
|
@@ -3001,6 +3111,7 @@ function unheldMessage(command, root, unheld) {
|
|
|
3001
3111
|
* The message for a `forge unify` failure after writing began (Ruling 33): the original error, the
|
|
3002
3112
|
* Forge-relative paths already touched, and the git commands that undo them — `checkout` for what
|
|
3003
3113
|
* git tracks, `clean` for files unify created (checkout refuses a path git does not know).
|
|
3114
|
+
* For created paths, directories get `clean -fd`, files (or missing paths) get `clean -f`.
|
|
3004
3115
|
*/
|
|
3005
3116
|
function lateFailure(e, root, journal, command = "unify") {
|
|
3006
3117
|
const msg = errorText(e);
|
|
@@ -3009,15 +3120,35 @@ function lateFailure(e, root, journal, command = "unify") {
|
|
|
3009
3120
|
const rel = (abs) => path.relative(root, abs).split(path.sep).join("/");
|
|
3010
3121
|
const quote = (p) => (/^[\w./-]+$/.test(p) ? p : `"${p}"`);
|
|
3011
3122
|
const restore = [...new Set(journal.filter((j) => !j.created).map((j) => rel(j.abs)))];
|
|
3012
|
-
const created = [...new Set(journal.filter((j) => j.created).map((j) =>
|
|
3123
|
+
const created = [...new Set(journal.filter((j) => j.created).map((j) => j.abs))];
|
|
3124
|
+
// For created paths, check if they are directories
|
|
3125
|
+
const createdDirs = [];
|
|
3126
|
+
const createdFiles = [];
|
|
3127
|
+
for (const abs of created) {
|
|
3128
|
+
try {
|
|
3129
|
+
const st = statSync(abs);
|
|
3130
|
+
if (st.isDirectory()) {
|
|
3131
|
+
createdDirs.push(rel(abs));
|
|
3132
|
+
}
|
|
3133
|
+
else {
|
|
3134
|
+
createdFiles.push(rel(abs));
|
|
3135
|
+
}
|
|
3136
|
+
}
|
|
3137
|
+
catch {
|
|
3138
|
+
// Path doesn't exist, treat as file
|
|
3139
|
+
createdFiles.push(rel(abs));
|
|
3140
|
+
}
|
|
3141
|
+
}
|
|
3013
3142
|
const lines = [msg, `${command} had already started changing the Forge (${root}) when this failed:`];
|
|
3014
|
-
for (const p of [...restore, ...
|
|
3143
|
+
for (const p of [...restore, ...createdDirs, ...createdFiles])
|
|
3015
3144
|
lines.push(` ${p}`);
|
|
3016
3145
|
lines.push("recover with:");
|
|
3017
3146
|
if (restore.length)
|
|
3018
3147
|
lines.push(` git -C ${quote(root)} checkout -- ${restore.map(quote).join(" ")}`);
|
|
3019
|
-
if (
|
|
3020
|
-
lines.push(` git -C ${quote(root)} clean -
|
|
3148
|
+
if (createdDirs.length)
|
|
3149
|
+
lines.push(` git -C ${quote(root)} clean -fd -- ${createdDirs.map(quote).join(" ")}`);
|
|
3150
|
+
if (createdFiles.length)
|
|
3151
|
+
lines.push(` git -C ${quote(root)} clean -f -- ${createdFiles.map(quote).join(" ")}`);
|
|
3021
3152
|
return lines.join("\n");
|
|
3022
3153
|
}
|
|
3023
3154
|
/** ` [1 evolution · 2 block]` in the fixed class order, zero counts omitted; empty for a variant without hunks (spec 08 §4.2). */
|