@izagood/avcs 0.32.0 → 0.33.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -0
- package/dist/api/repo.d.ts +141 -0
- package/dist/api/repo.d.ts.map +1 -1
- package/dist/api/repo.js +363 -8
- package/dist/api/repo.js.map +1 -1
- package/dist/cli.js +75 -5
- package/dist/cli.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/mcp/server.d.ts.map +1 -1
- package/dist/mcp/server.js +13 -2
- package/dist/mcp/server.js.map +1 -1
- package/package.json +1 -1
package/dist/api/repo.js
CHANGED
|
@@ -5,9 +5,9 @@
|
|
|
5
5
|
// agent workflow: intent → session → propose op → attach evidence → materialize →
|
|
6
6
|
// decide → checkpoint.
|
|
7
7
|
var _a;
|
|
8
|
-
import { mkdir, writeFile, rm, readdir, readFile } from "node:fs/promises";
|
|
8
|
+
import { mkdir, writeFile, rm, readdir, readFile, lstat, readlink, symlink, cp } from "node:fs/promises";
|
|
9
9
|
import { existsSync } from "node:fs";
|
|
10
|
-
import { join, dirname, resolve, relative } from "node:path";
|
|
10
|
+
import { join, dirname, resolve, relative, isAbsolute, sep } from "node:path";
|
|
11
11
|
import { Buffer } from "node:buffer";
|
|
12
12
|
import { ObjectStore } from "../store/objectStore.js";
|
|
13
13
|
import { LamportClock } from "../core/clock.js";
|
|
@@ -52,9 +52,12 @@ const GITIGNORE_COMMITTED = `# AVCS — committed mode.
|
|
|
52
52
|
/oplog
|
|
53
53
|
/objlog
|
|
54
54
|
/pending/
|
|
55
|
+
/shared/
|
|
55
56
|
*.lock
|
|
56
57
|
*.tmp*
|
|
57
58
|
`;
|
|
59
|
+
/** The committed-mode ignore entry for the shared-path cache tree (docs/21 §3.3). */
|
|
60
|
+
const SHARED_IGNORE_LINE = "/shared/";
|
|
58
61
|
export class Repo {
|
|
59
62
|
dir;
|
|
60
63
|
store;
|
|
@@ -2203,7 +2206,18 @@ export class Repo {
|
|
|
2203
2206
|
const root = resolve(workDir);
|
|
2204
2207
|
const out2 = out; // keep the closure below reading naturally
|
|
2205
2208
|
const avcsIgnore = await this.#loadAvcsIgnore(root);
|
|
2206
|
-
|
|
2209
|
+
// Shared paths are folded in HERE, in the core, and not left to the user's `.avcsignore`
|
|
2210
|
+
// (docs/21 §3.5). If listing `node_modules` in an ignore file were the defence, forgetting
|
|
2211
|
+
// to list it would capture 50k files — so contamination is made structurally impossible
|
|
2212
|
+
// instead of merely configurable. Listing a shared path in `.avcsignore` too is harmless.
|
|
2213
|
+
//
|
|
2214
|
+
// A symlinked shared path is already safe without this: the walk below branches on
|
|
2215
|
+
// `Dirent`, whose predicates are lstat-based, so a symlink is neither isDirectory() nor
|
|
2216
|
+
// isFile() and is never entered or read (pinned by docs/21 S8). `mode: "copy"` is the
|
|
2217
|
+
// dangerous one — a real directory the walk CAN descend — and this composition is its
|
|
2218
|
+
// only defence (S8b).
|
|
2219
|
+
const sharedIgnore = await this.#loadSharedIgnore();
|
|
2220
|
+
const ignored = (rel) => avcsIgnore(rel) || sharedIgnore(rel) || (ignorePredicate?.(rel) ?? false);
|
|
2207
2221
|
const walk = async (dir) => {
|
|
2208
2222
|
for (const ent of await readdir(dir, { withFileTypes: true })) {
|
|
2209
2223
|
const rel = relative(root, join(dir, ent.name)).split("\\").join("/");
|
|
@@ -2223,6 +2237,23 @@ export class Repo {
|
|
|
2223
2237
|
await walk(root);
|
|
2224
2238
|
return out;
|
|
2225
2239
|
}
|
|
2240
|
+
/**
|
|
2241
|
+
* Build an ignore predicate from the configured shared paths (docs/21 §3.5).
|
|
2242
|
+
*
|
|
2243
|
+
* A shared path matches itself and everything under it, and nothing else — it is a path
|
|
2244
|
+
* PREFIX rule, deliberately narrower than `.avcsignore`'s basename and `*.ext` matching,
|
|
2245
|
+
* because a shared path names one place in the projection rather than a family of files.
|
|
2246
|
+
*
|
|
2247
|
+
* Unconfigured ⇒ `() => false`, the same object shape `#loadAvcsIgnore` returns for a
|
|
2248
|
+
* missing file, so capture is byte-identical to before this existed (S1).
|
|
2249
|
+
*/
|
|
2250
|
+
async #loadSharedIgnore() {
|
|
2251
|
+
const entries = await this.readSharedPaths();
|
|
2252
|
+
if (!entries.length)
|
|
2253
|
+
return () => false;
|
|
2254
|
+
const paths = entries.map((e) => e.path);
|
|
2255
|
+
return (rel) => paths.some((p) => rel === p || rel.startsWith(p + "/"));
|
|
2256
|
+
}
|
|
2226
2257
|
/**
|
|
2227
2258
|
* Build an ignore predicate from a repo-root `.avcsignore`, kept git-independent so the core
|
|
2228
2259
|
* stays standalone (issue #10). Pragmatic subset of .gitignore: blank/`#` lines ignored; a
|
|
@@ -2247,11 +2278,282 @@ export class Repo {
|
|
|
2247
2278
|
: rel === p || rel.startsWith(p + "/") || base === p);
|
|
2248
2279
|
};
|
|
2249
2280
|
}
|
|
2250
|
-
|
|
2251
|
-
|
|
2281
|
+
// ── shared paths (docs/21) ─────────────────────────────────────────────────
|
|
2282
|
+
// The other half of "ignore". `.avcsignore` says "do not record this as an op";
|
|
2283
|
+
// a shared path says "do not record it AND still have it in the directory". Without
|
|
2284
|
+
// the second half a projected workspace has no dependency tree, so it cannot build,
|
|
2285
|
+
// so real projects keep git worktrees for physical isolation — and docs/16 §2-1
|
|
2286
|
+
// ("물리 격리도 avcs가 제공한다") does not hold where it matters.
|
|
2287
|
+
//
|
|
2288
|
+
// Persisted in `.avcs/shared-paths.json`: an aux file like `remotes.json`, not an
|
|
2289
|
+
// object — where your build cache lives is per-replica configuration, not shared
|
|
2290
|
+
// history, and being under `.avcs/` keeps sidecar mode from exposing it to git.
|
|
2291
|
+
/**
|
|
2292
|
+
* Reject a `path` that could not be resolved under a projection root, or slugged into a
|
|
2293
|
+
* store-local directory name, without ambiguity or escape. Called on WRITE so a bad entry
|
|
2294
|
+
* never reaches the file, and again on USE so a hand-edited file cannot escape either.
|
|
2295
|
+
*/
|
|
2296
|
+
static #assertSharedPath(path) {
|
|
2297
|
+
const bad = !path ||
|
|
2298
|
+
path !== path.trim() ||
|
|
2299
|
+
isAbsolute(path) ||
|
|
2300
|
+
path.startsWith("/") ||
|
|
2301
|
+
/(^|\/)\.\.(\/|$)/.test(path) ||
|
|
2302
|
+
path.split("/").some((seg) => seg === "" || seg === ".") ||
|
|
2303
|
+
path.startsWith(".avcs");
|
|
2304
|
+
if (bad)
|
|
2305
|
+
throw new Error(`shared path must be a relative path inside the projection, with no '..': ${JSON.stringify(path)}`);
|
|
2306
|
+
}
|
|
2307
|
+
/** Normalize one entry: forward slashes, no trailing slash, mode defaulted, keyFrom copied. */
|
|
2308
|
+
static #normalizeSharedEntry(entry) {
|
|
2309
|
+
const path = entry.path.replace(/\\/g, "/").replace(/\/+$/, "");
|
|
2310
|
+
_a.#assertSharedPath(path);
|
|
2311
|
+
const mode = entry.mode === "copy" ? "copy" : "symlink";
|
|
2312
|
+
return { path, keyFrom: [...(entry.keyFrom ?? [])], mode };
|
|
2313
|
+
}
|
|
2314
|
+
/**
|
|
2315
|
+
* The configured shared paths, or `[]` when nothing is configured. A torn/undecodable
|
|
2316
|
+
* file reads as empty, exactly like `remotes.json` and `config.json`: an unreadable
|
|
2317
|
+
* cache configuration must never make a projection fail.
|
|
2318
|
+
*
|
|
2319
|
+
* Reading must not CREATE the file — "no `shared-paths.json`" is the backward-compatible
|
|
2320
|
+
* state (docs/21 S1) and the absence of the file is itself the signal.
|
|
2321
|
+
*/
|
|
2322
|
+
async readSharedPaths() {
|
|
2323
|
+
const raw = await this.store.readAux("shared-paths.json");
|
|
2324
|
+
if (!raw)
|
|
2325
|
+
return [];
|
|
2326
|
+
try {
|
|
2327
|
+
const parsed = JSON.parse(raw.toString("utf8"));
|
|
2328
|
+
if (!Array.isArray(parsed?.shared))
|
|
2329
|
+
return [];
|
|
2330
|
+
const out = [];
|
|
2331
|
+
for (const e of parsed.shared) {
|
|
2332
|
+
if (!e || typeof e.path !== "string")
|
|
2333
|
+
continue;
|
|
2334
|
+
try {
|
|
2335
|
+
out.push(_a.#normalizeSharedEntry(e));
|
|
2336
|
+
}
|
|
2337
|
+
catch { /* a hand-edited escape: drop it, never honour it */ }
|
|
2338
|
+
}
|
|
2339
|
+
return out;
|
|
2340
|
+
}
|
|
2341
|
+
catch {
|
|
2342
|
+
return [];
|
|
2343
|
+
}
|
|
2344
|
+
}
|
|
2345
|
+
/** Replace the shared-path configuration wholesale. */
|
|
2346
|
+
async setSharedPaths(entries) {
|
|
2347
|
+
const shared = entries.map((e) => _a.#normalizeSharedEntry(e));
|
|
2348
|
+
const file = { version: 1, shared };
|
|
2349
|
+
await this.store.writeAux("shared-paths.json", JSON.stringify(file, null, 2) + "\n");
|
|
2350
|
+
this.logger.info("shared.set", { count: shared.length });
|
|
2351
|
+
}
|
|
2352
|
+
/** Add (or replace, by `path`) one shared path. Read-modify-write, like `setTrunk`. */
|
|
2353
|
+
async addSharedPath(entry) {
|
|
2354
|
+
const normalized = _a.#normalizeSharedEntry(entry);
|
|
2355
|
+
const entries = (await this.readSharedPaths()).filter((e) => e.path !== normalized.path);
|
|
2356
|
+
entries.push(normalized);
|
|
2357
|
+
await this.setSharedPaths(entries);
|
|
2358
|
+
}
|
|
2359
|
+
/** Remove one shared path. Returns whether it existed. The CACHE is left alone — that is
|
|
2360
|
+
* `gc --shared`'s call to make, because re-installing is expensive (docs/21 §3.6). */
|
|
2361
|
+
async removeSharedPath(path) {
|
|
2362
|
+
const want = path.replace(/\\/g, "/").replace(/\/+$/, "");
|
|
2363
|
+
const entries = await this.readSharedPaths();
|
|
2364
|
+
const kept = entries.filter((e) => e.path !== want);
|
|
2365
|
+
if (kept.length === entries.length)
|
|
2366
|
+
return false;
|
|
2367
|
+
await this.setSharedPaths(kept);
|
|
2368
|
+
return true;
|
|
2369
|
+
}
|
|
2370
|
+
/**
|
|
2371
|
+
* Derive a cache key from the PROJECTED content of the declared files (docs/21 §3.2):
|
|
2372
|
+
*
|
|
2373
|
+
* key = sha256( canonical( [[path, blobOidOfProjectedContent] for path in sorted(keyFrom)] ) )[:32]
|
|
2374
|
+
*
|
|
2375
|
+
* Projected content, not what is on disk. A tree entry is `path → blobOid`, and a blob
|
|
2376
|
+
* object is `{type,data,encoding}` — content and nothing else — so the oid IS the content
|
|
2377
|
+
* hash. Two workspaces that project the same view therefore get the SAME key by
|
|
2378
|
+
* construction, with no disk read and no clock in the way: determinism buys cache
|
|
2379
|
+
* correctness for free (S15). Conversely a declared file whose content changes moves the
|
|
2380
|
+
* key (S4), and an undeclared file cannot move it however much it changes.
|
|
2381
|
+
*
|
|
2382
|
+
* Pure and static: a key that decides which cache a workspace links to must be checkable
|
|
2383
|
+
* without a store, a projection, or a filesystem.
|
|
2384
|
+
*
|
|
2385
|
+
* - A declared file ABSENT from the view (a lockfile nobody has written yet) participates
|
|
2386
|
+
* as EMPTY content and is reported in `missing` — never silently keyed differently,
|
|
2387
|
+
* which would split the cache and leave nobody able to explain the extra install (S9).
|
|
2388
|
+
* - `keyFrom: []` (or absent) is the named constant `"unkeyed"`: the explicit choice that
|
|
2389
|
+
* every workspace shares one cache. Dangerous, and the user's to make (S10).
|
|
2390
|
+
*/
|
|
2391
|
+
static deriveSharedKey(keyFrom, tree) {
|
|
2392
|
+
const declared = [...new Set(keyFrom ?? [])].sort();
|
|
2393
|
+
if (!declared.length)
|
|
2394
|
+
return { key: "unkeyed", missing: [], unkeyed: true };
|
|
2395
|
+
const missing = [];
|
|
2396
|
+
const entries = declared.map((p) => {
|
|
2397
|
+
const oid = tree.get(p);
|
|
2398
|
+
if (oid === undefined)
|
|
2399
|
+
missing.push(p);
|
|
2400
|
+
return [p, oid ?? ""];
|
|
2401
|
+
});
|
|
2402
|
+
return { key: sha256hex(canonicalize(entries)).slice(0, 32), missing, unkeyed: false };
|
|
2403
|
+
}
|
|
2404
|
+
/**
|
|
2405
|
+
* Keep the cache tree out of git in COMMITTED mode.
|
|
2406
|
+
*
|
|
2407
|
+
* Sidecar mode ignores all of `.avcs/` (its `*` covers this), but committed mode
|
|
2408
|
+
* deliberately TRACKS `.avcs/` except for a named list of rebuildable caches — and a build
|
|
2409
|
+
* environment is emphatically one of those. Without the entry, `git add` would sweep tens
|
|
2410
|
+
* of thousands of dependency files into the history AVCS exists to keep clean.
|
|
2411
|
+
*
|
|
2412
|
+
* A repo that flipped to committed mode before shared paths existed has the older file, so
|
|
2413
|
+
* this repairs it at the moment the cache tree first comes into being. Cheap: it reads the
|
|
2414
|
+
* small ignore file and writes only when the entry is genuinely missing, and it never
|
|
2415
|
+
* touches a sidecar repo (nothing there needs it).
|
|
2416
|
+
*/
|
|
2417
|
+
async #ensureSharedCacheIgnored() {
|
|
2418
|
+
if ((await this.getGitMode()) !== "committed")
|
|
2419
|
+
return;
|
|
2420
|
+
const raw = (await this.store.readAux(".gitignore"))?.toString("utf8") ?? "";
|
|
2421
|
+
if (raw.split(/\r?\n/).some((l) => l.trim() === SHARED_IGNORE_LINE))
|
|
2422
|
+
return;
|
|
2423
|
+
await this.#writeGitignore("committed");
|
|
2424
|
+
}
|
|
2425
|
+
/**
|
|
2426
|
+
* Root of the store-local shared-cache tree (docs/21 §3.3).
|
|
2427
|
+
*
|
|
2428
|
+
* Store-local, not `$HOME`: cleanup is then one `.avcs` away, the home directory stays
|
|
2429
|
+
* clean, and two unrelated projects can never collide on a lock hash. `store.root` already
|
|
2430
|
+
* follows a linked working tree's `.avcs` POINTER file, so a linked worktree shares the
|
|
2431
|
+
* MAIN store's caches for free — which is exactly where sharing across workspaces starts
|
|
2432
|
+
* to pay (docs/21 S12, homomorphic to docs/14's one-store model).
|
|
2433
|
+
*/
|
|
2434
|
+
#sharedRoot() {
|
|
2435
|
+
return join(this.store.root, "shared");
|
|
2436
|
+
}
|
|
2437
|
+
/**
|
|
2438
|
+
* Throw away one cache directory by key (docs/21 R2). The core reports only "non-empty",
|
|
2439
|
+
* so a cache left broken by a half-finished install is not something it can detect — this
|
|
2440
|
+
* is the escape hatch for the caller who can.
|
|
2441
|
+
*/
|
|
2442
|
+
async dropSharedCache(key) {
|
|
2443
|
+
if (!/^[a-z0-9]{1,64}$/.test(key))
|
|
2444
|
+
throw new Error(`not a shared cache key: ${JSON.stringify(key)}`);
|
|
2445
|
+
const dir = join(this.#sharedRoot(), key);
|
|
2446
|
+
if (!existsSync(dir))
|
|
2447
|
+
return false;
|
|
2448
|
+
await rm(dir, { recursive: true, force: true });
|
|
2449
|
+
this.logger.info("shared.cache.dropped", { key });
|
|
2450
|
+
return true;
|
|
2451
|
+
}
|
|
2452
|
+
/**
|
|
2453
|
+
* Connect every configured shared path to its store-local cache (docs/21 §3.4). Runs
|
|
2454
|
+
* AFTER the tree has been written, because writing the tree can create directories.
|
|
2455
|
+
*
|
|
2456
|
+
* What the core does: derive the key, create the cache directory, connect it, and report
|
|
2457
|
+
* `populated`. What the core does NOT do: run an install. It does not know what
|
|
2458
|
+
* `node_modules` is, which package manager owns it, or whether the network is up — and the
|
|
2459
|
+
* moment it did, docs/21 §2 principle 1 would be gone. `populated` is the entire interface
|
|
2460
|
+
* between "the core made a place" and "somebody has to fill it".
|
|
2461
|
+
*
|
|
2462
|
+
* Existing content at a shared path is never destroyed. A real directory there is the
|
|
2463
|
+
* user's data and the core cannot recreate it (it does not know how to install), so it is
|
|
2464
|
+
* left alone with a warning. The one thing that IS re-pointed is a symlink the core itself
|
|
2465
|
+
* put inside this store's own cache tree, which is how a key change (S4) takes effect
|
|
2466
|
+
* instead of leaving the workspace wired to a stale environment.
|
|
2467
|
+
*
|
|
2468
|
+
* With `mode: "copy"`, a directory already at the target counts as materialized and is not
|
|
2469
|
+
* copied over — local edits inside it survive (S11). Re-materializing after a key change
|
|
2470
|
+
* therefore means removing that directory by hand; the core will not delete user data to
|
|
2471
|
+
* refresh a cache.
|
|
2472
|
+
*/
|
|
2473
|
+
async linkSharedPaths(workDir, tree) {
|
|
2474
|
+
const entries = await this.readSharedPaths();
|
|
2475
|
+
if (!entries.length)
|
|
2476
|
+
return []; // S1: unconfigured ⇒ not even a mkdir
|
|
2477
|
+
const root = resolve(workDir);
|
|
2478
|
+
const sharedRoot = resolve(this.#sharedRoot());
|
|
2479
|
+
await this.#ensureSharedCacheIgnored(); // the cache tree is about to exist
|
|
2480
|
+
const out = [];
|
|
2481
|
+
for (const entry of entries) {
|
|
2482
|
+
const mode = entry.mode === "copy" ? "copy" : "symlink";
|
|
2483
|
+
const { key, missing, unkeyed } = _a.deriveSharedKey(entry.keyFrom, tree);
|
|
2484
|
+
// One key may hold several shared paths, so the leaf is the path slugged `/`→`__`.
|
|
2485
|
+
const cache = join(sharedRoot, key, entry.path.split("/").join("__"));
|
|
2486
|
+
const target = join(root, entry.path);
|
|
2487
|
+
const notes = [];
|
|
2488
|
+
if (unkeyed)
|
|
2489
|
+
notes.push("no keyFrom, so every workspace shares this one cache");
|
|
2490
|
+
if (missing.length)
|
|
2491
|
+
notes.push(`keyFrom absent from the view, hashed as empty: ${missing.join(", ")}`);
|
|
2492
|
+
// R4: the LOCK covers only creating the directory, so two concurrent projections cannot
|
|
2493
|
+
// race it. Coordinating concurrent INSTALLS is outside the core's reach by construction
|
|
2494
|
+
// — it does not run them.
|
|
2495
|
+
await this.store.withLock(`shared:${key}`, async () => { await mkdir(cache, { recursive: true }); });
|
|
2496
|
+
const populated = (await readdir(cache)).length > 0;
|
|
2497
|
+
let linked = false;
|
|
2498
|
+
const st = await lstat(target).catch(() => null);
|
|
2499
|
+
if (st?.isSymbolicLink()) {
|
|
2500
|
+
const dest = resolve(dirname(target), await readlink(target));
|
|
2501
|
+
if (dest === cache) {
|
|
2502
|
+
linked = true; // S5: already correct — no-op, and nothing to say about it
|
|
2503
|
+
}
|
|
2504
|
+
else if (dest === sharedRoot || dest.startsWith(sharedRoot + sep)) {
|
|
2505
|
+
await rm(target, { force: true }); // our own cache tree: the key moved (S4)
|
|
2506
|
+
await symlink(cache, target, "dir");
|
|
2507
|
+
linked = true;
|
|
2508
|
+
}
|
|
2509
|
+
else {
|
|
2510
|
+
notes.push(`${entry.path} is a symlink to ${dest}, outside this store's cache — left alone`);
|
|
2511
|
+
}
|
|
2512
|
+
}
|
|
2513
|
+
else if (st) {
|
|
2514
|
+
if (mode === "copy" && st.isDirectory())
|
|
2515
|
+
linked = true; // already materialized (S11)
|
|
2516
|
+
else
|
|
2517
|
+
notes.push(`${entry.path} exists and is not a link to the cache — left alone (your data)`);
|
|
2518
|
+
}
|
|
2519
|
+
else {
|
|
2520
|
+
await mkdir(dirname(target), { recursive: true });
|
|
2521
|
+
if (mode === "copy")
|
|
2522
|
+
await cp(cache, target, { recursive: true });
|
|
2523
|
+
else
|
|
2524
|
+
await symlink(cache, target, "dir");
|
|
2525
|
+
linked = true;
|
|
2526
|
+
}
|
|
2527
|
+
const link = { path: entry.path, key, cache, target, mode, linked, populated };
|
|
2528
|
+
if (notes.length)
|
|
2529
|
+
link.warning = notes.join("; ");
|
|
2530
|
+
if (link.warning)
|
|
2531
|
+
this.logger.warn("shared.link", { path: entry.path, key, warning: link.warning });
|
|
2532
|
+
out.push(link);
|
|
2533
|
+
}
|
|
2534
|
+
return out;
|
|
2535
|
+
}
|
|
2536
|
+
/**
|
|
2537
|
+
* Project a view into `workDir` AND connect its shared paths (docs/21 §3.4) — the full
|
|
2538
|
+
* physical checkout `avcs workspace project` performs. `checkoutInto` is this without the
|
|
2539
|
+
* shared report, kept as-is for every existing caller.
|
|
2540
|
+
*
|
|
2541
|
+
* `skipped` are tree entries that live INSIDE a shared path. Normally there are none —
|
|
2542
|
+
* capture cannot produce them (§3.5) — but a history contaminated before shared paths
|
|
2543
|
+
* existed can still be opened, and writing those files would spill recorded content over a
|
|
2544
|
+
* live build environment. So they are skipped and named rather than written.
|
|
2545
|
+
*/
|
|
2546
|
+
async projectInto(workDir, view = "main", opts) {
|
|
2252
2547
|
const res = await this.materialize(view, opts?.workspace ? { workspace: opts.workspace } : undefined);
|
|
2548
|
+
const shared = await this.readSharedPaths();
|
|
2549
|
+
const inShared = (rel) => shared.some((e) => rel === e.path || rel.startsWith(e.path + "/"));
|
|
2253
2550
|
const written = [];
|
|
2551
|
+
const skipped = [];
|
|
2254
2552
|
for (const [path, blobOid] of res.tree) {
|
|
2553
|
+
if (shared.length && inShared(path)) {
|
|
2554
|
+
skipped.push(path);
|
|
2555
|
+
continue;
|
|
2556
|
+
}
|
|
2255
2557
|
const full = join(workDir, path);
|
|
2256
2558
|
const synth = res.synthBlobs.get(blobOid);
|
|
2257
2559
|
const want = synth ?? (await this.readBlob(blobOid));
|
|
@@ -2273,7 +2575,13 @@ export class Repo {
|
|
|
2273
2575
|
}
|
|
2274
2576
|
written.push(path);
|
|
2275
2577
|
}
|
|
2276
|
-
|
|
2578
|
+
if (skipped.length)
|
|
2579
|
+
this.logger.warn("shared.projection.skipped", { count: skipped.length, sample: skipped.slice(0, 5) });
|
|
2580
|
+
return { written: written.sort(), shared: await this.linkSharedPaths(workDir, res.tree), skipped: skipped.sort() };
|
|
2581
|
+
}
|
|
2582
|
+
/** Write a view's materialized files into `workDir` (alongside .avcs, like git). */
|
|
2583
|
+
async checkoutInto(workDir, view = "main", opts) {
|
|
2584
|
+
return (await this.projectInto(workDir, view, opts)).written;
|
|
2277
2585
|
}
|
|
2278
2586
|
/**
|
|
2279
2587
|
* Whether `buf` may travel the `edit_file` (text 3-way merge) path. `proposeEdit` takes
|
|
@@ -2802,6 +3110,10 @@ export class Repo {
|
|
|
2802
3110
|
* past `quarantineTtlMs`, that nothing else builds on — the one place append-only
|
|
2803
3111
|
* yields (abandoned/spam contributions, docs/09 G5).
|
|
2804
3112
|
* `dryRun` reports without deleting.
|
|
3113
|
+
*
|
|
3114
|
+
* `shared` opts IN to collecting shared-path caches (docs/21 §3.6). Plain `gc` never
|
|
3115
|
+
* touches them: re-installing a build environment is expensive, so the routine reclaim of
|
|
3116
|
+
* orphan blobs must not be able to cost somebody an install.
|
|
2805
3117
|
*/
|
|
2806
3118
|
async gc(opts = {}) {
|
|
2807
3119
|
const ops = await this.store.collect("operation");
|
|
@@ -2855,8 +3167,51 @@ export class Repo {
|
|
|
2855
3167
|
for (const oid of blobs)
|
|
2856
3168
|
this.#blobCache.delete(oid);
|
|
2857
3169
|
}
|
|
2858
|
-
|
|
2859
|
-
|
|
3170
|
+
const sharedKeys = opts.shared ? await this.#collectSharedCaches(opts.dryRun ?? false) : [];
|
|
3171
|
+
this.logger.info("gc", { dryRun: opts.dryRun ?? false, blobs: blobs.length, quarantinedOps: quarantinedOps.length, sharedKeys: sharedKeys.length });
|
|
3172
|
+
return { blobs, quarantinedOps, sharedKeys };
|
|
3173
|
+
}
|
|
3174
|
+
/**
|
|
3175
|
+
* Every shared cache key that some current scope still derives (docs/21 §3.6).
|
|
3176
|
+
*
|
|
3177
|
+
* "Some current scope" is deliberately generous — the base view, every line, and every one
|
|
3178
|
+
* of those crossed with every known workspace. Deriving a key extra times only costs a
|
|
3179
|
+
* reduce; failing to derive one costs a re-install, so over-approximating live keys is the
|
|
3180
|
+
* cheap mistake and the one to make.
|
|
3181
|
+
*/
|
|
3182
|
+
async #derivedSharedKeys() {
|
|
3183
|
+
const entries = await this.readSharedPaths();
|
|
3184
|
+
const keys = new Set();
|
|
3185
|
+
if (!entries.length)
|
|
3186
|
+
return keys;
|
|
3187
|
+
const views = ["main", ...(await this.listLines()).map((l) => l.name).filter((n) => n !== "main")];
|
|
3188
|
+
const workspaces = [undefined, ...(await this.workspaceNames())];
|
|
3189
|
+
for (const view of views) {
|
|
3190
|
+
for (const ws of workspaces) {
|
|
3191
|
+
const res = await this.materialize(view, ws ? { workspace: ws } : undefined);
|
|
3192
|
+
for (const e of entries)
|
|
3193
|
+
keys.add(_a.deriveSharedKey(e.keyFrom, res.tree).key);
|
|
3194
|
+
}
|
|
3195
|
+
}
|
|
3196
|
+
return keys;
|
|
3197
|
+
}
|
|
3198
|
+
/** Delete the shared caches no scope derives any more. Returns the keys (sorted). */
|
|
3199
|
+
async #collectSharedCaches(dryRun) {
|
|
3200
|
+
const root = this.#sharedRoot();
|
|
3201
|
+
if (!existsSync(root))
|
|
3202
|
+
return [];
|
|
3203
|
+
const live = await this.#derivedSharedKeys();
|
|
3204
|
+
const dead = [];
|
|
3205
|
+
for (const ent of await readdir(root, { withFileTypes: true })) {
|
|
3206
|
+
if (!ent.isDirectory() || live.has(ent.name))
|
|
3207
|
+
continue;
|
|
3208
|
+
dead.push(ent.name);
|
|
3209
|
+
}
|
|
3210
|
+
dead.sort();
|
|
3211
|
+
if (!dryRun)
|
|
3212
|
+
for (const key of dead)
|
|
3213
|
+
await rm(join(root, key), { recursive: true, force: true });
|
|
3214
|
+
return dead;
|
|
2860
3215
|
}
|
|
2861
3216
|
/**
|
|
2862
3217
|
* Materialize the state AT a given frontier: reduce only the causal closure of
|