@ricsam/r5d-worker 0.0.101 → 0.0.104

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.
@@ -1,5 +1,5 @@
1
1
  {
2
2
  "name": "@ricsam/r5d-worker",
3
- "version": "0.0.101",
3
+ "version": "0.0.104",
4
4
  "type": "module"
5
5
  }
@@ -1,16 +1,56 @@
1
- const PROJECT_MIRROR_FETCH_BACKSTOP_MS = 10 * 6e4;
2
1
  function projectMirrorFetchDecision(input) {
3
2
  if (!input.skippingEnabled) return { fetch: true, reason: "skipping_disabled" };
4
3
  if (input.advertisedToken === void 0) return { fetch: true, reason: "no_advertised_token" };
5
4
  if (input.fetchedToken === void 0) return { fetch: true, reason: "never_fetched" };
6
5
  if (input.advertisedToken !== input.fetchedToken) return { fetch: true, reason: "token_changed" };
7
- const backstopMs = input.backstopMs ?? PROJECT_MIRROR_FETCH_BACKSTOP_MS;
8
- if (input.lastFetchAtMs === void 0 || input.nowMs - input.lastFetchAtMs >= backstopMs) {
9
- return { fetch: true, reason: "backstop_elapsed" };
10
- }
11
6
  return { fetch: false, reason: "token_match" };
12
7
  }
8
+ function planProjectMirrorObservationFetch(input) {
9
+ if (!input.skippingEnabled) return { scope: "project", reason: "skipping_disabled" };
10
+ if (input.advertisedMountTokens === void 0) {
11
+ const decision = projectMirrorFetchDecision({
12
+ advertisedToken: input.advertisedProjectToken,
13
+ fetchedToken: input.fetchedProjectToken,
14
+ skippingEnabled: true
15
+ });
16
+ return decision.fetch ? { scope: "project", reason: decision.reason } : { scope: "none" };
17
+ }
18
+ const mounts = [];
19
+ for (const branchName of [...input.branchNames].sort()) {
20
+ const decision = projectMirrorFetchDecision({
21
+ advertisedToken: input.advertisedMountTokens.get(branchName),
22
+ fetchedToken: input.fetchedMountTokens.get(branchName),
23
+ skippingEnabled: true
24
+ });
25
+ if (decision.fetch) mounts.push({ branchName, reason: decision.reason });
26
+ }
27
+ return mounts.length === 0 ? { scope: "none" } : { scope: "mounts", mounts };
28
+ }
29
+ const TOKEN_RE = /^[0-9a-f]{64}$/;
30
+ const GIT_OBJECT_NAME_RE = /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/;
31
+ function parseProjectMirrorRefsTokensMessage(message) {
32
+ const projectTokens = /* @__PURE__ */ new Map();
33
+ if (message.tokens && typeof message.tokens === "object") {
34
+ for (const [projectId, token] of Object.entries(message.tokens)) {
35
+ if (typeof token === "string" && TOKEN_RE.test(token)) projectTokens.set(projectId, token);
36
+ }
37
+ }
38
+ const mountTokens = /* @__PURE__ */ new Map();
39
+ if (message.mountTokens && typeof message.mountTokens === "object") {
40
+ for (const [projectId, tokensByBranch] of Object.entries(message.mountTokens)) {
41
+ if (!tokensByBranch || typeof tokensByBranch !== "object") continue;
42
+ const perMount = /* @__PURE__ */ new Map();
43
+ for (const [branchName, token] of Object.entries(tokensByBranch)) {
44
+ if (typeof token === "string" && TOKEN_RE.test(token)) perMount.set(branchName, token);
45
+ }
46
+ mountTokens.set(projectId, perMount);
47
+ }
48
+ }
49
+ const workspaceHead = typeof message.workspaceHead === "string" && GIT_OBJECT_NAME_RE.test(message.workspaceHead) ? message.workspaceHead : void 0;
50
+ return { projectTokens, mountTokens, workspaceHead };
51
+ }
13
52
  export {
14
- PROJECT_MIRROR_FETCH_BACKSTOP_MS,
53
+ parseProjectMirrorRefsTokensMessage,
54
+ planProjectMirrorObservationFetch,
15
55
  projectMirrorFetchDecision
16
56
  };
@@ -1,10 +1,24 @@
1
1
  import { createHash } from "node:crypto";
2
+ const PROJECT_MIRROR_REFS_TOKEN_NAMESPACES = ["refs/heads/", "refs/r5d/server-synced/"];
2
3
  const PROJECT_MIRROR_REF_PREFIX = "refs/r5d/mirror/";
3
4
  const PROJECT_SERVER_SYNCED_REF_PREFIX = "refs/r5d/server-synced/";
5
+ const PROJECT_MIRROR_REF_LISTING_FORMAT = "--format=%(objectname) %(refname)";
6
+ const GIT_OBJECT_NAME_RE = /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/;
4
7
  function hashProjectMirrorRefs(refs) {
5
8
  const lines = refs.map(({ objectName, refname }) => `${objectName} ${refname}`).sort((left, right) => left < right ? -1 : left > right ? 1 : 0);
6
9
  return createHash("sha256").update(lines.join("\n")).digest("hex");
7
10
  }
11
+ function parseProjectMirrorRefListing(output, options = {}) {
12
+ return output.split(/\r?\n/).filter(Boolean).map((row) => {
13
+ const separator = row.indexOf(" ");
14
+ const objectName = separator < 0 ? "" : row.slice(0, separator);
15
+ const refname = separator < 0 ? "" : row.slice(separator + 1);
16
+ if (!GIT_OBJECT_NAME_RE.test(objectName) || refname.length === 0 || options.namespaces !== void 0 && !options.namespaces.some((prefix) => refname.startsWith(prefix))) {
17
+ throw new Error(`Invalid ref listing row: ${row}`);
18
+ }
19
+ return { refname, objectName };
20
+ });
21
+ }
8
22
  function canonicalizeLocalProjectMirrorRefs(refs) {
9
23
  const canonical = [];
10
24
  for (const { refname, objectName } of refs) {
@@ -16,9 +30,24 @@ function canonicalizeLocalProjectMirrorRefs(refs) {
16
30
  }
17
31
  return canonical;
18
32
  }
33
+ function projectMirrorMountRefs(refs, mount) {
34
+ const headRef = `refs/heads/${mount.branchName}`;
35
+ const handoffPrefix = `${PROJECT_SERVER_SYNCED_REF_PREFIX}${mount.branchId}/`;
36
+ return refs.filter(({ refname }) => refname === headRef || refname.startsWith(handoffPrefix));
37
+ }
38
+ function projectMirrorRefsTokens(refs, mounts) {
39
+ const mountTokens = /* @__PURE__ */ new Map();
40
+ for (const mount of mounts) mountTokens.set(mount.branchName, hashProjectMirrorRefs(projectMirrorMountRefs(refs, mount)));
41
+ return { project: hashProjectMirrorRefs(refs), mounts: mountTokens };
42
+ }
19
43
  export {
44
+ PROJECT_MIRROR_REFS_TOKEN_NAMESPACES,
45
+ PROJECT_MIRROR_REF_LISTING_FORMAT,
20
46
  PROJECT_MIRROR_REF_PREFIX,
21
47
  PROJECT_SERVER_SYNCED_REF_PREFIX,
22
48
  canonicalizeLocalProjectMirrorRefs,
23
- hashProjectMirrorRefs
49
+ hashProjectMirrorRefs,
50
+ parseProjectMirrorRefListing,
51
+ projectMirrorMountRefs,
52
+ projectMirrorRefsTokens
24
53
  };
@@ -6,9 +6,11 @@ import { gitCredentialUsernameConfigKey, gitTransportSecurityArgs, workerGitProc
6
6
  import { validateManagedBranchName } from "./managed-paths.mjs";
7
7
  import {
8
8
  canonicalizeLocalProjectMirrorRefs,
9
- hashProjectMirrorRefs,
9
+ parseProjectMirrorRefListing,
10
+ PROJECT_MIRROR_REF_LISTING_FORMAT,
10
11
  PROJECT_MIRROR_REF_PREFIX,
11
- PROJECT_SERVER_SYNCED_REF_PREFIX
12
+ PROJECT_SERVER_SYNCED_REF_PREFIX,
13
+ projectMirrorRefsTokens
12
14
  } from "./project-mirror-refs-token.mjs";
13
15
  import { mirrorWorkingTree } from "./working-tree-mirror.mjs";
14
16
  const PROJECT_WORKTREE_SNAPSHOT_PREFIX = "r5d-project-worktrees-";
@@ -285,6 +287,8 @@ function normalizeSnapshotManifest(value) {
285
287
  if (record.state !== "building" && record.state !== "prepared" && record.state !== "restoring" && record.state !== "consumed") {
286
288
  throw new Error("Project worktree snapshot manifest has an invalid state");
287
289
  }
290
+ const scope = record.scope === void 0 ? "project" : record.scope;
291
+ if (scope !== "project" && scope !== "branches") throw new Error("Project worktree snapshot manifest has an invalid scope");
288
292
  if (!Number.isSafeInteger(record.ownerProcessId) || record.ownerProcessId <= 0) {
289
293
  throw new Error("Project worktree snapshot manifest has an invalid owner process");
290
294
  }
@@ -320,6 +324,7 @@ function normalizeSnapshotManifest(value) {
320
324
  return {
321
325
  version: PROJECT_WORKTREE_SNAPSHOT_MANIFEST_VERSION,
322
326
  state: record.state,
327
+ scope,
323
328
  ownerProcessId: record.ownerProcessId,
324
329
  ownerSessionId: record.ownerSessionId,
325
330
  projectRoot: record.projectRoot,
@@ -356,7 +361,7 @@ function writeSnapshotManifest(snapshotRoot, manifest) {
356
361
  return readSnapshotManifest(snapshotRoot);
357
362
  }
358
363
  function assertSameSnapshotIdentity(expected, actual) {
359
- if (expected.ownerProcessId !== actual.ownerProcessId || expected.ownerSessionId !== actual.ownerSessionId || expected.projectRoot !== actual.projectRoot || expected.createdAt !== actual.createdAt || JSON.stringify(expected.branches) !== JSON.stringify(actual.branches)) {
364
+ if (expected.scope !== actual.scope || expected.ownerProcessId !== actual.ownerProcessId || expected.ownerSessionId !== actual.ownerSessionId || expected.projectRoot !== actual.projectRoot || expected.createdAt !== actual.createdAt || JSON.stringify(expected.branches) !== JSON.stringify(actual.branches)) {
360
365
  throw new Error("Project worktree snapshot identity changed");
361
366
  }
362
367
  }
@@ -423,7 +428,7 @@ function assertNoOutstandingSnapshotForProject(temporaryRoot, projectRoot) {
423
428
  }
424
429
  }
425
430
  }
426
- function snapshotBranchTrees(projectRoot, branches, temporaryRoot, onProgress) {
431
+ function snapshotBranchTrees(projectRoot, branches, temporaryRoot, onProgress, scope = "project") {
427
432
  const resolvedProjectRoot = path.resolve(projectRoot);
428
433
  const resolvedTemporaryRoot = path.resolve(temporaryRoot ?? os.tmpdir());
429
434
  assertNoOutstandingSnapshotForProject(resolvedTemporaryRoot, resolvedProjectRoot);
@@ -444,6 +449,7 @@ function snapshotBranchTrees(projectRoot, branches, temporaryRoot, onProgress) {
444
449
  let manifest = writeSnapshotManifest(root, {
445
450
  version: PROJECT_WORKTREE_SNAPSHOT_MANIFEST_VERSION,
446
451
  state: "building",
452
+ scope,
447
453
  ownerProcessId: process.pid,
448
454
  ownerSessionId: PROJECT_WORKTREE_SNAPSHOT_OWNER_SESSION_ID,
449
455
  projectRoot: resolvedProjectRoot,
@@ -550,7 +556,7 @@ function restoreProjectWorktreeSnapshot(snapshotRoot, manifest, onProgress) {
550
556
  const presentBranches = current.branches.filter(({ snapshotPresent }) => snapshotPresent);
551
557
  const destinationParent = path.dirname(current.projectRoot);
552
558
  fs.mkdirSync(destinationParent, { recursive: true });
553
- const canMoveWholeSnapshot = presentBranches.length > 0 && (current.state === "restoring" || treesRootStat !== null && treesRootStat.dev === fs.lstatSync(destinationParent).dev);
559
+ const canMoveWholeSnapshot = current.scope === "project" && presentBranches.length > 0 && (current.state === "restoring" || treesRootStat !== null && treesRootStat.dev === fs.lstatSync(destinationParent).dev);
554
560
  if (canMoveWholeSnapshot) {
555
561
  if (current.state === "prepared") current = advanceSnapshotState(snapshotRoot, current, "restoring");
556
562
  onProgress?.({
@@ -641,8 +647,12 @@ function consumeAndRemoveProjectWorktreeSnapshot(snapshotRoot, manifest) {
641
647
  } catch {
642
648
  }
643
649
  }
644
- function persistReconciledProjectAndConsumeSnapshot(snapshot, afterProjectTreeFsync) {
645
- fsyncTree(snapshot.manifest.projectRoot);
650
+ function persistReconciledProjectAndConsumeSnapshot(snapshot, afterProjectTreeFsync, durabilityPaths) {
651
+ if (durabilityPaths) {
652
+ for (const durabilityPath of durabilityPaths) if (fs.existsSync(durabilityPath)) fsyncTree(durabilityPath);
653
+ } else {
654
+ fsyncTree(snapshot.manifest.projectRoot);
655
+ }
646
656
  fsyncManagedProjectRootEntry(snapshot.manifest.projectRoot);
647
657
  afterProjectTreeFsync?.();
648
658
  consumeAndRemoveProjectWorktreeSnapshot(snapshot.root, snapshot.manifest);
@@ -940,8 +950,10 @@ async function reconcileLinkedLayout(input) {
940
950
  await configureRepository({ ...input, primaryPath });
941
951
  await tryGitAsync(primaryPath, ["worktree", "prune"]);
942
952
  await fetchProjectHeads({ ...input, primaryPath });
953
+ const primaryCommonDir = await commonGitDirectoryAsync(primaryPath);
954
+ if (!primaryCommonDir) throw new Error("Primary project checkout is unavailable");
943
955
  const mirrorHeads = /* @__PURE__ */ new Map();
944
- const mutatedBranches = /* @__PURE__ */ new Set();
956
+ const plan = [];
945
957
  for (const branch of input.branches) {
946
958
  const checkoutPath = branchPath(input.projectRoot, branch.branchName);
947
959
  const resolved = await seedForBranch({
@@ -951,24 +963,30 @@ async function reconcileLinkedLayout(input) {
951
963
  requireMirrorHead: input.exactMirrorBranches?.has(branch.branchName)
952
964
  });
953
965
  mirrorHeads.set(branch.branchName, resolved.mirrorHead);
954
- if (branch.branchName === input.primaryBranchName || await commonGitDirectoryAsync(checkoutPath) === await commonGitDirectoryAsync(primaryPath)) {
955
- const currentHead = await gitAsync(checkoutPath, ["rev-parse", "HEAD"], `resolve linked worktree head ${branch.branchName}`);
956
- if (currentHead === resolved.seed) continue;
957
- input.beforeDestructiveMutation();
958
- mutatedBranches.add(branch.branchName);
959
- await preserveHead(checkoutPath);
960
- await gitAsync(checkoutPath, ["reset", "--hard", resolved.seed], `anchor linked worktree ${branch.branchName}`);
966
+ const linked = branch.branchName === input.primaryBranchName || gitDirectoryKind(checkoutPath) !== null && await commonGitDirectoryAsync(checkoutPath) === primaryCommonDir;
967
+ if (!linked) {
968
+ plan.push({ branchName: branch.branchName, checkoutPath, seed: resolved.seed, action: "recreate" });
961
969
  continue;
962
970
  }
963
- input.beforeDestructiveMutation();
964
- mutatedBranches.add(branch.branchName);
965
- fs.rmSync(checkoutPath, { recursive: true, force: true });
966
- await gitAsync(primaryPath, ["branch", "-f", branch.branchName, resolved.seed], `anchor project branch ${branch.branchName}`);
967
- fs.mkdirSync(path.dirname(checkoutPath), { recursive: true });
971
+ const currentHead = await gitAsync(checkoutPath, ["rev-parse", "HEAD"], `resolve linked worktree head ${branch.branchName}`);
972
+ plan.push({ branchName: branch.branchName, checkoutPath, seed: resolved.seed, action: currentHead === resolved.seed ? "keep" : "reset" });
973
+ }
974
+ const mutatedBranches = new Set(plan.filter(({ action }) => action !== "keep").map(({ branchName }) => branchName));
975
+ if (mutatedBranches.size > 0) input.beforeDestructiveMutation(mutatedBranches);
976
+ for (const entry of plan) {
977
+ if (entry.action === "keep") continue;
978
+ if (entry.action === "reset") {
979
+ await preserveHead(entry.checkoutPath);
980
+ await gitAsync(entry.checkoutPath, ["reset", "--hard", entry.seed], `anchor linked worktree ${entry.branchName}`);
981
+ continue;
982
+ }
983
+ fs.rmSync(entry.checkoutPath, { recursive: true, force: true });
984
+ await gitAsync(primaryPath, ["branch", "-f", entry.branchName, entry.seed], `anchor project branch ${entry.branchName}`);
985
+ fs.mkdirSync(path.dirname(entry.checkoutPath), { recursive: true });
968
986
  await gitAsync(
969
987
  primaryPath,
970
- ["worktree", "add", "--force", checkoutPath, branch.branchName],
971
- `create linked worktree ${branch.branchName}`
988
+ ["worktree", "add", "--force", entry.checkoutPath, entry.branchName],
989
+ `create linked worktree ${entry.branchName}`
972
990
  );
973
991
  }
974
992
  return { mirrorHeads, mutatedBranches };
@@ -995,8 +1013,14 @@ async function ensureProjectWorktrees(input) {
995
1013
  const snapshotStorageRoot = path.resolve(input.snapshotRoot ?? os.tmpdir());
996
1014
  assertNoOutstandingSnapshotForProject(snapshotStorageRoot, projectRoot);
997
1015
  let snapshot;
998
- const ensureSnapshot = () => {
999
- snapshot ??= snapshotBranchTrees(projectRoot, input.branches, snapshotStorageRoot, input.onSnapshotProgress);
1016
+ const ensureSnapshot = (branchNames) => {
1017
+ snapshot ??= snapshotBranchTrees(
1018
+ projectRoot,
1019
+ branchNames ? input.branches.filter(({ branchName }) => branchNames.has(branchName)) : input.branches,
1020
+ snapshotStorageRoot,
1021
+ input.onSnapshotProgress,
1022
+ branchNames ? "branches" : "project"
1023
+ );
1000
1024
  return snapshot;
1001
1025
  };
1002
1026
  try {
@@ -1048,7 +1072,21 @@ async function ensureProjectWorktrees(input) {
1048
1072
  });
1049
1073
  }
1050
1074
  states.sort((left, right) => left.branchName.localeCompare(right.branchName));
1051
- if (snapshot) persistReconciledProjectAndConsumeSnapshot(snapshot);
1075
+ if (snapshot) {
1076
+ const primaryGitDirectory = path.join(branchPath(projectRoot, input.primaryBranchName), ".git");
1077
+ persistReconciledProjectAndConsumeSnapshot(
1078
+ snapshot,
1079
+ void 0,
1080
+ snapshot.manifest.scope === "branches" ? [
1081
+ ...snapshot.manifest.branches.map(({ branchName }) => branchPath(projectRoot, branchName)),
1082
+ // Branch refs and worktree registrations moved in the shared
1083
+ // administrative directory; objects are fsynced by git itself.
1084
+ path.join(primaryGitDirectory, "refs"),
1085
+ path.join(primaryGitDirectory, "packed-refs"),
1086
+ path.join(primaryGitDirectory, "worktrees")
1087
+ ] : void 0
1088
+ );
1089
+ }
1052
1090
  return states;
1053
1091
  } catch (error) {
1054
1092
  if (!snapshot) throw error;
@@ -1125,8 +1163,12 @@ function createLinkedProjectBranch(input) {
1125
1163
  );
1126
1164
  configureProjectBranchTracking(targetPath, input.branchName, input.branchName);
1127
1165
  assertNoSymlinkBranchPathComponents(input.projectRoot, targetPath);
1128
- git(targetPath, ["read-tree", sourceHead], `initialize linked worktree index ${input.branchName}`);
1129
- mirrorWorkingTree({ sourceRoot: sourcePath, targetRoot: targetPath, sourceMode: "git", deletionMode: "git" });
1166
+ if (input.workingTree === "carry") {
1167
+ git(targetPath, ["read-tree", sourceHead], `initialize linked worktree index ${input.branchName}`);
1168
+ mirrorWorkingTree({ sourceRoot: sourcePath, targetRoot: targetPath, sourceMode: "git", deletionMode: "git" });
1169
+ } else {
1170
+ git(targetPath, ["reset", "--hard", sourceHead], `check out linked worktree ${input.branchName}`);
1171
+ }
1130
1172
  return { branchPath: targetPath, baseCommitHash: sourceHead };
1131
1173
  } catch (error) {
1132
1174
  if (branchCreated) {
@@ -1190,6 +1232,53 @@ function createOrRetryLinkedProjectBranch(input) {
1190
1232
  }
1191
1233
  return createLinkedProjectBranch(input);
1192
1234
  }
1235
+ function classifyProjectCheckout(input) {
1236
+ const checkoutPath = branchPath(input.projectRoot, input.branchName);
1237
+ let stat;
1238
+ try {
1239
+ stat = fs.lstatSync(checkoutPath);
1240
+ } catch {
1241
+ return "missing";
1242
+ }
1243
+ if (!stat.isDirectory()) return "not_a_worktree";
1244
+ if (input.branchName === input.primaryBranchName) return gitDirectoryKind(checkoutPath) === "directory" ? "linked" : "not_a_worktree";
1245
+ if (gitDirectoryKind(checkoutPath) !== "file") return "not_a_worktree";
1246
+ const gitDirectory = perWorktreeGitDirectory(checkoutPath);
1247
+ if (!gitDirectory) return "not_a_worktree";
1248
+ let registry;
1249
+ let registered;
1250
+ try {
1251
+ registry = fs.realpathSync(path.join(branchPath(input.projectRoot, input.primaryBranchName), ".git", "worktrees"));
1252
+ registered = fs.realpathSync(gitDirectory);
1253
+ } catch {
1254
+ return "not_a_worktree";
1255
+ }
1256
+ const relative = path.relative(registry, registered);
1257
+ if (!relative || relative === ".." || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) return "not_a_worktree";
1258
+ return "linked";
1259
+ }
1260
+ function inventoryProjectCheckouts(input) {
1261
+ const states = input.branchNames.map((branchName) => ({
1262
+ branchName,
1263
+ checkoutPath: branchPath(input.projectRoot, branchName),
1264
+ state: classifyProjectCheckout({ ...input, branchName })
1265
+ }));
1266
+ const primaryLinked = states.some(({ branchName, state }) => branchName === input.primaryBranchName && state === "linked");
1267
+ const inventory = { linkedBranchNames: [], missingCheckouts: [] };
1268
+ for (const { branchName, checkoutPath, state } of states) {
1269
+ if (primaryLinked && state === "linked") inventory.linkedBranchNames.push(branchName);
1270
+ else inventory.missingCheckouts.push({ branchName, checkoutPath, reason: state === "linked" ? "not_a_worktree" : state });
1271
+ }
1272
+ return inventory;
1273
+ }
1274
+ const PROJECT_REMOVED_CHECKOUTS_DIRECTORY = ".r5d-removed";
1275
+ function moveProjectCheckoutAside(projectRoot, branchName, checkoutPath) {
1276
+ const timestamp = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-");
1277
+ const target = path.join(projectRoot, PROJECT_REMOVED_CHECKOUTS_DIRECTORY, ...`${branchName}-${timestamp}`.split("/"));
1278
+ fs.mkdirSync(path.dirname(target), { recursive: true });
1279
+ fs.renameSync(checkoutPath, target);
1280
+ return target;
1281
+ }
1193
1282
  function deleteLinkedProjectBranch(input) {
1194
1283
  validateManagedBranchName(input.primaryBranchName);
1195
1284
  validateManagedBranchName(input.branchName);
@@ -1198,15 +1287,18 @@ function deleteLinkedProjectBranch(input) {
1198
1287
  const checkoutPath = branchPath(input.projectRoot, input.branchName);
1199
1288
  const primaryCommonDir = commonGitDirectory(primaryPath);
1200
1289
  if (!primaryCommonDir) throw new Error("Primary project checkout is unavailable");
1290
+ let movedAsidePath;
1201
1291
  if (fs.existsSync(checkoutPath)) {
1202
- const checkoutCommonDir = commonGitDirectory(checkoutPath);
1292
+ const checkoutCommonDir = fs.lstatSync(checkoutPath).isDirectory() ? commonGitDirectory(checkoutPath) : null;
1203
1293
  if (checkoutCommonDir !== primaryCommonDir) {
1204
- throw new Error(`Project branch path ${input.branchName} is not a linked worktree of the configured project`);
1205
- }
1206
- if (projectWorktreeOperationInProgress(checkoutPath)) {
1207
- throw new Error(`Project branch ${input.branchName} has an in-progress Git operation`);
1294
+ movedAsidePath = moveProjectCheckoutAside(input.projectRoot, input.branchName, checkoutPath);
1295
+ tryGit(primaryPath, ["worktree", "prune"]);
1296
+ } else {
1297
+ if (projectWorktreeOperationInProgress(checkoutPath)) {
1298
+ throw new Error(`Project branch ${input.branchName} has an in-progress Git operation`);
1299
+ }
1300
+ git(primaryPath, ["worktree", "remove", "--force", checkoutPath], `remove linked worktree ${input.branchName}`);
1208
1301
  }
1209
- git(primaryPath, ["worktree", "remove", "--force", checkoutPath], `remove linked worktree ${input.branchName}`);
1210
1302
  } else {
1211
1303
  tryGit(primaryPath, ["worktree", "prune"]);
1212
1304
  }
@@ -1214,7 +1306,7 @@ function deleteLinkedProjectBranch(input) {
1214
1306
  if (tryGit(primaryPath, ["show-ref", "--verify", "--quiet", branchRef])) {
1215
1307
  git(primaryPath, ["branch", "-D", input.branchName], `delete project branch ${input.branchName}`);
1216
1308
  }
1217
- return { branchName: input.branchName };
1309
+ return { branchName: input.branchName, ...movedAsidePath ? { movedAsidePath } : {} };
1218
1310
  }
1219
1311
  function removeProjectWorktrees(input) {
1220
1312
  const projectRoot = path.resolve(input.projectRoot);
@@ -1316,24 +1408,46 @@ function assertProjectMirrorHeadsPushed(results) {
1316
1408
  throw new Error(`Hidden project mirror publication skipped ${skipped.branchName}${skipped.reason ? ` (${skipped.reason})` : ""}`);
1317
1409
  }
1318
1410
  }
1411
+ const PROJECT_MIRROR_FETCH_REFSPECS = ["+refs/heads/*:refs/r5d/mirror/*", "+refs/r5d/server-synced/*:refs/r5d/server-synced/*"];
1412
+ function projectMirrorMountFetchRefspecs(branchName, branchId) {
1413
+ validateManagedBranchName(branchName);
1414
+ if (branchId !== void 0 && !/^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/.test(branchId)) throw new Error(`Invalid branch id for ${branchName}`);
1415
+ return [
1416
+ `+refs/heads/${branchName}:refs/r5d/mirror/${branchName}`,
1417
+ branchId === void 0 ? "+refs/r5d/server-synced/*:refs/r5d/server-synced/*" : `+refs/r5d/server-synced/${branchId}/*:refs/r5d/server-synced/${branchId}/*`
1418
+ ];
1419
+ }
1420
+ async function fetchProjectMirrorRefs(input) {
1421
+ const fetch = (refspecs) => gitResultAsync(
1422
+ input.primaryPath,
1423
+ [
1424
+ ...gitTransportSecurityArgs(input.mirrorUrl, input.credentialHelper, input.credentialUsername),
1425
+ "fetch",
1426
+ "--no-recurse-submodules",
1427
+ "--prune",
1428
+ input.mirrorUrl,
1429
+ ...refspecs
1430
+ ],
1431
+ PROJECT_GIT_TRANSPORT_TIMEOUT_MS
1432
+ );
1433
+ if (input.fetchOnlyBranches && input.fetchOnlyBranches.size > 0) {
1434
+ const refspecs = [...input.fetchOnlyBranches].sort().flatMap((branchName) => projectMirrorMountFetchRefspecs(branchName, input.branchIds?.get(branchName)));
1435
+ const scoped = await fetch(refspecs);
1436
+ if (scoped.exitCode === 0) return;
1437
+ if (!/(couldn't find remote ref|no such ref)/i.test(`${scoped.stderr}
1438
+ ${scoped.stdout}`)) {
1439
+ throw new Error(`Refresh project head mirror: ${scoped.stderr || scoped.stdout || `git exited ${scoped.exitCode}`}`);
1440
+ }
1441
+ }
1442
+ const whole = await fetch(PROJECT_MIRROR_FETCH_REFSPECS);
1443
+ if (whole.exitCode !== 0) {
1444
+ throw new Error(`Refresh project head mirror: ${whole.stderr || whole.stdout || `git exited ${whole.exitCode}`}`);
1445
+ }
1446
+ }
1319
1447
  async function observeProjectMirrorHeads(input) {
1320
1448
  const primaryPath = branchPath(input.projectRoot, input.primaryBranchName);
1321
- if (input.refreshRemote !== false) {
1322
- const fetch = await gitResultAsync(
1323
- primaryPath,
1324
- [
1325
- ...gitTransportSecurityArgs(input.mirrorUrl, input.credentialHelper, input.credentialUsername),
1326
- "fetch",
1327
- "--no-recurse-submodules",
1328
- "--prune",
1329
- input.mirrorUrl,
1330
- "+refs/heads/*:refs/r5d/mirror/*",
1331
- "+refs/r5d/server-synced/*:refs/r5d/server-synced/*"
1332
- ],
1333
- PROJECT_GIT_TRANSPORT_TIMEOUT_MS
1334
- );
1335
- if (fetch.exitCode !== 0)
1336
- throw new Error(`Refresh project head mirror: ${fetch.stderr || fetch.stdout || `git exited ${fetch.exitCode}`}`);
1449
+ if (input.refreshRemote !== false && !(input.fetchOnlyBranches && input.fetchOnlyBranches.size === 0)) {
1450
+ await fetchProjectMirrorRefs({ ...input, primaryPath });
1337
1451
  }
1338
1452
  const observations = [];
1339
1453
  for (const branchName of [...input.branchNames].sort()) {
@@ -1380,23 +1494,14 @@ async function observeProjectMirrorHeads(input) {
1380
1494
  }
1381
1495
  return observations;
1382
1496
  }
1383
- async function computeLocalProjectMirrorRefsToken(input) {
1497
+ async function computeLocalProjectMirrorRefsTokens(input) {
1384
1498
  const primaryPath = branchPath(input.projectRoot, input.primaryBranchName);
1385
1499
  const rows = await gitAsync(
1386
1500
  primaryPath,
1387
- ["for-each-ref", "--format=%(objectname) %(refname)", PROJECT_MIRROR_REF_PREFIX, PROJECT_SERVER_SYNCED_REF_PREFIX],
1501
+ ["for-each-ref", PROJECT_MIRROR_REF_LISTING_FORMAT, PROJECT_MIRROR_REF_PREFIX, PROJECT_SERVER_SYNCED_REF_PREFIX],
1388
1502
  "list cached project mirror refs"
1389
1503
  );
1390
- const refs = rows.split(/\r?\n/).filter(Boolean).map((row) => {
1391
- const separator = row.indexOf(" ");
1392
- const objectName = separator < 0 ? "" : row.slice(0, separator);
1393
- const refname = separator < 0 ? "" : row.slice(separator + 1);
1394
- if (!/^[0-9a-f]{40,64}$/.test(objectName) || refname.length === 0) {
1395
- throw new Error(`Invalid cached mirror ref listing row: ${row}`);
1396
- }
1397
- return { refname, objectName };
1398
- });
1399
- return hashProjectMirrorRefs(canonicalizeLocalProjectMirrorRefs(refs));
1504
+ return projectMirrorRefsTokens(canonicalizeLocalProjectMirrorRefs(parseProjectMirrorRefListing(rows)), input.mounts);
1400
1505
  }
1401
1506
  async function applyObservedProjectMirrorHeads(input) {
1402
1507
  const results = [];
@@ -1460,7 +1565,7 @@ const projectWorktreesTestHarness = {
1460
1565
  commandArgs: gitCommandArgs,
1461
1566
  configureRepository,
1462
1567
  createPreparedSnapshot(input) {
1463
- return { root: snapshotBranchTrees(input.projectRoot, input.branches, input.temporaryRoot).root };
1568
+ return { root: snapshotBranchTrees(input.projectRoot, input.branches, input.temporaryRoot, void 0, input.scope).root };
1464
1569
  },
1465
1570
  consumeSnapshot(snapshotRoot) {
1466
1571
  const manifest = readSnapshotManifest(snapshotRoot);
@@ -1475,11 +1580,13 @@ const projectWorktreesTestHarness = {
1475
1580
  }
1476
1581
  };
1477
1582
  export {
1583
+ PROJECT_REMOVED_CHECKOUTS_DIRECTORY,
1478
1584
  PROJECT_WORKTREE_SNAPSHOT_PREFIX,
1479
1585
  ProjectBranchCreationRollbackIncompleteError,
1480
1586
  applyObservedProjectMirrorHeads,
1481
1587
  assertProjectMirrorHeadsPushed,
1482
- computeLocalProjectMirrorRefsToken,
1588
+ classifyProjectCheckout,
1589
+ computeLocalProjectMirrorRefsTokens,
1483
1590
  createLinkedProjectBranch,
1484
1591
  createOrRetryLinkedProjectBranch,
1485
1592
  deleteLinkedProjectBranch,
@@ -1487,6 +1594,7 @@ export {
1487
1594
  ensureProjectWorktrees,
1488
1595
  fastForwardProjectHeadsFromMirror,
1489
1596
  hasLinkedProjectWorktreeLayout,
1597
+ inventoryProjectCheckouts,
1490
1598
  observeProjectMirrorHeads,
1491
1599
  projectBranchMayExistAfterCreateFailure,
1492
1600
  projectOriginBranchName,
@@ -1352,6 +1352,19 @@ ${result.stdout}`;
1352
1352
  }
1353
1353
  return revParse(workspacePath, `refs/remotes/origin/${WORKSPACE_GIT_BRANCH}`);
1354
1354
  }
1355
+ async function resolveWorkspaceRemoteHead(workspacePath, input) {
1356
+ const advertised = input.advertisedRemoteHead;
1357
+ if (advertised && /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/.test(advertised)) {
1358
+ const tracked = await revParseAsync(workspacePath, `refs/remotes/origin/${WORKSPACE_GIT_BRANCH}`);
1359
+ if (tracked === advertised && await tryGitAsync(workspacePath, ["cat-file", "-e", `${tracked}^{commit}`])) {
1360
+ return { head: tracked, fetched: false };
1361
+ }
1362
+ }
1363
+ return {
1364
+ head: await fetchWorkspaceHead(workspacePath, input.remoteUrl, input.credentialHelper, input.credentialUsername),
1365
+ fetched: true
1366
+ };
1367
+ }
1355
1368
  async function ensureWorkspaceGitClone(input) {
1356
1369
  const workspacePath = path.resolve(input.workspacePath);
1357
1370
  const workspaceParent = path.dirname(workspacePath);
@@ -1399,7 +1412,8 @@ async function ensureWorkspaceGitClone(input) {
1399
1412
  if (!await revParseAsync(workspacePath, WORKSPACE_GIT_INTEGRATED_REF) && previousRemoteHead && localHeadBeforeFetch && await tryGitAsync(workspacePath, ["merge-base", "--is-ancestor", previousRemoteHead, localHeadBeforeFetch])) {
1400
1413
  await updateIntegratedWorkspaceHead(workspacePath, previousRemoteHead);
1401
1414
  }
1402
- const remoteHead = await fetchWorkspaceHead(workspacePath, input.remoteUrl, input.credentialHelper, input.credentialUsername);
1415
+ const resolvedRemoteHead = await resolveWorkspaceRemoteHead(workspacePath, input);
1416
+ const remoteHead = resolvedRemoteHead.head;
1403
1417
  let localHead = await revParseAsync(workspacePath, "HEAD");
1404
1418
  if (!localHead && remoteHead) {
1405
1419
  await runWorkspaceCheckoutTransition(workspacePath, async () => {
@@ -1420,7 +1434,7 @@ async function ensureWorkspaceGitClone(input) {
1420
1434
  await updateIntegratedWorkspaceHead(workspacePath, remoteHead);
1421
1435
  localHead = remoteHead;
1422
1436
  }
1423
- return { localHead, remoteHead };
1437
+ return { localHead, remoteHead, remoteHeadFetched: resolvedRemoteHead.fetched };
1424
1438
  }
1425
1439
  function activeMounts(mounts) {
1426
1440
  const active = [];
@@ -2069,6 +2083,11 @@ async function synchronizeWithFetchedHead(input) {
2069
2083
  return rebased.ok ? { kind: "ready", updated: false, rebased: true } : { kind: "conflict", conflictPaths: rebased.conflictPaths, refs: rebased.refs, error: rebased.error };
2070
2084
  }
2071
2085
  async function synchronizeWorkspaceGit(input) {
2086
+ const remoteHeadFetches = { count: 0 };
2087
+ const result = await runWorkspaceGitSynchronization(input, remoteHeadFetches);
2088
+ return { ...result, remoteHeadFetches: remoteHeadFetches.count };
2089
+ }
2090
+ async function runWorkspaceGitSynchronization(input, remoteHeadFetches) {
2072
2091
  const workspacePath = path.resolve(input.workspacePath);
2073
2092
  const attemptId = input.attemptId ?? crypto.randomUUID();
2074
2093
  const maxDiffBytes = input.maxDiffBytes ?? MAX_WORKSPACE_GIT_DIFF_BYTES;
@@ -2077,6 +2096,7 @@ async function synchronizeWorkspaceGit(input) {
2077
2096
  const requiredAncestorHeads = requiredWorkspaceAncestorHeads(input.requiredAncestorHeads);
2078
2097
  const preserveResolutionInProgress = input.skipMountMirror === true;
2079
2098
  const initial = await ensureWorkspaceGitClone({ ...input, workspacePath, preserveResolutionInProgress });
2099
+ if (initial.remoteHeadFetched) remoteHeadFetches.count += 1;
2080
2100
  assertWorkspaceRemediationAncestry({
2081
2101
  workspacePath,
2082
2102
  currentHead: initial.localHead,
@@ -2124,11 +2144,22 @@ async function synchronizeWorkspaceGit(input) {
2124
2144
  const classifyMounts = (activeCandidates, additionalSkippedIds = []) => {
2125
2145
  refreshCycleSkippedMounts();
2126
2146
  const skippedIds = /* @__PURE__ */ new Set([...cycleSkippedMountIds, ...projectionSkippedIds, ...additionalSkippedIds]);
2147
+ const activeMountIds = [...new Set(activeCandidates.filter(({ id }) => !skippedIds.has(id)).map(({ id }) => id))].sort();
2127
2148
  return {
2128
- activeMountIds: [...new Set(activeCandidates.filter(({ id }) => !skippedIds.has(id)).map(({ id }) => id))].sort(),
2129
- skippedMountIds: [...skippedIds].sort()
2149
+ activeMountIds,
2150
+ skippedMountIds: [...skippedIds].sort(),
2151
+ // An active mount's token is unchanged since capture (a moved token
2152
+ // lands it in cycleSkippedMountIds above), so this is the token its
2153
+ // projected bytes belong to.
2154
+ projectedMutationTokens: Object.fromEntries(
2155
+ activeMountIds.flatMap((id) => {
2156
+ const token = projectionMutationTokens.get(id);
2157
+ return token === void 0 ? [] : [[id, token]];
2158
+ })
2159
+ )
2130
2160
  };
2131
2161
  };
2162
+ const mountProjectionIsCurrent = (mount) => mount.lastProjectedMutationToken !== void 0 && !mount.deleteWhenSourceMissing && mount.preserveLocalOnHydrationBasisChange !== true && projectionBasisHead !== null && workspaceHydrationReceiptMountBasisHead(projectionBasisReceipt, mount) === projectionBasisHead && projectionMutationTokens.get(mount.id) === mount.lastProjectedMutationToken && mount.mutationToken?.() === mount.lastProjectedMutationToken;
2132
2163
  if (!input.skipMountMirror) {
2133
2164
  const mergeProjectionEnabled = process.env.R5D_WORKSPACE_MERGE_PROJECTION !== "0";
2134
2165
  const mergeSupport = mergeProjectionEnabled ? workspaceMergeProjectionSupport() : { supported: false, error: "Workspace merge projection is disabled by R5D_WORKSPACE_MERGE_PROJECTION=0" };
@@ -2267,6 +2298,7 @@ async function synchronizeWorkspaceGit(input) {
2267
2298
  }
2268
2299
  };
2269
2300
  const projectedFastMounts = [];
2301
+ const currentProjectionMounts = [];
2270
2302
  if (!input.skipMountMirror) {
2271
2303
  beginWorkspaceCheckoutTransition(workspacePath);
2272
2304
  try {
@@ -2278,6 +2310,10 @@ async function synchronizeWorkspaceGit(input) {
2278
2310
  cycleSkippedMountIds.add(mount.id);
2279
2311
  continue;
2280
2312
  }
2313
+ if (mountProjectionIsCurrent(mount)) {
2314
+ currentProjectionMounts.push(mount);
2315
+ continue;
2316
+ }
2281
2317
  projectedGitlinks.push(...mirrorMountsToWorkspace(workspacePath, [mount]));
2282
2318
  projectedFastMounts.push(mount);
2283
2319
  await yieldToWorkerEventLoop();
@@ -2311,7 +2347,7 @@ async function synchronizeWorkspaceGit(input) {
2311
2347
  const targetProjectionReceipt = receiptWithMountsAtHead(
2312
2348
  projectionBasisReceipt,
2313
2349
  projectedHead,
2314
- projectedFastMounts,
2350
+ [...projectedFastMounts, ...currentProjectionMounts],
2315
2351
  allProjectionReceiptMounts
2316
2352
  );
2317
2353
  const currentProjectionReceipt = await readHydratedWorkspaceReceiptAsync(workspacePath);
@@ -2324,7 +2360,9 @@ async function synchronizeWorkspaceGit(input) {
2324
2360
  }
2325
2361
  await completeWorkspaceCheckoutTransition(workspacePath);
2326
2362
  }
2327
- let remoteHead = await fetchWorkspaceHead(workspacePath, input.remoteUrl, input.credentialHelper, input.credentialUsername);
2363
+ const prePublishRemoteHead = await resolveWorkspaceRemoteHead(workspacePath, input);
2364
+ if (prePublishRemoteHead.fetched) remoteHeadFetches.count += 1;
2365
+ let remoteHead = prePublishRemoteHead.head;
2328
2366
  let rebaseCount = 0;
2329
2367
  let updated = false;
2330
2368
  for (let pushAttempt = 0; pushAttempt < maxPushAttempts; pushAttempt += 1) {
@@ -2481,6 +2519,7 @@ ${push.stdout}`)) {
2481
2519
  throw new Error(`Push workspace main: ${push.stderr || push.stdout || `git exited ${push.exitCode}`}`);
2482
2520
  }
2483
2521
  remoteHead = await fetchWorkspaceHead(workspacePath, input.remoteUrl, input.credentialHelper, input.credentialUsername);
2522
+ remoteHeadFetches.count += 1;
2484
2523
  }
2485
2524
  throw new Error(`Workspace push did not converge after ${maxPushAttempts} attempts`);
2486
2525
  }