amalgm 0.1.262 → 0.1.263

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.
@@ -35,6 +35,24 @@ function createRepoFollowers({ observer, registry, repoStates, recordForRoot, on
35
35
  const followers = new Map(); // observer rootId -> { path, uuid, follower }
36
36
  let stopped = false;
37
37
 
38
+ /**
39
+ * A scoped capture reuses an unsuspected untracked entry WITHOUT its
40
+ * bytes. The persisted state must stay whole — transport cargo packs
41
+ * untracked content straight from it — so the bytes are carried forward
42
+ * from the previous settled state: a memory copy, never a disk read.
43
+ * The transient `reused` flag never persists.
44
+ */
45
+ function hydrateReusedUntracked(next, previous) {
46
+ const entries = next?.checkpoint?.untracked;
47
+ if (!entries?.some((file) => file.reused)) return;
48
+ const prior = new Map((previous?.checkpoint?.untracked ?? []).map((file) => [file.path, file]));
49
+ next.checkpoint.untracked = entries.map((file) => {
50
+ if (!file.reused) return file;
51
+ const { reused, ...kept } = file;
52
+ return { ...kept, content: prior.get(file.path)?.content ?? null };
53
+ });
54
+ }
55
+
38
56
  function close(rootId) {
39
57
  const entry = followers.get(rootId);
40
58
  if (!entry) return;
@@ -74,14 +92,32 @@ function createRepoFollowers({ observer, registry, repoStates, recordForRoot, on
74
92
  // The service can turn a full capture into immutable cloud cargo
75
93
  // before the one stateId attestation commits. Local-only callers
76
94
  // retain the lightweight bundle-free capture used historically.
77
- captureFn: async (repoPath) => {
78
- const captured = await worker.capture(repoPath, { withBundle: typeof prepareRepo === 'function' });
79
- return typeof prepareRepo === 'function' && !captured?.busy ? prepareRepo(captured) : captured;
95
+ // The repo UUID rides along so the transport packs the current
96
+ // worktree identity map from the registry.
97
+ captureFn: async (repoPath, { suspects } = {}) => {
98
+ const withBundle = typeof prepareRepo === 'function';
99
+ // The warm checkpoint: the last settled state's untracked entries
100
+ // ((path, mode, sha256) — content is never persisted) let a scoped
101
+ // capture reuse every unsuspected file without opening it. Cloud
102
+ // cargo captures (withBundle) need content and read everything;
103
+ // capture() enforces that side of the contract itself.
104
+ const previous = withBundle ? null : repoStates.get(record.uuid);
105
+ const captured = await worker.capture(repoPath, {
106
+ withBundle,
107
+ suspects,
108
+ previousUntracked: previous?.checkpoint?.untracked?.map(
109
+ ({ path: rel, mode, sha256 }) => ({ path: rel, mode, sha256 }),
110
+ ) ?? null,
111
+ });
112
+ return withBundle && !captured?.busy
113
+ ? prepareRepo(captured, { entityUuid: record.uuid })
114
+ : captured;
80
115
  },
81
116
  emit: (next) => {
82
117
  // ONE settled Card + Checkpoint state is ONE registry transition on
83
118
  // the repo entity. Raw filesystem events never write Git state.
84
119
  const previous = repoStates.get(record.uuid);
120
+ hydrateReusedUntracked(next, previous);
85
121
  registry.transaction(() => {
86
122
  repoStates.put(record.uuid, next);
87
123
  registry.attest(record.uuid, { type: 'repo.git', payloadVersion: next.stateId });
@@ -115,7 +151,10 @@ function createRepoFollowers({ observer, registry, repoStates, recordForRoot, on
115
151
  sync();
116
152
  return;
117
153
  }
118
- if (event.type === 'repo.changed') followers.get(event.rootId)?.follower.ring();
154
+ // The ding's hint (the named path, rebased to the repo's ground, or
155
+ // null for "anywhere") rides into the follower's scope: it is what
156
+ // lets the next capture patch one file instead of re-reading the world.
157
+ if (event.type === 'repo.changed') followers.get(event.rootId)?.follower.ring(event.hint ?? null);
119
158
  }
120
159
 
121
160
  function stop() {
@@ -28,13 +28,18 @@ function decodeBytes(encoded) {
28
28
  /** The durable local state deliberately omits a Git bundle. Bundles are
29
29
  * transport cargo: keeping them in identity.db would turn a small registry
30
30
  * into a second, unbounded object store. The immutable content store owns
31
- * the bundle bytes; SQLite retains only the content head that names them. */
32
- function pack(state, { includeBundle = true, transportVersion = state.transportVersion ?? null } = {}) {
31
+ * the bundle bytes; SQLite retains only the content head that names them.
32
+ * The identity map is transport cargo too: it names the repo's enrolled
33
+ * worktree entities so a receiving machine binds the SAME child UUIDs
34
+ * instead of minting competitors — derived from the registry whenever a
35
+ * transport is packed, never persisted beside the local state. */
36
+ function pack(state, { includeBundle = true, transportVersion = state.transportVersion ?? null, identity = null } = {}) {
33
37
  return JSON.stringify({
34
38
  stateId: state.stateId,
35
39
  cardId: state.cardId,
36
40
  checkpointId: state.checkpointId,
37
41
  transportVersion,
42
+ ...(identity ? { identity } : {}),
38
43
  card: {
39
44
  ...state.card,
40
45
  bundle: includeBundle && state.card.bundle ? {
@@ -59,6 +64,7 @@ function unpack(text) {
59
64
  return {
60
65
  ...state,
61
66
  transportVersion: typeof state.transportVersion === 'string' ? state.transportVersion : null,
67
+ identity: Array.isArray(state.identity) ? state.identity : null,
62
68
  card: {
63
69
  ...state.card,
64
70
  bundle: state.card.bundle && {
@@ -81,15 +87,19 @@ function unpack(text) {
81
87
  /** A Repocard transport document contains its bundle and checkpoint bytes,
82
88
  * but never names itself: its SHA-256 is the immutable cloud content head.
83
89
  * The semantic `stateId` remains on the repo entity, so bundle packing can
84
- * vary without changing repository identity. */
85
- function transportBytes(state) {
86
- return Buffer.from(pack(state, { includeBundle: true, transportVersion: null }), 'utf8');
90
+ * vary without changing repository identity. `identity` is the enrolled
91
+ * worktree's path → { uuid, type } entries: repo children never travel as
92
+ * individual cloud records, so their permanent UUIDs ride here — inside
93
+ * the one state parcel — and `amalgm add` binds them from this map. */
94
+ function transportBytes(state, identity = null) {
95
+ return Buffer.from(pack(state, { includeBundle: true, transportVersion: null, identity }), 'utf8');
87
96
  }
88
97
 
89
98
  function localState(state, transportVersion) {
90
99
  return {
91
100
  ...state,
92
101
  transportVersion,
102
+ identity: null,
93
103
  card: { ...state.card, bundle: null },
94
104
  };
95
105
  }
@@ -52,6 +52,7 @@ const {
52
52
  migrateLegacyToolboxCatalog,
53
53
  } = require('../toolbox/artifacts');
54
54
  const { createObserver } = require('../observer');
55
+ const { gitDirTruth } = require('../observer/scan');
55
56
  const { shouldEnroll } = require('../workspace/tree/inclusion');
56
57
  const { classifyFile } = require('./classify');
57
58
  const { createEntityCloud } = require('./entity-cloud');
@@ -106,13 +107,37 @@ function createRegistrationService({
106
107
  const repoTransportHeads = new Map(); // semantic stateId -> immutable content hash
107
108
  const importedRepoStates = new Map(); // cloud UUID -> verified bundle-free state
108
109
 
110
+ /** The identity map a settled repo state travels with: every enrolled
111
+ * record beneath the repo boundary (stopping at nested repo boundaries,
112
+ * which pack their own maps). Repo children never journal their own
113
+ * cloud mutations, so this map — inside the one transport parcel — is
114
+ * the only way their permanent UUIDs reach another machine. */
115
+ function worktreeIdentity(repoUuid) {
116
+ const registry = boundary.current?.registry;
117
+ if (!registry) return null;
118
+ const entries = [];
119
+ const visit = (parentUuid, base) => {
120
+ for (const child of registry.children(parentUuid)) {
121
+ if (child.status !== 'active') continue;
122
+ const childPath = base ? `${base}/${child.name}` : child.name;
123
+ entries.push({ path: childPath, uuid: child.uuid, type: child.type });
124
+ if (child.type === 'folder') visit(child.uuid, childPath);
125
+ }
126
+ };
127
+ visit(repoUuid, '');
128
+ return entries;
129
+ }
130
+
109
131
  /** Git transport is immutable content, not identity-database cargo. The
110
132
  * semantic repo stateId remains the entity head; this prepares the exact
111
133
  * Card + Checkpoint + bundle payload and leaves only its content hash in
112
- * SQLite so a large clone cannot bloat the device registry. */
113
- function prepareRepo(captured) {
134
+ * SQLite so a large clone cannot bloat the device registry. Registration
135
+ * supplies the freshly assigned identity entries; a settled follower
136
+ * names its repo UUID instead and the map is read from the registry. */
137
+ function prepareRepo(captured, { identity = null, entityUuid = null } = {}) {
114
138
  if (captured?.busy) return captured;
115
- const artifact = entityContent.captureBytes({ bytes: transportBytes(captured) });
139
+ const transportIdentity = identity ?? (entityUuid ? worktreeIdentity(entityUuid) : null);
140
+ const artifact = entityContent.captureBytes({ bytes: transportBytes(captured, transportIdentity) });
116
141
  repoTransportHeads.set(captured.stateId, artifact.contentHash);
117
142
  return localState(captured, artifact.contentHash);
118
143
  }
@@ -148,7 +173,7 @@ function createRegistrationService({
148
173
  db: identityDb,
149
174
  sentinelPath: path.join(dir, 'identity.epoch'),
150
175
  journal: entityCloud.journal,
151
- captureRepo: (repoPath) => prepareRepo(capture(repoPath, { withBundle: true })),
176
+ captureRepo: (repoPath, options) => prepareRepo(capture(repoPath, { withBundle: true }), options),
152
177
  prepareRepo,
153
178
  repoStateAt: (uuid) => importedRepoStates.get(uuid) || null,
154
179
  }),
@@ -158,10 +183,12 @@ function createRegistrationService({
158
183
 
159
184
  let closed = false;
160
185
 
161
- /** Count only the same eligible shape that registration will see. This is
186
+ /** Count only the records that will actually TRAVEL to the cloud. This is
162
187
  * not a second registration walker: it is a bounded routing decision that
163
188
  * lets a large tree use one atomic cloud graph replacement instead of
164
- * thousands of individually compacted mutations. */
189
+ * thousands of individually compacted mutations. A repository counts as
190
+ * ONE — its children ride the transport parcel, never the cloud graph —
191
+ * so registering even a 40,000-file repo is a single ordinary mutation. */
165
192
  function estimatedRegistrationRecords(targetPath, limit = ATOMIC_REGISTRATION_THRESHOLD) {
166
193
  let total = 0;
167
194
  const visit = (candidate) => {
@@ -170,6 +197,7 @@ function createRegistrationService({
170
197
  let stat;
171
198
  try { stat = fs.lstatSync(candidate); } catch { return; }
172
199
  if (!stat.isDirectory() || stat.isSymbolicLink()) return;
200
+ if (gitDirTruth(candidate) === 'present') return;
173
201
  let entries;
174
202
  try { entries = fs.readdirSync(candidate, { withFileTypes: true }); } catch { return; }
175
203
  for (const entry of entries) {
@@ -482,15 +510,50 @@ function createRegistrationService({
482
510
  group.push(record);
483
511
  children.set(record.parentUUID, group);
484
512
  }
513
+ // Unpacking a transport decodes the whole bundle from base64; one unpack
514
+ // per repository, not one per caller.
515
+ const repoStatesByUuid = new Map();
485
516
  function repoState(record) {
517
+ const cached = repoStatesByUuid.get(record.uuid);
518
+ if (cached) return cached;
486
519
  if (!record.transportVersion) throw new Error(`cloud repository ${record.uuid} has no Repocard transport head`);
487
520
  const state = unpack(entityContent.bytes({ contentHash: record.transportVersion }));
488
521
  if (state.stateId !== record.payloadVersion) {
489
522
  throw new Error(`cloud repository ${record.uuid} transport does not name its declared state`);
490
523
  }
524
+ repoStatesByUuid.set(record.uuid, state);
491
525
  return state;
492
526
  }
493
527
 
528
+ /** The identity a repo's transport carries for one worktree address:
529
+ * the same permanent UUID the registering machine minted, synthesized
530
+ * into the record shape the structural walk verifies. Records under a
531
+ * repository no longer live in the cloud catalog — the map inside the
532
+ * one state parcel is their identity carrier. */
533
+ function repoChildIdentity(repoRecord, repoRelativePath) {
534
+ const state = repoState(repoRecord);
535
+ if (!Array.isArray(state.identity)) return null;
536
+ const byPath = state.identityByPath
537
+ || (state.identityByPath = new Map(state.identity.map((entry) => [entry.path, entry])));
538
+ const entry = byPath.get(repoRelativePath);
539
+ if (!entry) return null;
540
+ // A nested repository does not travel (the repo travel law), so the
541
+ // transport cannot promise this ground materializes as a repo here.
542
+ // The receiving walk decides what the ground actually is and mints
543
+ // fresh local identity for it.
544
+ if (entry.type === 'repo.git') return null;
545
+ const slash = repoRelativePath.lastIndexOf('/');
546
+ const parentEntry = slash === -1 ? null : byPath.get(repoRelativePath.slice(0, slash));
547
+ return {
548
+ uuid: entry.uuid,
549
+ type: entry.type,
550
+ parentUUID: parentEntry ? parentEntry.uuid : repoRecord.uuid,
551
+ name: repoRelativePath.slice(slash + 1),
552
+ status: 'active',
553
+ payloadVersion: null,
554
+ };
555
+ }
556
+
494
557
  for (const { contentHash } of entityCloud.contentHeads(active)) {
495
558
  if (!entityContent.complete({ contentHash })) return false;
496
559
  }
@@ -535,9 +598,21 @@ function createRegistrationService({
535
598
  function identityAt(relativePath) {
536
599
  if (!relativePath) return root;
537
600
  let cursor = root;
538
- for (const name of relativePath.split('/')) {
539
- cursor = (children.get(cursor.uuid) || []).find((record) => record.name === name) || null;
540
- if (!cursor) return null;
601
+ const parts = relativePath.split('/');
602
+ for (let index = 0; index < parts.length; index += 1) {
603
+ const next = (children.get(cursor.uuid) || []).find((record) => record.name === parts[index]) || null;
604
+ if (next) {
605
+ cursor = next;
606
+ continue;
607
+ }
608
+ // Inside a repository boundary the catalog goes quiet: the rest of
609
+ // the address is answered by that repo's transport identity map.
610
+ // (A snapshot from before the repo travel law still carries child
611
+ // records; those keep answering above, exactly as they used to.)
612
+ if (cursor.type === 'repo.git') {
613
+ return repoChildIdentity(records.get(cursor.uuid) || cursor, parts.slice(index).join('/'));
614
+ }
615
+ return null;
541
616
  }
542
617
  return records.get(cursor.uuid) || null;
543
618
  }
@@ -621,12 +696,18 @@ function createRegistrationService({
621
696
  return crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex');
622
697
  }
623
698
 
699
+ // One unpack per repository: the live projection consults the same
700
+ // state for its drift check, its registry preload, and its apply.
701
+ const repoStatesByUuid = new Map();
624
702
  function repoState(record) {
703
+ const cached = repoStatesByUuid.get(record.uuid);
704
+ if (cached) return cached;
625
705
  if (!record.transportVersion) throw new Error(`cloud repository ${record.uuid} has no Repocard transport head`);
626
706
  const state = unpack(entityContent.bytes({ contentHash: record.transportVersion }));
627
707
  if (state.stateId !== record.payloadVersion) {
628
708
  throw new Error(`cloud repository ${record.uuid} transport does not name its declared state`);
629
709
  }
710
+ repoStatesByUuid.set(record.uuid, state);
630
711
  return state;
631
712
  }
632
713
 
@@ -908,9 +989,15 @@ function createRegistrationService({
908
989
  estimatedRegistrationRecords(targetPath) >= ATOMIC_REGISTRATION_THRESHOLD
909
990
  ));
910
991
  if (!needsAtomicReplacement) return boundary.current.addMany(targetPaths);
911
- const results = boundary.current.registry.withoutJournal(() => boundary.current.addMany(targetPaths));
912
- entityCloud.replaceSnapshot(boundary.current.registry.syncRecords());
913
- return results;
992
+ // The snapshot must describe whatever actually committed, even when a
993
+ // later path refuses (each path is durable alone): without journal
994
+ // rows, a committed-but-unsnapshotted registration would be local
995
+ // truth with zero cloud delivery intent — a ghost nothing repairs.
996
+ try {
997
+ return boundary.current.registry.withoutJournal(() => boundary.current.addMany(targetPaths));
998
+ } finally {
999
+ entityCloud.replaceSnapshot(boundary.current.registry.syncRecords());
1000
+ }
914
1001
  },
915
1002
  /** `amalgm add <cloud-root-uuid> [parent-directory]` — cloud to local. */
916
1003
  add: async ({ uuid, targetPath }) => {
@@ -44,6 +44,57 @@ function isRepository(dirPath) {
44
44
  return gitDirTruth(dirPath) === 'present';
45
45
  }
46
46
 
47
+ /**
48
+ * Assign every enrolled worktree entity its permanent UUID BEFORE the
49
+ * repo's transport is packed, so the transport's identity map and the
50
+ * records this walk then creates name the exact same UUIDs. Repo children
51
+ * never travel as individual cloud records (the repo travel law); this
52
+ * map, riding inside the one state parcel, is how a receiving machine
53
+ * binds the same child identities instead of minting competitors.
54
+ *
55
+ * The walk mirrors registerDirectory's classification exactly — same
56
+ * readdir order, same enrollment gate, same lstat shape — and stops at a
57
+ * nested repository boundary: that repo's own capture owns its own map.
58
+ */
59
+ function collectWorktreeIdentity(repoPath, { shouldEnroll, identityFor }) {
60
+ const assignment = new Map(); // repo-relative path -> { uuid, type }
61
+ const walk = (dirPath, relativePath) => {
62
+ let entries;
63
+ try {
64
+ entries = fs.readdirSync(dirPath, { withFileTypes: true })
65
+ .sort((a, b) => a.name.localeCompare(b.name));
66
+ } catch (error) {
67
+ if (error.code === 'EACCES' || error.code === 'EPERM') return;
68
+ throw error;
69
+ }
70
+ for (const entry of entries) {
71
+ const entryPath = path.join(dirPath, entry.name);
72
+ const entryRelativePath = relativePath ? `${relativePath}/${entry.name}` : entry.name;
73
+ if (!shouldEnroll(entryPath)) continue;
74
+ let stat;
75
+ try {
76
+ stat = fs.lstatSync(entryPath);
77
+ } catch {
78
+ continue; // raced away between readdir and look; the create-walk decides
79
+ }
80
+ let type;
81
+ if (stat.isSymbolicLink()) type = 'link';
82
+ else if (stat.isDirectory()) type = isRepository(entryPath) ? 'repo.git' : 'folder';
83
+ else if (stat.isFile()) type = 'file.text';
84
+ else continue;
85
+ const uuid = identityFor?.(entryRelativePath) || crypto.randomUUID();
86
+ assignment.set(entryRelativePath, { uuid, type });
87
+ if (type === 'folder') walk(entryPath, entryRelativePath);
88
+ }
89
+ };
90
+ walk(repoPath, '');
91
+ return assignment;
92
+ }
93
+
94
+ function transportIdentity(assignment) {
95
+ return [...assignment].map(([entryPath, { uuid, type }]) => ({ path: entryPath, uuid, type }));
96
+ }
97
+
47
98
  /**
48
99
  * Create the root record, its witness, and every entity below ordinary
49
100
  * directory ground. The caller supplies a root that is already known to the
@@ -90,47 +141,58 @@ function registerTree({ root, registry, memory, shouldEnroll, captureRepo, repoS
90
141
  /** An imported repository already has a verified Repocard artifact in the
91
142
  * local state table before it is bound to Watch. Reuse that exact state;
92
143
  * recapturing would waste a second Git bundle and could race the import.
93
- * Ordinary local registration has no seeded UUID and captures fresh truth. */
94
- function repoStateFor(identity, repoPath) {
144
+ * Ordinary local registration has no seeded UUID and captures fresh truth:
145
+ * the worktree's UUIDs are assigned FIRST so the packed transport's
146
+ * identity map and the created child records agree exactly. */
147
+ function repoStateFor(identity, repoPath, relativePath) {
95
148
  if (identity.uuid) {
96
149
  const imported = repoStateAt?.(identity.uuid);
97
- if (imported?.stateId === identity.payloadVersion) return imported;
150
+ if (imported?.stateId === identity.payloadVersion) return { state: imported, assignment: null };
98
151
  const saved = repoStates.get(identity.uuid);
99
- if (saved?.stateId === identity.payloadVersion) return saved;
152
+ if (saved?.stateId === identity.payloadVersion) return { state: saved, assignment: null };
100
153
  }
101
- return captureInitialRepo(repoPath, captureRepo);
154
+ const assignment = collectWorktreeIdentity(repoPath, {
155
+ shouldEnroll,
156
+ identityFor: (repoRelativePath) => identityAt?.(
157
+ relativePath ? `${relativePath}/${repoRelativePath}` : repoRelativePath,
158
+ )?.uuid || null,
159
+ });
160
+ return {
161
+ state: captureInitialRepo(repoPath, captureRepo, transportIdentity(assignment)),
162
+ assignment,
163
+ };
102
164
  }
103
165
 
104
166
  return registry.transaction(() => {
105
167
  const rootIdentity = seeded('', {
106
168
  type: rootType(root.path, root.kind), parentUUID: null, name: path.basename(root.path), root: true,
107
169
  });
108
- const initialState = root.kind === 'repo' ? repoStateFor(rootIdentity, root.path) : null;
170
+ const initial = root.kind === 'repo' ? repoStateFor(rootIdentity, root.path, '') : null;
109
171
  // A repo's immutable transport head is already held by the injected
110
172
  // cloud-cargo lookup before its entity journal runs. Allocate the UUID,
111
173
  // create the FK parent, then persist its bundle-free state in this same
112
174
  // transaction. A rollback removes both durable facts together.
113
- const rootUuid = rootIdentity.uuid || (initialState ? crypto.randomUUID() : undefined);
175
+ const rootUuid = rootIdentity.uuid || (initial ? crypto.randomUUID() : undefined);
114
176
  const record = registry.enrollRoot({
115
177
  type: rootIdentity.type,
116
178
  name: path.basename(root.path),
117
- payloadVersion: initialState?.stateId ?? rootIdentity.payloadVersion ?? null,
179
+ payloadVersion: initial?.state.stateId ?? rootIdentity.payloadVersion ?? null,
118
180
  uuid: rootUuid,
119
181
  });
120
182
  memory.bind(root.rootId, root.rootId, record.uuid);
121
- if (initialState) repoStates.put(record.uuid, initialState);
183
+ if (initial) repoStates.put(record.uuid, initial.state);
122
184
 
123
185
  if (root.kind === 'files' || root.kind === 'repo') {
124
186
  registerDirectory(root.path, record.uuid, {
125
187
  registry, shouldEnroll, captureRepo, repoStates, identityAt, repoStateAt, repoStateFor, seeded, relativePath: '',
126
- });
188
+ }, initial?.assignment ? { base: '', byPath: initial.assignment } : null);
127
189
  }
128
190
  return record;
129
191
  });
130
192
  }
131
193
 
132
- function captureInitialRepo(repoPath, captureRepo) {
133
- const state = captureRepo(repoPath);
194
+ function captureInitialRepo(repoPath, captureRepo, identityEntries = null) {
195
+ const state = captureRepo(repoPath, { identity: identityEntries });
134
196
  if (state?.busy) {
135
197
  throw new Error(`cannot register ${repoPath}: Git is unsettled (${state.busy})`);
136
198
  }
@@ -142,7 +204,7 @@ function captureInitialRepo(repoPath, captureRepo) {
142
204
 
143
205
  function registerDirectory(dirPath, parentUUID, {
144
206
  registry, shouldEnroll, captureRepo, repoStates, identityAt, repoStateAt, repoStateFor, seeded, relativePath,
145
- }) {
207
+ }, assigned = null) {
146
208
  let entries;
147
209
  try {
148
210
  entries = fs.readdirSync(dirPath, { withFileTypes: true })
@@ -155,6 +217,20 @@ function registerDirectory(dirPath, parentUUID, {
155
217
  throw error;
156
218
  }
157
219
 
220
+ // The UUID a pre-capture assignment reserved for this exact ground. The
221
+ // transport's identity map was packed from these, so the created record
222
+ // must wear the same UUID — unless the ground changed shape between the
223
+ // assignment walk and this one, in which case the walk's truth wins and
224
+ // the map entry is honestly stale.
225
+ const assignedFor = (entryRelativePath, type) => {
226
+ if (!assigned) return undefined;
227
+ const repoRelativePath = assigned.base
228
+ ? entryRelativePath.slice(assigned.base.length + 1)
229
+ : entryRelativePath;
230
+ const seat = assigned.byPath.get(repoRelativePath);
231
+ return seat && seat.type === type ? seat.uuid : undefined;
232
+ };
233
+
158
234
  for (const entry of entries) {
159
235
  const entryPath = path.join(dirPath, entry.name);
160
236
  const entryRelativePath = relativePath ? `${relativePath}/${entry.name}` : entry.name;
@@ -173,7 +249,7 @@ function registerDirectory(dirPath, parentUUID, {
173
249
  // Preserve it through the structural bind; normal local registration
174
250
  // still leaves this null until Watch observes the filesystem.
175
251
  payloadVersion: identity.payloadVersion ?? null,
176
- uuid: identity.uuid,
252
+ uuid: identity.uuid ?? assignedFor(entryRelativePath, 'link'),
177
253
  });
178
254
  continue;
179
255
  }
@@ -182,19 +258,27 @@ function registerDirectory(dirPath, parentUUID, {
182
258
  const identity = seeded(entryRelativePath, {
183
259
  type, parentUUID, name: entry.name, root: false,
184
260
  });
185
- const state = type === 'repo.git' ? repoStateFor(identity, entryPath) : null;
186
- const uuid = identity.uuid || (state ? crypto.randomUUID() : undefined);
261
+ const initial = type === 'repo.git' ? repoStateFor(identity, entryPath, entryRelativePath) : null;
262
+ const uuid = identity.uuid
263
+ ?? assignedFor(entryRelativePath, type)
264
+ ?? (initial ? crypto.randomUUID() : undefined);
187
265
  const record = registry.create({
188
266
  type: identity.type,
189
267
  parentUUID,
190
268
  name: entry.name,
191
- payloadVersion: state?.stateId ?? identity.payloadVersion ?? null,
269
+ payloadVersion: initial?.state.stateId ?? identity.payloadVersion ?? null,
192
270
  uuid,
193
271
  });
194
- if (state) repoStates.put(record.uuid, state);
272
+ if (initial) repoStates.put(record.uuid, initial.state);
273
+ // A repository starts its own assignment scope: its transport's map
274
+ // covers exactly its worktree, so its children draw from it — never
275
+ // from an enclosing repo's map.
276
+ const childAssigned = type === 'repo.git'
277
+ ? (initial?.assignment ? { base: entryRelativePath, byPath: initial.assignment } : null)
278
+ : assigned;
195
279
  registerDirectory(entryPath, record.uuid, {
196
280
  registry, shouldEnroll, captureRepo, repoStates, identityAt, repoStateAt, repoStateFor, seeded, relativePath: entryRelativePath,
197
- });
281
+ }, childAssigned);
198
282
  continue;
199
283
  }
200
284
  if (stat.isFile()) {
@@ -206,7 +290,7 @@ function registerDirectory(dirPath, parentUUID, {
206
290
  parentUUID,
207
291
  name: entry.name,
208
292
  payloadVersion: identity.payloadVersion ?? null,
209
- uuid: identity.uuid,
293
+ uuid: identity.uuid ?? assignedFor(entryRelativePath, 'file.text'),
210
294
  });
211
295
  }
212
296
  }
@@ -62,13 +62,50 @@ function remoteList(repoPath) {
62
62
  }).sort((a, b) => (a.name < b.name ? -1 : 1));
63
63
  }
64
64
 
65
- function untrackedFiles(repoPath) {
65
+ /** Is `rel` named by the suspect list — itself, or under a named directory? */
66
+ function makeSuspectTest(suspects) {
67
+ const named = new Set(suspects);
68
+ return (rel) => {
69
+ if (named.has(rel)) return true;
70
+ let prefix = rel;
71
+ for (;;) {
72
+ const cut = prefix.lastIndexOf('/');
73
+ if (cut < 0) return false;
74
+ prefix = prefix.slice(0, cut);
75
+ if (named.has(prefix)) return true;
76
+ }
77
+ };
78
+ }
79
+
80
+ /**
81
+ * The warm half of the checkpoint. Git keeps the Card's ledger itself and
82
+ * its index makes the tracked diffs cheap — the one cost with no cache was
83
+ * untracked files, re-read in full on every capture. With `suspects` (the
84
+ * doorbell's named paths since the last settled state) and the previous
85
+ * state's untracked entries, an unsuspected file's (mode, sha256) is
86
+ * REUSED without opening it: silence from the doorbell is the proof the
87
+ * written answer is still true. Everything else stays a fresh read — a
88
+ * suspect, a new path, or any capture without both inputs (suspects null
89
+ * means "everything is suspect": first capture, flush, catch-up, a command
90
+ * ending). Reused entries carry `content: null`; ids derive from
91
+ * (path, mode, sha256) alone, so a scoped capture's ids are byte-identical
92
+ * to a from-scratch one over the same ground.
93
+ */
94
+ function untrackedFiles(repoPath, { suspects = null, previousUntracked = null } = {}) {
95
+ const reusable = suspects !== null && previousUntracked
96
+ ? new Map(previousUntracked.map((file) => [file.path, file]))
97
+ : null;
98
+ const suspect = reusable ? makeSuspectTest(suspects) : null;
66
99
  const out = runGit(repoPath, ['ls-files', '--others', '--exclude-standard', '-z']).toString('utf8');
67
100
  return out.split('\0').filter(Boolean).sort().flatMap((rel) => {
68
101
  // A trailing slash is git marking a repo boundary it will not enter — a
69
102
  // nested repository or linked worktree. That is its own territory with
70
103
  // its own card; the parent's checkpoint must not contain it.
71
104
  if (rel.endsWith('/')) return [];
105
+ if (reusable && !suspect(rel)) {
106
+ const prev = reusable.get(rel);
107
+ if (prev) return [{ path: rel, mode: prev.mode, content: null, sha256: prev.sha256, reused: true }];
108
+ }
72
109
  const abs = path.join(repoPath, rel);
73
110
  try {
74
111
  const stat = fs.lstatSync(abs);
@@ -123,9 +160,14 @@ function deriveCheckpointId(checkpoint) {
123
160
  }));
124
161
  }
125
162
 
126
- function capture(repoPath, { withBundle = true } = {}) {
163
+ function capture(repoPath, { withBundle = true, suspects = null, previousUntracked = null } = {}) {
127
164
  const busy = busyReason(repoPath);
128
165
  if (busy) return { busy };
166
+ // Untracked reuse is bundle-free (local) captures only: cloud cargo
167
+ // (prepareRepo) consumes untracked CONTENT, and a reused entry carries
168
+ // none. Teaching the cargo path to dedupe by sha256 is the named next
169
+ // step; until then a withBundle capture always reads every byte.
170
+ const scoped = withBundle ? {} : { suspects, previousUntracked };
129
171
 
130
172
  const branch = tryGitText(repoPath, ['symbolic-ref', '-q', '--short', 'HEAD']); // null = detached
131
173
  const commit = tryGitText(repoPath, ['rev-parse', '-q', '--verify', 'HEAD']); // null = unborn
@@ -146,7 +188,7 @@ function capture(repoPath, { withBundle = true } = {}) {
146
188
  // raw non-UTF-8 bytes, and a string conversion would corrupt it silently.
147
189
  stagedPatch: runGit(repoPath, ['diff', '--cached', '--binary', '--full-index', commit ? 'HEAD' : EMPTY_TREE]),
148
190
  unstagedPatch: runGit(repoPath, ['diff', '--binary', '--full-index']),
149
- untracked: untrackedFiles(repoPath),
191
+ untracked: untrackedFiles(repoPath, scoped),
150
192
  };
151
193
 
152
194
  const cardId = deriveCardId(card);