@anhlp/alp 0.0.1-beta.3 → 0.0.1-beta.4
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 +18 -16
- package/dist/acp.js +37 -4
- package/dist/alpd.js +10350 -3346
- package/docs/modular-monorepo.md +17 -13
- package/package.json +8 -6
- package/packages/cli/dist/THIRD-PARTY-NOTICES.txt +25 -0
- package/packages/cli/dist/acp/main.js +1 -1
- package/packages/cli/dist/acp.js +37 -4
- package/packages/cli/dist/alp-web/THIRD-PARTY-NOTICES.txt +2590 -0
- package/packages/cli/dist/alp-web/alp-boundary.json +15 -0
- package/packages/cli/dist/alp-web/apple-touch-icon.png +0 -0
- package/packages/cli/dist/alp-web/assets/TerminalPanel-DLuoa74B.css +1 -0
- package/packages/cli/dist/alp-web/assets/TerminalPanel-PrU2l3u5.js +16 -0
- package/packages/cli/dist/alp-web/assets/index-BJdBTDFX.css +1 -0
- package/packages/cli/dist/alp-web/assets/index-DpfiPv5s.js +52 -0
- package/packages/cli/dist/alp-web/favicon-attention.svg +6 -0
- package/packages/cli/dist/alp-web/favicon-running.svg +6 -0
- package/packages/cli/dist/alp-web/favicon.svg +5 -0
- package/packages/cli/dist/alp-web/index.html +20 -0
- package/packages/cli/dist/alp-web.js +9640 -0
- package/packages/cli/dist/alpd.js +10350 -3346
- package/packages/cli/dist/backup.js +2 -2
- package/packages/cli/dist/build-manifest.json +99 -1
- package/packages/cli/dist/cli.js +4 -2
- package/packages/cli/dist/interfaces.d.ts +9 -1
- package/packages/cli/dist/interfaces.js +99 -39
- package/packages/cli/dist/schedules.d.ts +2 -0
- package/packages/cli/dist/schedules.js +45 -0
- package/packages/cli/dist/sessions.d.ts +3 -1
- package/packages/cli/dist/sessions.js +22 -11
- package/packages/cli/dist/terminal.d.ts +8 -0
- package/packages/cli/dist/terminal.js +79 -0
- package/packages/cli/dist/usage.d.ts +1 -1
- package/packages/cli/dist/usage.js +8 -2
- package/packages/cli/dist/web-service.d.ts +38 -0
- package/packages/cli/dist/web-service.js +119 -0
- package/packages/client/README.md +6 -1
- package/packages/client/dist/index.d.ts +37 -2
- package/packages/client/dist/index.js +55 -22
- package/packages/client/dist/integration-lock.d.ts +35 -0
- package/packages/client/dist/integration-lock.js +179 -0
- package/packages/client/dist/portable.d.ts +1 -0
- package/packages/client/dist/portable.js +7 -1
- package/packages/client/dist/rpc/registry.d.ts +211 -0
- package/packages/client/dist/rpc/registry.js +145 -0
- package/packages/client/dist/supervise.d.ts +2 -0
- package/packages/client/dist/supervise.js +4 -4
- package/packages/core/dist/backup.js +103 -4
- package/packages/core/dist/index.d.ts +1 -0
- package/packages/core/dist/index.js +2 -0
- package/packages/core/dist/migrations.js +1 -1
- package/packages/core/dist/user-worktree.d.ts +29 -0
- package/packages/core/dist/user-worktree.js +242 -0
- package/packages/core/dist/validation.js +3 -2
|
@@ -13,10 +13,15 @@ import { STATE_SCHEMA, assertStateSupported, readStateSchema } from "./state-sch
|
|
|
13
13
|
import { withTaskLock } from "./tasks.js";
|
|
14
14
|
var BACKUP_FORMAT = 1;
|
|
15
15
|
var BACKUP_EXTENSION = ".alpbackup";
|
|
16
|
-
var
|
|
16
|
+
var RESULT_LEDGER = "state/assignment-results.json";
|
|
17
|
+
var RESULT_MARKER = `${RESULT_LEDGER}.initialized`;
|
|
18
|
+
var DESTINATIONS = "worktree-destinations.json";
|
|
19
|
+
var QUARANTINED_DESTINATIONS = "state/worktree-destinations.quarantined.json";
|
|
20
|
+
var STATE_UNITS = [RESULT_LEDGER, RESULT_MARKER, "state/schema.json", "state/sessions", "state/timeline", "state/receipts", "state/recall.json", "state/pause.json", "state/trust.json", "state/web-workspaces.json"];
|
|
17
21
|
var CLEARED_UNITS = ["state/live.json", "state/migration.json"];
|
|
18
22
|
var HOME_UNITS = [
|
|
19
23
|
...STATE_UNITS,
|
|
24
|
+
DESTINATIONS,
|
|
20
25
|
"settings.json",
|
|
21
26
|
"library.json",
|
|
22
27
|
"role-skills.json",
|
|
@@ -378,6 +383,98 @@ async function holdTaskLocks(roots, work) {
|
|
|
378
383
|
clearInterval(touch);
|
|
379
384
|
}
|
|
380
385
|
}
|
|
386
|
+
async function prepareWorkspaceRestore(home, root, units, warnings) {
|
|
387
|
+
const relevant = [RESULT_LEDGER, RESULT_MARKER, DESTINATIONS, "state/sessions", "state/web-workspaces.json"];
|
|
388
|
+
const restorable = units.filter((unit) => unit.path !== QUARANTINED_DESTINATIONS);
|
|
389
|
+
if (!units.some((unit) => relevant.includes(unit.path))) return restorable;
|
|
390
|
+
for (const base of [home, root]) {
|
|
391
|
+
const info = await lstat(path.join(base, "state")).catch((error) => {
|
|
392
|
+
if (error.code === "ENOENT") return void 0;
|
|
393
|
+
throw error;
|
|
394
|
+
});
|
|
395
|
+
if (info && !info.isDirectory()) throw invalid("workspace state must be a real directory");
|
|
396
|
+
}
|
|
397
|
+
const result = restorable.filter((unit) => ![RESULT_LEDGER, RESULT_MARKER, DESTINATIONS, "state/web-workspaces.json"].includes(unit.path));
|
|
398
|
+
async function regular(base, unit, declared) {
|
|
399
|
+
if (declared && declared.kind === "absent") return void 0;
|
|
400
|
+
const file = path.join(base, unit);
|
|
401
|
+
const info = await lstat(file).catch((error) => {
|
|
402
|
+
if (error.code === "ENOENT") return void 0;
|
|
403
|
+
throw error;
|
|
404
|
+
});
|
|
405
|
+
if (!info) {
|
|
406
|
+
if (declared) throw invalid(`${unit} is declared but missing from the backup`);
|
|
407
|
+
return void 0;
|
|
408
|
+
}
|
|
409
|
+
if (!info.isFile()) throw invalid(`${unit} must be a regular file for a workspace restore`);
|
|
410
|
+
return readFile(file, "utf8");
|
|
411
|
+
}
|
|
412
|
+
const incoming = async (unit) => {
|
|
413
|
+
const declared = units.find((entry) => entry.path === unit);
|
|
414
|
+
return declared ? regular(root, unit, declared) : void 0;
|
|
415
|
+
};
|
|
416
|
+
const owners = /* @__PURE__ */ new Map();
|
|
417
|
+
let hasLedger = false;
|
|
418
|
+
for (const text of [await incoming(RESULT_LEDGER), await regular(home, RESULT_LEDGER)]) {
|
|
419
|
+
if (text === void 0) continue;
|
|
420
|
+
let value;
|
|
421
|
+
try {
|
|
422
|
+
value = JSON.parse(text);
|
|
423
|
+
} catch {
|
|
424
|
+
throw invalid("assignment result ledger is not readable JSON; repair it before restoring");
|
|
425
|
+
}
|
|
426
|
+
const absolute = (value2) => typeof value2 === "string" && path.isAbsolute(value2);
|
|
427
|
+
if (value?.version !== 1 || !Array.isArray(value.workspaces) || !value.workspaces.every(absolute) || !Array.isArray(value.results) || !value.results.every((entry) => entry && typeof entry.assignmentId === "string" && absolute(entry.checkout) && (entry.worktree === void 0 || absolute(entry.worktree))))
|
|
428
|
+
throw invalid("assignment result ledger has an unsupported or invalid shape");
|
|
429
|
+
hasLedger = true;
|
|
430
|
+
for (const entry of value.results) {
|
|
431
|
+
const owner = { assignmentId: entry.assignmentId, checkout: entry.checkout, ...entry.worktree === void 0 ? {} : { worktree: entry.worktree } };
|
|
432
|
+
const previous = owners.get(owner.assignmentId);
|
|
433
|
+
if (previous && JSON.stringify(previous) !== JSON.stringify(owner)) throw invalid("assignment result ownership conflicts with current data; reconcile it before restoring");
|
|
434
|
+
owners.set(owner.assignmentId, owner);
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
const markers = [await incoming(RESULT_MARKER), await regular(home, RESULT_MARKER)];
|
|
438
|
+
if (markers.some((text) => text !== void 0 && text !== "1\n")) throw invalid("assignment result initialized marker is invalid");
|
|
439
|
+
await mkdir(path.join(root, "state"), { recursive: true, mode: 448 });
|
|
440
|
+
if (hasLedger) await writeAtomic(path.join(root, RESULT_LEDGER), JSON.stringify({ version: 1, workspaces: [], results: [...owners.values()] }) + "\n");
|
|
441
|
+
result.push({ path: RESULT_LEDGER, kind: hasLedger ? "file" : "absent" });
|
|
442
|
+
const initialized = markers.some((text) => text !== void 0);
|
|
443
|
+
if (initialized) await writeAtomic(path.join(root, RESULT_MARKER), "1\n");
|
|
444
|
+
result.push({ path: RESULT_MARKER, kind: initialized ? "file" : "absent" });
|
|
445
|
+
const mappings = await incoming(DESTINATIONS);
|
|
446
|
+
if (mappings !== void 0) {
|
|
447
|
+
await writeAtomic(path.join(root, QUARANTINED_DESTINATIONS), mappings);
|
|
448
|
+
result.push({ path: QUARANTINED_DESTINATIONS, kind: "file" });
|
|
449
|
+
}
|
|
450
|
+
result.push({ path: DESTINATIONS, kind: "absent" });
|
|
451
|
+
const registryUnit = "state/web-workspaces.json";
|
|
452
|
+
const registryDeclaration = units.find((unit) => unit.path === registryUnit);
|
|
453
|
+
const registryText = registryDeclaration ? await incoming(registryUnit) : await regular(home, registryUnit);
|
|
454
|
+
if (registryText !== void 0) {
|
|
455
|
+
let registry;
|
|
456
|
+
try {
|
|
457
|
+
registry = JSON.parse(registryText);
|
|
458
|
+
} catch {
|
|
459
|
+
throw invalid("workspace registry is not readable JSON; repair it before restoring");
|
|
460
|
+
}
|
|
461
|
+
const epoch = `restore-${randomBytes(16).toString("hex")}`;
|
|
462
|
+
let revoked = false;
|
|
463
|
+
const revoke = (value) => {
|
|
464
|
+
if (!value || typeof value !== "object") return;
|
|
465
|
+
if (Object.hasOwn(value, "grantEpoch")) {
|
|
466
|
+
value.grantEpoch = epoch;
|
|
467
|
+
revoked = true;
|
|
468
|
+
}
|
|
469
|
+
for (const child of Object.values(value)) revoke(child);
|
|
470
|
+
};
|
|
471
|
+
revoke(registry);
|
|
472
|
+
await writeAtomic(path.join(root, registryUnit), revoked ? JSON.stringify(registry) + "\n" : registryText);
|
|
473
|
+
result.push({ path: registryUnit, kind: "file" });
|
|
474
|
+
} else if (registryDeclaration) result.push({ path: registryUnit, kind: "absent" });
|
|
475
|
+
if (hasLedger || initialized || mappings !== void 0 || await exists(path.join(home, DESTINATIONS))) warnings.push("Workspace restore retains assignment owners but clears checkout birth certification; old targets require an audited migration. Worktree destination mappings are inactive and require explicit host approval; archived mapping evidence is in state/worktree-destinations.quarantined.json. Historical checkout grants remain revoked after reapproval.");
|
|
476
|
+
return result;
|
|
477
|
+
}
|
|
381
478
|
async function restoreBackup({ home, file, projects = true, safety = true, clearLive = true, replaceNewer = false, by = "alp", beforeSwap }) {
|
|
382
479
|
const interrupted = await recoverInterruptedRestore(home);
|
|
383
480
|
try {
|
|
@@ -392,6 +489,7 @@ async function restoreBackup({ home, file, projects = true, safety = true, clear
|
|
|
392
489
|
const warnings = [];
|
|
393
490
|
const areaBase = (area) => area === "home" ? home : header.projects[Number(area.slice(8))].root;
|
|
394
491
|
const directories = [];
|
|
492
|
+
let homeUnits;
|
|
395
493
|
const journal = { version: 1, id, file: path.resolve(file), by, startedAt: (/* @__PURE__ */ new Date()).toISOString(), phase: "staging", units: [], staging: [], created: [], locks: [] };
|
|
396
494
|
const record = () => writeAtomic(journalFile(home), JSON.stringify(journal) + "\n");
|
|
397
495
|
const abandon = async () => {
|
|
@@ -443,6 +541,7 @@ async function restoreBackup({ home, file, projects = true, safety = true, clear
|
|
|
443
541
|
};
|
|
444
542
|
}
|
|
445
543
|
});
|
|
544
|
+
homeUnits = await prepareWorkspaceRestore(home, staging.get("home"), header.units, warnings);
|
|
446
545
|
for (const [directory, mode] of directories.reverse()) if (Number.isSafeInteger(mode)) await chmod(directory, mode & 511 | 448).catch(() => {
|
|
447
546
|
});
|
|
448
547
|
} catch (error) {
|
|
@@ -463,8 +562,8 @@ async function restoreBackup({ home, file, projects = true, safety = true, clear
|
|
|
463
562
|
const plan = [];
|
|
464
563
|
for (const [area, root] of staging) {
|
|
465
564
|
const base = areaBase(area);
|
|
466
|
-
const units = area === "home" ?
|
|
467
|
-
const known = area === "home" ? HOME_UNITS : PROJECT_UNITS;
|
|
565
|
+
const units = area === "home" ? homeUnits : header.projects[Number(area.slice(8))].units;
|
|
566
|
+
const known = area === "home" ? [...HOME_UNITS, QUARANTINED_DESTINATIONS] : PROJECT_UNITS;
|
|
468
567
|
for (const unit of units) {
|
|
469
568
|
if (!known.includes(unit.path)) {
|
|
470
569
|
warnings.push(`${unit.path} is not a unit this ALP restores; left as it is`);
|
|
@@ -489,7 +588,7 @@ async function restoreBackup({ home, file, projects = true, safety = true, clear
|
|
|
489
588
|
let safetyFile;
|
|
490
589
|
if (safety) {
|
|
491
590
|
try {
|
|
492
|
-
safetyFile = await automaticBackup(home, "pre-restore", { units: header.units.map((unit) => unit.path).filter((unit) => HOME_UNITS.includes(unit)), projects: roots, by });
|
|
591
|
+
safetyFile = await automaticBackup(home, "pre-restore", { units: [.../* @__PURE__ */ new Set([...header.units.map((unit) => unit.path), ...homeUnits.map((unit) => unit.path)])].filter((unit) => HOME_UNITS.includes(unit) || unit === QUARANTINED_DESTINATIONS), projects: roots, by });
|
|
493
592
|
} catch (error) {
|
|
494
593
|
await abandon();
|
|
495
594
|
throw new AlpError("RESTORE_SAFETY_BACKUP_FAILED", `could not save the current data before restoring (${error?.message ?? error}); nothing was changed`, { cause: error });
|
|
@@ -29,6 +29,7 @@ import * as user_settings from "./user-settings.js";
|
|
|
29
29
|
import * as validation from "./validation.js";
|
|
30
30
|
import * as verify from "./verify.js";
|
|
31
31
|
import * as workflow from "./workflow.js";
|
|
32
|
+
import * as user_worktree from "./user-worktree.js";
|
|
32
33
|
export {
|
|
33
34
|
adapter,
|
|
34
35
|
atomic,
|
|
@@ -57,6 +58,7 @@ export {
|
|
|
57
58
|
trust,
|
|
58
59
|
upgrade,
|
|
59
60
|
user_settings,
|
|
61
|
+
user_worktree,
|
|
60
62
|
validation,
|
|
61
63
|
verify,
|
|
62
64
|
workflow
|
|
@@ -78,7 +78,7 @@ async function migrateState(home, { by = "alp", migrations = MIGRATIONS, target
|
|
|
78
78
|
throw new AlpError("STATE_MIGRATION_ROLLBACK_FAILED", `state migration ${step.name} failed (${error?.message ?? error}) and putting the state back from ${backup} failed too (${rollback?.message ?? rollback}); the next start tries the migration again. See docs/alp/data-recovery.md`, { cause: error });
|
|
79
79
|
}
|
|
80
80
|
await rm(migrationFile(home), { force: true });
|
|
81
|
-
throw new AlpError("STATE_MIGRATION_FAILED", `state migration ${step.name} (schema ${step.from} \u2192 ${step.to}) failed: ${error?.message ?? error};
|
|
81
|
+
throw new AlpError("STATE_MIGRATION_FAILED", `state migration ${step.name} (schema ${step.from} \u2192 ${step.to}) failed: ${error?.message ?? error}; data was restored from ${backup} with workspace authority checks. See docs/alp/data-recovery.md`, { cause: error });
|
|
82
82
|
}
|
|
83
83
|
await writeStateSchema(home, step.to, by);
|
|
84
84
|
await rm(migrationFile(home), { force: true });
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
export function repositoryIdentity(directory: any): Promise<{
|
|
2
|
+
gitDir: string;
|
|
3
|
+
commonDir: string;
|
|
4
|
+
branch: string | null;
|
|
5
|
+
}>;
|
|
6
|
+
/** User workspace primitives. Assignment cleanup's force/rm semantics must never be used here. */
|
|
7
|
+
export function userWorktreeGit({ executable, timeoutMs, maxBytes }?: {
|
|
8
|
+
executable?: string | undefined;
|
|
9
|
+
timeoutMs?: number | undefined;
|
|
10
|
+
maxBytes?: number | undefined;
|
|
11
|
+
}): {
|
|
12
|
+
facts: (source: any) => Promise<{
|
|
13
|
+
commonDir: string;
|
|
14
|
+
gitDir: string;
|
|
15
|
+
head: any;
|
|
16
|
+
branch: any;
|
|
17
|
+
}>;
|
|
18
|
+
inspect: (source: any, target: any, branch: any) => Promise<any>;
|
|
19
|
+
assertClean: (target: any) => Promise<void>;
|
|
20
|
+
checkoutFacts(directory: any): Promise<{
|
|
21
|
+
isGit: true;
|
|
22
|
+
repoRoot: any;
|
|
23
|
+
currentBranch: any;
|
|
24
|
+
remoteUrl: any;
|
|
25
|
+
isDirty: boolean;
|
|
26
|
+
}>;
|
|
27
|
+
create(source: any, parent: any, name: any, branch: any, head: any): Promise<string>;
|
|
28
|
+
remove(source: any, target: any, branch: any): Promise<void>;
|
|
29
|
+
};
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
// packages/core/src/user-worktree.js
|
|
2
|
+
import { spawn } from "node:child_process";
|
|
3
|
+
import { lstat, realpath, open } from "node:fs/promises";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { constants } from "node:fs";
|
|
6
|
+
async function control(file) {
|
|
7
|
+
const handle = await open(file, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
8
|
+
try {
|
|
9
|
+
const info = await handle.stat();
|
|
10
|
+
if (!info.isFile() || info.nlink !== 1 || info.size > 4096) throw new Error("Invalid Git administrative file");
|
|
11
|
+
const bytes = Buffer.alloc(4097);
|
|
12
|
+
const { bytesRead } = await handle.read(bytes, 0, bytes.length, 0);
|
|
13
|
+
if (bytesRead > 4096) throw new Error("Git administrative file exceeds limit");
|
|
14
|
+
return bytes.subarray(0, bytesRead).toString("utf8").replace(/\r?\n$/, "");
|
|
15
|
+
} finally {
|
|
16
|
+
await handle.close();
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
async function repositoryIdentity(directory) {
|
|
20
|
+
if (await realpath(directory) !== directory) throw new Error("Repository root is not canonical");
|
|
21
|
+
const entry = path.join(directory, ".git");
|
|
22
|
+
const info = await lstat(entry);
|
|
23
|
+
let gitDir;
|
|
24
|
+
if (info.isDirectory()) gitDir = await realpath(entry);
|
|
25
|
+
else {
|
|
26
|
+
const value = await control(entry);
|
|
27
|
+
if (!value.startsWith("gitdir: ") || /[\r\n\0]/.test(value)) throw new Error("Invalid Git control link");
|
|
28
|
+
gitDir = await realpath(path.resolve(directory, value.slice(8)));
|
|
29
|
+
}
|
|
30
|
+
const commonEntry = path.join(gitDir, "commondir");
|
|
31
|
+
const hasCommon = await lstat(commonEntry).catch((error) => {
|
|
32
|
+
if (error.code === "ENOENT") return void 0;
|
|
33
|
+
throw error;
|
|
34
|
+
});
|
|
35
|
+
const commonDir = hasCommon ? await realpath(path.resolve(gitDir, await control(commonEntry))) : gitDir;
|
|
36
|
+
if (gitDir !== commonDir) {
|
|
37
|
+
if (path.dirname(gitDir) !== path.join(commonDir, "worktrees") || path.resolve(gitDir, await control(path.join(gitDir, "gitdir"))) !== entry)
|
|
38
|
+
throw new Error("Git worktree backlink changed");
|
|
39
|
+
}
|
|
40
|
+
const head = await control(path.join(gitDir, "HEAD"));
|
|
41
|
+
const branch = head.startsWith("ref: refs/heads/") ? head.slice(16) : null;
|
|
42
|
+
if (branch !== null && (!branch || /[\r\n\0]/.test(branch))) throw new Error("Invalid branch reference");
|
|
43
|
+
return { gitDir, commonDir, branch };
|
|
44
|
+
}
|
|
45
|
+
function userWorktreeGit({ executable = "/usr/bin/git", timeoutMs = 15e3, maxBytes = 2 * 1024 * 1024 } = {}) {
|
|
46
|
+
if (process.platform === "win32") throw new Error("User worktree operations are unavailable on this host");
|
|
47
|
+
if (!path.isAbsolute(executable)) throw new Error("Git executable must be host configured and absolute");
|
|
48
|
+
const disabled = [
|
|
49
|
+
"-c",
|
|
50
|
+
"core.hooksPath=/dev/null",
|
|
51
|
+
"-c",
|
|
52
|
+
"core.fsmonitor=false",
|
|
53
|
+
"-c",
|
|
54
|
+
"core.untrackedCache=false",
|
|
55
|
+
"-c",
|
|
56
|
+
"core.pager=cat",
|
|
57
|
+
"-c",
|
|
58
|
+
"maintenance.auto=false",
|
|
59
|
+
"-c",
|
|
60
|
+
"gc.auto=0",
|
|
61
|
+
"-c",
|
|
62
|
+
"protocol.allow=never",
|
|
63
|
+
"-c",
|
|
64
|
+
"submodule.recurse=false"
|
|
65
|
+
];
|
|
66
|
+
const env = {
|
|
67
|
+
PATH: "/usr/bin:/bin",
|
|
68
|
+
GIT_CONFIG_NOSYSTEM: "1",
|
|
69
|
+
GIT_CONFIG_GLOBAL: "/dev/null",
|
|
70
|
+
GIT_TERMINAL_PROMPT: "0",
|
|
71
|
+
GIT_OPTIONAL_LOCKS: "0",
|
|
72
|
+
LANG: "C",
|
|
73
|
+
LC_ALL: "C"
|
|
74
|
+
};
|
|
75
|
+
async function raw(cwd, args, extra = []) {
|
|
76
|
+
return new Promise((resolve, reject) => {
|
|
77
|
+
const child = spawn(executable, [...disabled, ...extra, ...args], {
|
|
78
|
+
cwd,
|
|
79
|
+
env,
|
|
80
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
81
|
+
windowsHide: true,
|
|
82
|
+
detached: true
|
|
83
|
+
});
|
|
84
|
+
let size = 0;
|
|
85
|
+
let overflow = false;
|
|
86
|
+
const stdout = [];
|
|
87
|
+
const stderr = [];
|
|
88
|
+
const kill = () => {
|
|
89
|
+
try {
|
|
90
|
+
process.kill(-child.pid, "SIGKILL");
|
|
91
|
+
} catch {
|
|
92
|
+
child.kill("SIGKILL");
|
|
93
|
+
}
|
|
94
|
+
};
|
|
95
|
+
const timer = setTimeout(() => {
|
|
96
|
+
overflow = true;
|
|
97
|
+
kill();
|
|
98
|
+
}, timeoutMs);
|
|
99
|
+
const collect = (sink) => (chunk) => {
|
|
100
|
+
size += chunk.length;
|
|
101
|
+
if (size > maxBytes) {
|
|
102
|
+
overflow = true;
|
|
103
|
+
kill();
|
|
104
|
+
} else sink.push(chunk);
|
|
105
|
+
};
|
|
106
|
+
child.stdout.on("data", collect(stdout));
|
|
107
|
+
child.stderr.on("data", collect(stderr));
|
|
108
|
+
child.once("error", (error) => {
|
|
109
|
+
clearTimeout(timer);
|
|
110
|
+
reject(error);
|
|
111
|
+
});
|
|
112
|
+
child.once("close", (code) => {
|
|
113
|
+
clearTimeout(timer);
|
|
114
|
+
if (overflow) reject(new Error("Git operation exceeded its host limit"));
|
|
115
|
+
else
|
|
116
|
+
resolve({
|
|
117
|
+
code,
|
|
118
|
+
stdout: Buffer.concat(stdout).toString("utf8"),
|
|
119
|
+
stderr: Buffer.concat(stderr).toString("utf8")
|
|
120
|
+
});
|
|
121
|
+
});
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
async function filterOverrides(cwd) {
|
|
125
|
+
const filters = await raw(cwd, [
|
|
126
|
+
"config",
|
|
127
|
+
"--null",
|
|
128
|
+
"--name-only",
|
|
129
|
+
"--get-regexp",
|
|
130
|
+
"^filter\\..*\\.(smudge|clean|process|required)$"
|
|
131
|
+
]);
|
|
132
|
+
if (filters.code !== 0 && filters.code !== 1) throw new Error("Cannot inspect repository filters");
|
|
133
|
+
const neutral = filters.stdout.split("\0").filter(Boolean).flatMap((key) => ["-c", `${key}=${key.endsWith(".required") ? "false" : ""}`]);
|
|
134
|
+
return neutral;
|
|
135
|
+
}
|
|
136
|
+
async function run(cwd, args, extra = []) {
|
|
137
|
+
return raw(cwd, args, [...await filterOverrides(cwd), ...extra]);
|
|
138
|
+
}
|
|
139
|
+
async function checked(cwd, args, extra = []) {
|
|
140
|
+
const result = await run(cwd, args, extra);
|
|
141
|
+
if (result.code !== 0) throw new Error("Git workspace operation refused");
|
|
142
|
+
return result.stdout.trim();
|
|
143
|
+
}
|
|
144
|
+
async function directory(value) {
|
|
145
|
+
const canonical = await realpath(value);
|
|
146
|
+
if (canonical !== value || !(await lstat(value)).isDirectory())
|
|
147
|
+
throw new Error("Workspace paths must be canonical directories");
|
|
148
|
+
return canonical;
|
|
149
|
+
}
|
|
150
|
+
async function facts(source) {
|
|
151
|
+
await directory(source);
|
|
152
|
+
const top = await checked(source, ["rev-parse", "--show-toplevel"]);
|
|
153
|
+
if (await realpath(top) !== source) throw new Error("Workspace must be a repository top level");
|
|
154
|
+
const commonDir = await realpath(
|
|
155
|
+
await checked(source, ["rev-parse", "--path-format=absolute", "--git-common-dir"])
|
|
156
|
+
);
|
|
157
|
+
const identity = await repositoryIdentity(source);
|
|
158
|
+
if (identity.commonDir !== commonDir) throw new Error("Repository identity changed");
|
|
159
|
+
const head = await checked(source, ["rev-parse", "--verify", "HEAD"]);
|
|
160
|
+
const branch = await run(source, ["symbolic-ref", "--quiet", "--short", "HEAD"]);
|
|
161
|
+
return { commonDir, gitDir: identity.gitDir, head, branch: branch.code === 0 ? branch.stdout.trim() : null };
|
|
162
|
+
}
|
|
163
|
+
async function list(source) {
|
|
164
|
+
const result = await checked(source, ["worktree", "list", "--porcelain", "-z"]);
|
|
165
|
+
return result.split("\0\0").filter(Boolean).map(
|
|
166
|
+
(block) => Object.fromEntries(
|
|
167
|
+
block.split("\0").filter(Boolean).map((line) => {
|
|
168
|
+
const i = line.indexOf(" ");
|
|
169
|
+
return i < 0 ? [line, true] : [line.slice(0, i), line.slice(i + 1)];
|
|
170
|
+
})
|
|
171
|
+
)
|
|
172
|
+
);
|
|
173
|
+
}
|
|
174
|
+
async function inspect(source, target, branch) {
|
|
175
|
+
const row = (await list(source)).find((row2) => row2.worktree === target);
|
|
176
|
+
if (!row) return void 0;
|
|
177
|
+
await directory(target);
|
|
178
|
+
if (row.branch !== `refs/heads/${branch}` || row.locked || row.prunable)
|
|
179
|
+
throw new Error("Managed worktree identity changed");
|
|
180
|
+
const origin = await facts(source);
|
|
181
|
+
const destination = await facts(target);
|
|
182
|
+
if (origin.commonDir !== destination.commonDir) throw new Error("Managed worktree repository changed");
|
|
183
|
+
return row;
|
|
184
|
+
}
|
|
185
|
+
async function assertClean(target) {
|
|
186
|
+
const flags = await checked(target, ["ls-files", "-v", "-z"]);
|
|
187
|
+
if (flags.split("\0").some((row) => row && (row[0] === "S" || /[a-z]/.test(row[0]))))
|
|
188
|
+
throw new Error("Worktree has hidden index state");
|
|
189
|
+
if ((await checked(target, ["ls-files", "--stage", "-z"])).split("\0").some((row) => row.startsWith("160000 ")))
|
|
190
|
+
throw new Error("Worktree has submodules");
|
|
191
|
+
if ((await checked(target, ["status", "--porcelain=v1", "--untracked-files=all", "--ignored"])).length)
|
|
192
|
+
throw new Error("Worktree contains uncommitted or ignored files");
|
|
193
|
+
}
|
|
194
|
+
return {
|
|
195
|
+
facts,
|
|
196
|
+
inspect,
|
|
197
|
+
assertClean,
|
|
198
|
+
async checkoutFacts(directory2) {
|
|
199
|
+
const canonical = await realpath(directory2);
|
|
200
|
+
const repoRoot = await checked(canonical, ["rev-parse", "--show-toplevel"]);
|
|
201
|
+
const branch = await run(canonical, ["symbolic-ref", "--quiet", "--short", "HEAD"]);
|
|
202
|
+
const remote = await run(canonical, ["config", "--get", "remote.origin.url"]);
|
|
203
|
+
const status = await checked(canonical, ["status", "--porcelain=v1", "--untracked-files=all"]);
|
|
204
|
+
return {
|
|
205
|
+
isGit: (
|
|
206
|
+
/** @type {const} */
|
|
207
|
+
true
|
|
208
|
+
),
|
|
209
|
+
repoRoot,
|
|
210
|
+
currentBranch: branch.code === 0 ? branch.stdout.trim() : null,
|
|
211
|
+
remoteUrl: remote.code === 0 ? remote.stdout.trim() || null : null,
|
|
212
|
+
isDirty: !!status
|
|
213
|
+
};
|
|
214
|
+
},
|
|
215
|
+
async create(source, parent, name, branch, head) {
|
|
216
|
+
await directory(parent);
|
|
217
|
+
if (!/^wks_[0-9a-f]{16}$/.test(name) || branch !== `alp/workspace/${name}` || !/^[0-9a-f]{40,64}$/.test(head))
|
|
218
|
+
throw new Error("Invalid generated worktree identity");
|
|
219
|
+
const target = path.join(parent, name);
|
|
220
|
+
if (await lstat(target).catch((error) => {
|
|
221
|
+
if (error.code === "ENOENT") return void 0;
|
|
222
|
+
throw error;
|
|
223
|
+
}))
|
|
224
|
+
throw new Error("Worktree destination already exists");
|
|
225
|
+
await checked(source, ["worktree", "add", "--quiet", "--no-checkout", "-b", branch, target, head]);
|
|
226
|
+
await checked(target, ["reset", "--hard", "--no-recurse-submodules", head]);
|
|
227
|
+
if (!await inspect(source, target, branch)) throw new Error("Worktree creation needs reconciliation");
|
|
228
|
+
return target;
|
|
229
|
+
},
|
|
230
|
+
async remove(source, target, branch) {
|
|
231
|
+
if (!await inspect(source, target, branch)) throw new Error("Managed worktree is missing");
|
|
232
|
+
await assertClean(target);
|
|
233
|
+
await checked(source, ["worktree", "remove", target], await filterOverrides(target));
|
|
234
|
+
if ((await list(source)).some((row) => row.worktree === target))
|
|
235
|
+
throw new Error("Worktree removal needs reconciliation");
|
|
236
|
+
}
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
export {
|
|
240
|
+
repositoryIdentity,
|
|
241
|
+
userWorktreeGit
|
|
242
|
+
};
|
|
@@ -147,8 +147,9 @@ function validateUserSettings(settings, source) {
|
|
|
147
147
|
}
|
|
148
148
|
if (settings.web !== void 0) {
|
|
149
149
|
check(object(settings.web), "web must be an object");
|
|
150
|
-
const extra = Object.keys(settings.web).find((field) => !["enabled", "port", "relay"].includes(field));
|
|
151
|
-
check(!extra, `unsupported web field '${extra}'; use enabled, port or
|
|
150
|
+
const extra = Object.keys(settings.web).find((field) => !["enabled", "port", "relay", "permissions"].includes(field));
|
|
151
|
+
check(!extra, `unsupported web field '${extra}'; use enabled, port, relay or permissions`);
|
|
152
|
+
if (settings.web.permissions !== void 0) check(Array.isArray(settings.web.permissions) && settings.web.permissions.every((entry) => typeof entry === "string" && /^[a-z][a-z.]*$/.test(entry)), "web.permissions must be a list of permission names, such as files.read");
|
|
152
153
|
const relay = settings.web.relay;
|
|
153
154
|
if (relay !== void 0) {
|
|
154
155
|
check(object(relay), "web.relay must be an object");
|