@izagood/avcs 0.29.1 → 0.31.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/dist/api/repo.d.ts +79 -5
- package/dist/api/repo.d.ts.map +1 -1
- package/dist/api/repo.js +254 -26
- package/dist/api/repo.js.map +1 -1
- package/dist/cli.js +162 -32
- package/dist/cli.js.map +1 -1
- package/dist/git/scope.d.ts +38 -0
- package/dist/git/scope.d.ts.map +1 -0
- package/dist/git/scope.js +69 -0
- package/dist/git/scope.js.map +1 -0
- package/dist/mcp/server.js +2 -2
- package/dist/mcp/server.js.map +1 -1
- package/dist/merge/merge3.d.ts +12 -1
- package/dist/merge/merge3.d.ts.map +1 -1
- package/dist/merge/merge3.js +23 -1
- package/dist/merge/merge3.js.map +1 -1
- package/dist/objects/types.d.ts +8 -0
- package/dist/objects/types.d.ts.map +1 -1
- package/dist/reducer/aliases.d.ts +39 -0
- package/dist/reducer/aliases.d.ts.map +1 -0
- package/dist/reducer/aliases.js +112 -0
- package/dist/reducer/aliases.js.map +1 -0
- package/dist/reducer/policy.d.ts +1 -1
- package/dist/reducer/reducer.d.ts +7 -0
- package/dist/reducer/reducer.d.ts.map +1 -1
- package/dist/reducer/reducer.js +161 -19
- package/dist/reducer/reducer.js.map +1 -1
- package/package.json +1 -1
package/dist/api/repo.js
CHANGED
|
@@ -20,8 +20,17 @@ import { defaultPolicy, MATERIALIZER_VERSION } from "../reducer/policy.js";
|
|
|
20
20
|
import { Keyring, generateKeypair, signMessage, } from "../core/identity.js";
|
|
21
21
|
import { checkLease, isActive, scopesOverlap } from "../concurrency/lease.js";
|
|
22
22
|
import { isBinary } from "../core/bytes.js";
|
|
23
|
+
import { lcsLineLength } from "../merge/merge3.js";
|
|
23
24
|
import { Metrics } from "../observe/metrics.js";
|
|
24
25
|
import { silentLogger } from "../observe/logger.js";
|
|
26
|
+
/** The trunk branch assumed when `.avcs/config.json` records none (docs/20 §3.1). */
|
|
27
|
+
export const DEFAULT_TRUNK = "main";
|
|
28
|
+
/**
|
|
29
|
+
* The branch names that count as trunk when none is configured. The pre-trunk bridge
|
|
30
|
+
* special-cased exactly this pair, so keeping both is what makes an unconfigured repository
|
|
31
|
+
* — including a `master`-default one — behave as it always did (docs/20 W7).
|
|
32
|
+
*/
|
|
33
|
+
export const LEGACY_TRUNK_BRANCHES = ["main", "master"];
|
|
25
34
|
// Sidecar: ignore EVERYTHING under .avcs/ (the `*` also ignores this file itself), so the
|
|
26
35
|
// directory contributes nothing to git — the team's git history is untouched by AVCS.
|
|
27
36
|
const GITIGNORE_SIDECAR = `# AVCS — sidecar mode (default).
|
|
@@ -946,6 +955,12 @@ export class Repo {
|
|
|
946
955
|
}
|
|
947
956
|
// required checks — unless an active break-glass Override waives them (Phase 12)
|
|
948
957
|
const cp = await this.store.get(args.newCheckpoint);
|
|
958
|
+
// A workspace-scoped checkpoint (docs/20 §3.3) froze a tree containing ops that have
|
|
959
|
+
// not landed on the base line. Advancing a protected head to it would publish
|
|
960
|
+
// unlanded work under a verified head, so it is refused outright.
|
|
961
|
+
if (cp.workspace) {
|
|
962
|
+
return { finalized: false, reason: `checkpoint ${args.newCheckpoint.slice(0, 16)} is scoped to workspace ${cp.workspace} — land it first (\`avcs workspace land ${cp.workspace}\`)` };
|
|
963
|
+
}
|
|
949
964
|
const waived = await this.#activeWaivers(args.view);
|
|
950
965
|
for (const k of prot?.requiredChecks ?? []) {
|
|
951
966
|
if (waived.has(k))
|
|
@@ -1754,6 +1769,19 @@ export class Repo {
|
|
|
1754
1769
|
async landedWorkspaces() {
|
|
1755
1770
|
return [...(await this.#landedWorkspaces())].sort();
|
|
1756
1771
|
}
|
|
1772
|
+
/**
|
|
1773
|
+
* Every workspace that actually carries operations. A workspace is not a stored object —
|
|
1774
|
+
* it exists exactly as a tag on ops — so this is the only way to ask whether a NAME names
|
|
1775
|
+
* anything. The `post-merge` land seam uses it as a guard: landing is append-only and
|
|
1776
|
+
* irreversible, so a name it cannot corroborate is not landed (docs/20 §3.4, R1).
|
|
1777
|
+
*/
|
|
1778
|
+
async workspaceNames() {
|
|
1779
|
+
const names = new Set();
|
|
1780
|
+
for (const op of await this.#allOpsTailed())
|
|
1781
|
+
if (op.workspace)
|
|
1782
|
+
names.add(op.workspace);
|
|
1783
|
+
return [...names].sort();
|
|
1784
|
+
}
|
|
1757
1785
|
/**
|
|
1758
1786
|
* Land a workspace onto its base line (docs/16): its ops join the base view and merge
|
|
1759
1787
|
* there. There is no "rebase" — reduce always 3-way-merges the full op set, and any
|
|
@@ -1790,17 +1818,22 @@ export class Repo {
|
|
|
1790
1818
|
const allOps = await this.#allOpsTailed();
|
|
1791
1819
|
const inherited = await this.#inheritedOps(lineName, allOps);
|
|
1792
1820
|
const wsName = opts?.workspace;
|
|
1793
|
-
//
|
|
1794
|
-
|
|
1821
|
+
// Landing makes a workspace's ops BASE-ACCEPTED (docs/16 §4.3), so the landed set is
|
|
1822
|
+
// read for EVERY view, not only base ones. A workspace view that ignored it would keep
|
|
1823
|
+
// a sibling's already-landed work invisible and rediscover the conflict at land time
|
|
1824
|
+
// (docs/20 §1.3).
|
|
1825
|
+
const landed = await this.#landedWorkspaces();
|
|
1795
1826
|
const ops = [];
|
|
1796
1827
|
for (const op of allOps) {
|
|
1797
1828
|
const onLine = (op.line ?? "main") === lineName || inherited.has(op.oid);
|
|
1798
1829
|
if (!onLine)
|
|
1799
1830
|
continue;
|
|
1800
|
-
// Workspace isolation (docs/16): a
|
|
1801
|
-
//
|
|
1831
|
+
// Workspace isolation (docs/16): a view excludes a workspace-tagged op unless that
|
|
1832
|
+
// workspace has landed, or it is this view's OWN workspace. With `wsName` undefined
|
|
1833
|
+
// the middle clause is vacuously true, so a BASE view's op set is exactly what it was
|
|
1834
|
+
// — exclude tagged-and-unlanded, keep everything else.
|
|
1802
1835
|
const opWs = op.workspace;
|
|
1803
|
-
if (
|
|
1836
|
+
if (opWs && opWs !== wsName && !landed.has(opWs))
|
|
1804
1837
|
continue;
|
|
1805
1838
|
if (exclude.has(op.oid))
|
|
1806
1839
|
continue;
|
|
@@ -1976,6 +2009,15 @@ export class Repo {
|
|
|
1976
2009
|
// repeats). A clone is returned so callers can mutate without corrupting the cache.
|
|
1977
2010
|
#reduceCache = new Map();
|
|
1978
2011
|
static REDUCE_CACHE_MAX = 64;
|
|
2012
|
+
/**
|
|
2013
|
+
* Minimum line similarity for the capture path to call a removed × added pair a MOVE
|
|
2014
|
+
* rather than an unrelated delete + create (docs/19 §3.1, and §6 R3 asks for exactly one
|
|
2015
|
+
* place to tune it). 0.5 is git's `-M` default, so a tree avcs captures and the same tree
|
|
2016
|
+
* `git diff -M` describes agree about what moved. Raising it makes capture more
|
|
2017
|
+
* conservative (more moves recorded as delete + create, which is the pre-Stage-0
|
|
2018
|
+
* behaviour); lowering it risks attaching a wrong merge base, which is worse than none.
|
|
2019
|
+
*/
|
|
2020
|
+
static RENAME_SIMILARITY = 0.5;
|
|
1979
2021
|
#cloneResult(r) {
|
|
1980
2022
|
return {
|
|
1981
2023
|
tree: new Map(r.tree),
|
|
@@ -2208,6 +2250,102 @@ export class Repo {
|
|
|
2208
2250
|
return false;
|
|
2209
2251
|
return Buffer.from(buf.toString("utf8"), "utf8").equals(buf);
|
|
2210
2252
|
}
|
|
2253
|
+
/**
|
|
2254
|
+
* Pair `removed` paths against `added` ones to recover MOVES from a path-set diff
|
|
2255
|
+
* (docs/19 §3.1, Stage 0).
|
|
2256
|
+
*
|
|
2257
|
+
* `commitWorkingTree` compares path sets, so a move arrives as a removal plus an
|
|
2258
|
+
* unrelated-looking addition — which the reducer then sees as a delete racing an edit,
|
|
2259
|
+
* plus a create with no merge base. Recovering the move here is what makes Stage 1's
|
|
2260
|
+
* commutativity reachable from actual usage.
|
|
2261
|
+
*
|
|
2262
|
+
* Two tiers, both deterministic and language-blind:
|
|
2263
|
+
*
|
|
2264
|
+
* 1. EXACT content match. Content is hashed, so this is an O(n) bucket join and a pure
|
|
2265
|
+
* relocation — the common case, and the whole of the binary case — never pays for a
|
|
2266
|
+
* line diff at all.
|
|
2267
|
+
* 2. LINE SIMILARITY `2·LCS / (linesP + linesQ)` over the leftovers, using merge3's own
|
|
2268
|
+
* LCS. Binary content (a NUL byte) is excluded: a line diff over bytes is noise, so
|
|
2269
|
+
* binary is exact-match-only.
|
|
2270
|
+
*
|
|
2271
|
+
* A pair is only accepted when it is UNAMBIGUOUS in both directions — one candidate for
|
|
2272
|
+
* the source and one for the destination. Anything else stays a delete + create rather
|
|
2273
|
+
* than a guess: naming the wrong file as "the same file" invents history, and a wrong
|
|
2274
|
+
* merge base is worse than no merge base. That is also why there is no tie-breaking to
|
|
2275
|
+
* get wrong; ambiguity is not resolved, it is declined.
|
|
2276
|
+
*/
|
|
2277
|
+
#detectRenames(removed, added, before, after) {
|
|
2278
|
+
if (!removed.length || !added.length)
|
|
2279
|
+
return [];
|
|
2280
|
+
const sources = [...removed].sort();
|
|
2281
|
+
const destinations = [...added].sort();
|
|
2282
|
+
/** Accept only pairs that are the single candidate on BOTH sides. */
|
|
2283
|
+
const unambiguous = (links) => {
|
|
2284
|
+
const inverse = new Map();
|
|
2285
|
+
for (const [from, tos] of links)
|
|
2286
|
+
for (const to of tos)
|
|
2287
|
+
(inverse.get(to) ?? inverse.set(to, new Set()).get(to)).add(from);
|
|
2288
|
+
const out = [];
|
|
2289
|
+
for (const from of [...links.keys()].sort()) {
|
|
2290
|
+
const tos = links.get(from);
|
|
2291
|
+
if (tos.size !== 1)
|
|
2292
|
+
continue;
|
|
2293
|
+
const to = [...tos][0];
|
|
2294
|
+
if (inverse.get(to).size !== 1)
|
|
2295
|
+
continue;
|
|
2296
|
+
out.push({ from, to });
|
|
2297
|
+
}
|
|
2298
|
+
return out;
|
|
2299
|
+
};
|
|
2300
|
+
// ── Tier 1: exact content ──
|
|
2301
|
+
const byContent = new Map();
|
|
2302
|
+
for (const to of destinations) {
|
|
2303
|
+
const h = sha256hex(after.get(to));
|
|
2304
|
+
(byContent.get(h) ?? byContent.set(h, []).get(h)).push(to);
|
|
2305
|
+
}
|
|
2306
|
+
const exact = new Map();
|
|
2307
|
+
for (const from of sources) {
|
|
2308
|
+
const hits = byContent.get(sha256hex(before.get(from)));
|
|
2309
|
+
if (hits)
|
|
2310
|
+
exact.set(from, new Set(hits));
|
|
2311
|
+
}
|
|
2312
|
+
const pairs = unambiguous(exact);
|
|
2313
|
+
// ── Tier 2: line similarity over what tier 1 left ──
|
|
2314
|
+
const takenFrom = new Set(pairs.map((p) => p.from));
|
|
2315
|
+
const takenTo = new Set(pairs.map((p) => p.to));
|
|
2316
|
+
const similar = new Map();
|
|
2317
|
+
const linesCache = new Map();
|
|
2318
|
+
const linesOf = (path, buf) => {
|
|
2319
|
+
if (!linesCache.has(path))
|
|
2320
|
+
linesCache.set(path, isBinary(buf) ? null : buf.toString("utf8").split("\n"));
|
|
2321
|
+
return linesCache.get(path);
|
|
2322
|
+
};
|
|
2323
|
+
for (const from of sources) {
|
|
2324
|
+
if (takenFrom.has(from))
|
|
2325
|
+
continue;
|
|
2326
|
+
const fromLines = linesOf(`-${from}`, before.get(from));
|
|
2327
|
+
if (!fromLines)
|
|
2328
|
+
continue; // binary: exact match only (rule 4)
|
|
2329
|
+
for (const to of destinations) {
|
|
2330
|
+
if (takenTo.has(to))
|
|
2331
|
+
continue;
|
|
2332
|
+
const toLines = linesOf(`+${to}`, after.get(to));
|
|
2333
|
+
if (!toLines)
|
|
2334
|
+
continue;
|
|
2335
|
+
const n = fromLines.length;
|
|
2336
|
+
const m = toLines.length;
|
|
2337
|
+
// Sound pre-filter (docs/19 R4): LCS ≤ min(n, m), so this is an upper bound on the
|
|
2338
|
+
// similarity and can never skip a pair that would have passed. It keeps a large
|
|
2339
|
+
// relocation from running an O(n·m) line DP against every unrelated candidate.
|
|
2340
|
+
if ((2 * Math.min(n, m)) / (n + m) < _a.RENAME_SIMILARITY)
|
|
2341
|
+
continue;
|
|
2342
|
+
if ((2 * lcsLineLength(fromLines, toLines)) / (n + m) < _a.RENAME_SIMILARITY)
|
|
2343
|
+
continue;
|
|
2344
|
+
(similar.get(from) ?? similar.set(from, new Set()).get(from)).add(to);
|
|
2345
|
+
}
|
|
2346
|
+
}
|
|
2347
|
+
return [...pairs, ...unambiguous(similar)].sort((a, b) => (a.from < b.from ? -1 : a.from > b.from ? 1 : 0));
|
|
2348
|
+
}
|
|
2211
2349
|
/**
|
|
2212
2350
|
* Commit a working tree: diff `workDir`'s files against the materialized view and
|
|
2213
2351
|
* author edit_file / put_file / delete_file ops for the changes (the git `add`+`commit`
|
|
@@ -2220,19 +2358,35 @@ export class Repo {
|
|
|
2220
2358
|
* ADDED files keep `put_file` (a create genuinely has no base), and so does any content that
|
|
2221
2359
|
* is not losslessly UTF-8 text (see `#isMergeableText`).
|
|
2222
2360
|
*
|
|
2223
|
-
*
|
|
2224
|
-
*
|
|
2225
|
-
*
|
|
2226
|
-
*
|
|
2361
|
+
* A MOVED file is recovered from the removed × added pair (`#detectRenames`, docs/19 §3.1)
|
|
2362
|
+
* and captured as `rename_file` — plus an `edit_file` at the NEW path, based on the content
|
|
2363
|
+
* from BEFORE the move, when it was edited on the way. Without this the reducer's whole
|
|
2364
|
+
* rename × edit commutativity is unreachable from real usage: a path-set diff turns every
|
|
2365
|
+
* move into a delete racing an edit and a create with nothing to merge against. Recovered
|
|
2366
|
+
* moves are reported in `renamed` and are NOT double-counted in `added`/`removed`.
|
|
2367
|
+
*
|
|
2368
|
+
* `workspace` scopes the capture to a converging workspace (docs/20 §3.3) — the git bridge
|
|
2369
|
+
* maps a topic branch to one. It has to reach BOTH ends: the authored ops carry the tag, and
|
|
2370
|
+
* the projection this diffs against is the WORKSPACE's view. Diffing disk against the base
|
|
2371
|
+
* view instead would re-capture the workspace's own earlier edits as brand-new changes on
|
|
2372
|
+
* every commit, and — since a move is recovered from a removal paired with an addition —
|
|
2373
|
+
* those stale removals could pair into renames that never happened.
|
|
2374
|
+
*
|
|
2375
|
+
* Capture also runs the early conflict warning with CROSS-LINE visibility: a competing
|
|
2376
|
+
* session may be on a different line, and a line-scoped check would never see it. The
|
|
2377
|
+
* warnings are returned (`contention`) so the CLI can put them in front of the human
|
|
2378
|
+
* running `git commit`.
|
|
2227
2379
|
*/
|
|
2228
2380
|
async commitWorkingTree(workDir, opts) {
|
|
2229
2381
|
const view = opts.line ?? "main";
|
|
2230
|
-
const
|
|
2382
|
+
const ws = opts.workspace ? { workspace: opts.workspace } : {};
|
|
2383
|
+
// The base to diff disk against is THIS scope's projection, workspace included.
|
|
2384
|
+
const res = await this.materialize(view, ws);
|
|
2231
2385
|
const current = new Map((await this.materializedBytes(res)).map((f) => [f.path, f.bytes]));
|
|
2232
2386
|
const disk = await this.#readWorkTree(workDir, opts.ignorePredicate);
|
|
2233
|
-
|
|
2387
|
+
let added = [];
|
|
2234
2388
|
const modified = [];
|
|
2235
|
-
|
|
2389
|
+
let removed = [];
|
|
2236
2390
|
for (const [path, content] of disk) {
|
|
2237
2391
|
if (!current.has(path))
|
|
2238
2392
|
added.push(path);
|
|
@@ -2242,10 +2396,19 @@ export class Repo {
|
|
|
2242
2396
|
for (const path of current.keys())
|
|
2243
2397
|
if (!disk.has(path))
|
|
2244
2398
|
removed.push(path);
|
|
2399
|
+
// Recover moves before deciding op kinds: a paired path is a move, not an unrelated
|
|
2400
|
+
// delete + create, and must not be counted as both.
|
|
2401
|
+
const renamed = this.#detectRenames(removed, added, current, disk);
|
|
2402
|
+
if (renamed.length) {
|
|
2403
|
+
const movedFrom = new Set(renamed.map((r) => r.from));
|
|
2404
|
+
const movedTo = new Set(renamed.map((r) => r.to));
|
|
2405
|
+
removed = removed.filter((p) => !movedFrom.has(p));
|
|
2406
|
+
added = added.filter((p) => !movedTo.has(p));
|
|
2407
|
+
}
|
|
2245
2408
|
const ops = [];
|
|
2246
2409
|
const contention = [];
|
|
2247
|
-
if (!added.length && !modified.length && !removed.length)
|
|
2248
|
-
return { ops, added, modified, removed, intent: "", contention };
|
|
2410
|
+
if (!added.length && !modified.length && !removed.length && !renamed.length)
|
|
2411
|
+
return { ops, added, modified, removed, renamed, intent: "", contention };
|
|
2249
2412
|
const intent = await this.createIntent({ title: opts.message, owner: opts.actor.id });
|
|
2250
2413
|
const sess = await this.startSession({ intentOid: intent, actor: opts.actor });
|
|
2251
2414
|
const deps = res.headOps;
|
|
@@ -2260,21 +2423,45 @@ export class Repo {
|
|
|
2260
2423
|
}
|
|
2261
2424
|
};
|
|
2262
2425
|
const warn = { warnContention: true, contentionAcrossLines: true, onContention: collect };
|
|
2426
|
+
// Moves first, sorted by source. The paired `edit_file` must causally FOLLOW its own
|
|
2427
|
+
// rename: it names the destination path, so if the two were concurrent the reducer would
|
|
2428
|
+
// read them as a move and an unrelated edit fighting over that path (docs/19 §3.2 leaves
|
|
2429
|
+
// rename-vs-destination a genuine contest, and rightly so). Depending on the rename also
|
|
2430
|
+
// states the truth — the author moved the file, then wrote to where it now lives.
|
|
2431
|
+
for (const { from, to } of renamed) {
|
|
2432
|
+
const rn = await this.proposeOperation({
|
|
2433
|
+
sessionOid: sess, intentOid: intent, actor: opts.actor,
|
|
2434
|
+
target: { entityKind: "file", entityId: from },
|
|
2435
|
+
body: { kind: "rename_file", fromPath: from, path: to },
|
|
2436
|
+
declaredPurpose: `move ${from} → ${to}`, causalDeps: deps, line: opts.line, ...ws, ...warn,
|
|
2437
|
+
});
|
|
2438
|
+
ops.push(rn);
|
|
2439
|
+
const base = current.get(from);
|
|
2440
|
+
const content = disk.get(to);
|
|
2441
|
+
if (base.equals(content))
|
|
2442
|
+
continue; // a pure move needs no second op
|
|
2443
|
+
// Content changed on the way. Only mergeable text can say so as an edit; otherwise the
|
|
2444
|
+
// move stands and the new bytes go in byte-exact as a `put_file` at the new path.
|
|
2445
|
+
const common = { sessionOid: sess, intentOid: intent, actor: opts.actor, path: to, declaredPurpose: opts.message, causalDeps: [...deps, rn], line: opts.line, ...ws, ...warn };
|
|
2446
|
+
ops.push(this.#isMergeableText(base) && this.#isMergeableText(content)
|
|
2447
|
+
? await this.proposeEdit({ ...common, newText: content.toString("utf8"), baseBlobOid: await this.putBlob(base) })
|
|
2448
|
+
: await this.proposeFileWrite({ ...common, content }));
|
|
2449
|
+
}
|
|
2263
2450
|
const isModified = new Set(modified);
|
|
2264
2451
|
// One sorted pass over both categories keeps op authoring order (and therefore lamport
|
|
2265
2452
|
// assignment) exactly as before; only the op KIND differs per category.
|
|
2266
2453
|
for (const path of [...added, ...modified].sort()) {
|
|
2267
2454
|
const content = disk.get(path);
|
|
2268
2455
|
const base = isModified.has(path) ? current.get(path) : undefined;
|
|
2269
|
-
const common = { sessionOid: sess, intentOid: intent, actor: opts.actor, path, declaredPurpose: opts.message, causalDeps: deps, line: opts.line, ...warn };
|
|
2456
|
+
const common = { sessionOid: sess, intentOid: intent, actor: opts.actor, path, declaredPurpose: opts.message, causalDeps: deps, line: opts.line, ...ws, ...warn };
|
|
2270
2457
|
ops.push(base !== undefined && this.#isMergeableText(base) && this.#isMergeableText(content)
|
|
2271
2458
|
? await this.proposeEdit({ ...common, newText: content.toString("utf8"), baseBlobOid: await this.putBlob(base) })
|
|
2272
2459
|
: await this.proposeFileWrite({ ...common, content }));
|
|
2273
2460
|
}
|
|
2274
2461
|
for (const path of removed.sort()) {
|
|
2275
|
-
ops.push(await this.proposeOperation({ sessionOid: sess, intentOid: intent, actor: opts.actor, target: { entityKind: "file", entityId: path }, body: { kind: "delete_file", path }, declaredPurpose: `delete ${path}`, causalDeps: deps, line: opts.line, ...warn }));
|
|
2462
|
+
ops.push(await this.proposeOperation({ sessionOid: sess, intentOid: intent, actor: opts.actor, target: { entityKind: "file", entityId: path }, body: { kind: "delete_file", path }, declaredPurpose: `delete ${path}`, causalDeps: deps, line: opts.line, ...ws, ...warn }));
|
|
2276
2463
|
}
|
|
2277
|
-
return { ops, added: added.sort(), modified: modified.sort(), removed: removed.sort(), intent, contention };
|
|
2464
|
+
return { ops, added: added.sort(), modified: modified.sort(), removed: removed.sort(), renamed, intent, contention };
|
|
2278
2465
|
}
|
|
2279
2466
|
// ── git bridge (docs/14) ───────────────────────────────────────────────────
|
|
2280
2467
|
/** Read `.avcs/config.json` (a torn/absent file is treated as empty). */
|
|
@@ -2305,6 +2492,32 @@ export class Repo {
|
|
|
2305
2492
|
await this.#writeGitignore(mode);
|
|
2306
2493
|
this.logger.info("git.mode", { mode });
|
|
2307
2494
|
}
|
|
2495
|
+
/**
|
|
2496
|
+
* The git branch that carries the base view (docs/20 §3.1). The core stays git-agnostic:
|
|
2497
|
+
* this is a recorded NAME, and only the bridge ever compares it against a real branch.
|
|
2498
|
+
* Unset ⇒ `main`, which is what the bridge assumed before trunk existed.
|
|
2499
|
+
*/
|
|
2500
|
+
async getTrunk() {
|
|
2501
|
+
const t = (await this.#readConfig()).trunk;
|
|
2502
|
+
return typeof t === "string" && t.length ? t : DEFAULT_TRUNK;
|
|
2503
|
+
}
|
|
2504
|
+
/**
|
|
2505
|
+
* Every branch name that counts as trunk. With `trunk` configured it is the single
|
|
2506
|
+
* answer; with nothing configured BOTH `main` and `master` are trunk — exactly the pair
|
|
2507
|
+
* the pre-trunk bridge special-cased, so an unconfigured repository (a `master`-default
|
|
2508
|
+
* one included) keeps behaving as it always did (docs/20 W7).
|
|
2509
|
+
*/
|
|
2510
|
+
async trunkBranches() {
|
|
2511
|
+
const t = (await this.#readConfig()).trunk;
|
|
2512
|
+
return typeof t === "string" && t.length ? [t] : [...LEGACY_TRUNK_BRANCHES];
|
|
2513
|
+
}
|
|
2514
|
+
/** Record the trunk branch. Shares `config.json` with the git mode, so read-modify-write. */
|
|
2515
|
+
async setTrunk(branch) {
|
|
2516
|
+
const cfg = await this.#readConfig();
|
|
2517
|
+
cfg.trunk = branch;
|
|
2518
|
+
await this.store.writeAux("config.json", JSON.stringify(cfg, null, 2) + "\n");
|
|
2519
|
+
this.logger.info("git.trunk", { trunk: branch });
|
|
2520
|
+
}
|
|
2308
2521
|
/**
|
|
2309
2522
|
* Build the commit-message trailer block that links a git commit to its AVCS provenance
|
|
2310
2523
|
* (the git→avcs half). A reader with the `.avcs/` history can resolve the checkpoint;
|
|
@@ -2427,20 +2640,25 @@ export class Repo {
|
|
|
2427
2640
|
const workDir = opts.workDir ?? this.dir;
|
|
2428
2641
|
const view = opts.line ?? "main";
|
|
2429
2642
|
const lineOpt = opts.line ? { line: opts.line } : {};
|
|
2643
|
+
// A workspace scope (docs/20 §3.3) has to travel the WHOLE round trip: the capture tags
|
|
2644
|
+
// its ops and diffs against the workspace's projection, the conflict gate reads the same
|
|
2645
|
+
// view, and the re-projection writes it back. Any one of them left on base would make
|
|
2646
|
+
// this working tree oscillate between two different trees.
|
|
2647
|
+
const wsOpt = opts.workspace ? { workspace: opts.workspace } : undefined;
|
|
2430
2648
|
// 1. Capture direct working-tree edits as ops before anything else.
|
|
2431
|
-
const cap = await this.commitWorkingTree(workDir, { message: opts.message, actor: opts.actor, ...lineOpt, ...(opts.ignorePredicate ? { ignorePredicate: opts.ignorePredicate } : {}) });
|
|
2432
|
-
const captured = { ops: cap.ops, added: cap.added, modified: cap.modified, removed: cap.removed, intent: cap.intent };
|
|
2649
|
+
const cap = await this.commitWorkingTree(workDir, { message: opts.message, actor: opts.actor, ...lineOpt, ...(wsOpt ?? {}), ...(opts.ignorePredicate ? { ignorePredicate: opts.ignorePredicate } : {}) });
|
|
2650
|
+
const captured = { ops: cap.ops, added: cap.added, modified: cap.modified, removed: cap.removed, renamed: cap.renamed, intent: cap.intent };
|
|
2433
2651
|
// Ensure the gitignore reflects the current mode (pre-existing repos never wrote one).
|
|
2434
2652
|
const mode = await this.getGitMode();
|
|
2435
2653
|
await this.#writeGitignore(mode);
|
|
2436
2654
|
// 2. Conflict gate.
|
|
2437
|
-
const res = await this.materialize(view);
|
|
2655
|
+
const res = await this.materialize(view, wsOpt);
|
|
2438
2656
|
if (res.conflicts.length > 0)
|
|
2439
2657
|
return { mode, captured, contention: cap.contention, conflicts: res.conflicts };
|
|
2440
2658
|
// 3. Checkpoint the verified state. 4. Re-project the working tree.
|
|
2441
|
-
const checkpoint = await this.createCheckpoint(view, opts.message);
|
|
2442
|
-
const written = await this.checkoutInto(workDir, view);
|
|
2443
|
-
this.logger.info("git.sync", { view, mode, capturedOps: captured.ops.length, checkpoint, treeHash: res.treeHash });
|
|
2659
|
+
const checkpoint = await this.createCheckpoint(view, opts.message, wsOpt);
|
|
2660
|
+
const written = await this.checkoutInto(workDir, view, wsOpt);
|
|
2661
|
+
this.logger.info("git.sync", { view, mode, workspace: opts.workspace, capturedOps: captured.ops.length, checkpoint, treeHash: res.treeHash });
|
|
2444
2662
|
return { mode, captured, contention: cap.contention, conflicts: [], checkpoint, treeHash: res.treeHash, reprojected: written.length };
|
|
2445
2663
|
}
|
|
2446
2664
|
// ── backup / transfer (docs/10 WS-F) ──────────────────────────────────────
|
|
@@ -2839,9 +3057,16 @@ export class Repo {
|
|
|
2839
3057
|
await writeFile(full, synth ?? await this.readBlob(blobOid));
|
|
2840
3058
|
}
|
|
2841
3059
|
}
|
|
2842
|
-
|
|
3060
|
+
/**
|
|
3061
|
+
* Freeze a view's verified state. `workspace` freezes that WORKSPACE's projection instead
|
|
3062
|
+
* of the bare base view (docs/20 §3.3): a commit on a topic branch contains the workspace's
|
|
3063
|
+
* tree, so a checkpoint of the base view would describe a tree git does not hold and
|
|
3064
|
+
* `avcs verify-git` would report every such commit as a mismatch. The scope is recorded on
|
|
3065
|
+
* the checkpoint so it can never be mistaken for a base-view one (`finalize` refuses it).
|
|
3066
|
+
*/
|
|
3067
|
+
async createCheckpoint(viewName, summary, opts) {
|
|
2843
3068
|
const view = await this.getView(viewName);
|
|
2844
|
-
const result = await this.materialize(viewName);
|
|
3069
|
+
const result = await this.materialize(viewName, opts?.workspace ? { workspace: opts.workspace } : undefined);
|
|
2845
3070
|
const evidence = {};
|
|
2846
3071
|
const evidenceBinding = {};
|
|
2847
3072
|
// Deterministic aggregation: process evidence in canonical (createdAt, oid) order
|
|
@@ -2878,12 +3103,15 @@ export class Repo {
|
|
|
2878
3103
|
// Only present when some evidence aggregated — an evidence-less checkpoint's
|
|
2879
3104
|
// bytes (and oid) are identical to pre-13.4.
|
|
2880
3105
|
...(Object.keys(evidenceBinding).length ? { evidenceBinding } : {}),
|
|
3106
|
+
...(opts?.workspace ? { workspace: opts.workspace } : {}),
|
|
2881
3107
|
status: result.conflicts.length === 0 ? "verified" : "draft",
|
|
2882
3108
|
summary,
|
|
2883
3109
|
createdAt: new Date().toISOString(),
|
|
2884
3110
|
};
|
|
2885
3111
|
const oid = await this.store.put(cp);
|
|
2886
|
-
|
|
3112
|
+
// `checkpoint:<view>:latest` names the view's own latest state; a workspace checkpoint is
|
|
3113
|
+
// a different tree, so it gets its own ref rather than displacing the base view's.
|
|
3114
|
+
await this.store.setRef(opts?.workspace ? `checkpoint:${viewName}:workspace:${opts.workspace}:latest` : `checkpoint:${viewName}:latest`, oid);
|
|
2887
3115
|
return oid;
|
|
2888
3116
|
}
|
|
2889
3117
|
/** Resolve the materialized tree into {path, bytes} entries (byte-preserving). */
|