@ebay-dt/bonfire-node 0.2.0-alpha.10 → 0.2.0-alpha.11

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.
Files changed (45) hide show
  1. package/README.md +18 -5
  2. package/dist/deployment-provider.d.ts +1 -1
  3. package/dist/deployment-provider.d.ts.map +1 -1
  4. package/dist/deployment-provider.js +1 -1
  5. package/dist/deployment-provider.js.map +1 -1
  6. package/dist/duplicate.d.ts.map +1 -1
  7. package/dist/duplicate.js +103 -47
  8. package/dist/duplicate.js.map +1 -1
  9. package/dist/git-repository.d.ts +14 -1
  10. package/dist/git-repository.d.ts.map +1 -1
  11. package/dist/git-repository.js +99 -3
  12. package/dist/git-repository.js.map +1 -1
  13. package/dist/index.d.ts +1 -0
  14. package/dist/index.d.ts.map +1 -1
  15. package/dist/index.js +1 -0
  16. package/dist/index.js.map +1 -1
  17. package/dist/internal/prototype-templates.d.ts +0 -1
  18. package/dist/internal/prototype-templates.d.ts.map +1 -1
  19. package/dist/internal/prototype-templates.js +0 -5
  20. package/dist/internal/prototype-templates.js.map +1 -1
  21. package/dist/lifecycle/deployment-worktree.d.ts +5 -4
  22. package/dist/lifecycle/deployment-worktree.d.ts.map +1 -1
  23. package/dist/lifecycle/deployment-worktree.js +269 -68
  24. package/dist/lifecycle/deployment-worktree.js.map +1 -1
  25. package/dist/lifecycle/local-prototype-lifecycle.d.ts +13 -1
  26. package/dist/lifecycle/local-prototype-lifecycle.d.ts.map +1 -1
  27. package/dist/lifecycle/local-prototype-lifecycle.js +634 -110
  28. package/dist/lifecycle/local-prototype-lifecycle.js.map +1 -1
  29. package/dist/lifecycle/preparation-marker.d.ts +12 -0
  30. package/dist/lifecycle/preparation-marker.d.ts.map +1 -0
  31. package/dist/lifecycle/preparation-marker.js +76 -0
  32. package/dist/lifecycle/preparation-marker.js.map +1 -0
  33. package/dist/lifecycle/prototype-path-policy.d.ts +18 -0
  34. package/dist/lifecycle/prototype-path-policy.d.ts.map +1 -0
  35. package/dist/lifecycle/prototype-path-policy.js +429 -0
  36. package/dist/lifecycle/prototype-path-policy.js.map +1 -0
  37. package/dist/lifecycle/repository-summary.d.ts +5 -1
  38. package/dist/lifecycle/repository-summary.d.ts.map +1 -1
  39. package/dist/lifecycle/repository-summary.js +6 -0
  40. package/dist/lifecycle/repository-summary.js.map +1 -1
  41. package/dist/lifecycle/repository-synchronization.d.ts +13 -9
  42. package/dist/lifecycle/repository-synchronization.d.ts.map +1 -1
  43. package/dist/lifecycle/repository-synchronization.js +670 -173
  44. package/dist/lifecycle/repository-synchronization.js.map +1 -1
  45. package/package.json +2 -2
@@ -1,6 +1,8 @@
1
- import { lstat } from "node:fs/promises";
2
- import { isAbsolute, resolve } from "node:path";
3
- import { GitRepositoryError, restorePathFromRef, } from "../git-repository.js";
1
+ import { lstat, mkdir, mkdtemp, open, rm, writeFile } from "node:fs/promises";
2
+ import { tmpdir } from "node:os";
3
+ import { dirname, isAbsolute, join, relative, resolve } from "node:path";
4
+ import { GitRepositoryError } from "../git-repository.js";
5
+ import { isProtectedPrototypePath, isRegularPrototypeMode, } from "./prototype-path-policy.js";
4
6
  const OPERATION_MARKERS = [
5
7
  "MERGE_HEAD",
6
8
  "CHERRY_PICK_HEAD",
@@ -8,6 +10,17 @@ const OPERATION_MARKERS = [
8
10
  "rebase-merge",
9
11
  "rebase-apply",
10
12
  ];
13
+ const DEPENDENCY_NAMES = new Set([
14
+ "package.json",
15
+ "pnpm-lock.yaml",
16
+ "pnpm-lock.yml",
17
+ "pnpm-workspace.yaml",
18
+ "package-lock.json",
19
+ "npm-shrinkwrap.json",
20
+ "yarn.lock",
21
+ "bun.lock",
22
+ "bun.lockb",
23
+ ]);
11
24
  function assertBranch(value, option) {
12
25
  if (value.length === 0 ||
13
26
  value.startsWith("-") ||
@@ -18,14 +31,34 @@ function assertBranch(value, option) {
18
31
  throw new GitRepositoryError("invalid-configuration", option);
19
32
  }
20
33
  }
34
+ function normalize(path) {
35
+ return path.replaceAll("\\", "/").replace(/^\.\//u, "");
36
+ }
37
+ function namespaceFor(repository, root, login) {
38
+ if (root.length === 0 ||
39
+ root.includes("\0") ||
40
+ root.split(/[\\/]/u).some((segment) => segment === "..")) {
41
+ throw new GitRepositoryError("invalid-configuration", "prototype-root");
42
+ }
43
+ assertBranch(login, "identity-login");
44
+ const absolute = isAbsolute(root)
45
+ ? resolve(root, login)
46
+ : resolve(repository.repositoryRoot, root, login);
47
+ const relativePath = normalize(relative(repository.repositoryRoot, absolute));
48
+ if (relativePath.length === 0 ||
49
+ relativePath === ".." ||
50
+ relativePath.startsWith("../")) {
51
+ throw new GitRepositoryError("invalid-configuration", "prototype-root");
52
+ }
53
+ return relativePath;
54
+ }
21
55
  function remoteRef(remote, branch) {
22
56
  return `remotes/${remote}/${branch}`;
23
57
  }
24
58
  function branchRef(branch) {
25
59
  return `refs/heads/${branch}`;
26
60
  }
27
- async function markerExists(repository, marker) {
28
- const markerPath = (await repository.raw(["rev-parse", "--git-path", marker])).trim();
61
+ async function markerPathExists(repository, markerPath) {
29
62
  if (markerPath.length === 0)
30
63
  return false;
31
64
  const absolutePath = isAbsolute(markerPath)
@@ -39,121 +72,337 @@ async function markerExists(repository, marker) {
39
72
  if (error &&
40
73
  typeof error === "object" &&
41
74
  "code" in error &&
42
- error.code === "ENOENT") {
75
+ error.code === "ENOENT")
43
76
  return false;
44
- }
45
77
  throw error;
46
78
  }
47
79
  }
48
- export async function hasUnresolvedRepositoryOperation(repository) {
49
- for (const marker of OPERATION_MARKERS) {
50
- if (await markerExists(repository, marker))
80
+ /** Resolve every marker path in one Git process; each is still checked on disk. */
81
+ async function anyMarkerExists(repository, markers) {
82
+ const markerPaths = (await repository.raw([
83
+ "rev-parse",
84
+ ...markers.flatMap((marker) => ["--git-path", marker]),
85
+ ]))
86
+ .split("\n")
87
+ .map((markerPath) => markerPath.trim());
88
+ for (const markerPath of markerPaths)
89
+ if (await markerPathExists(repository, markerPath))
51
90
  return true;
52
- }
53
- const status = await repository.status();
54
- return hasUnmergedFiles(status.files);
91
+ return false;
55
92
  }
56
93
  function hasUnmergedFiles(files) {
57
94
  return files.some((file) => file.index.includes("U") || file.workingDirectory.includes("U"));
58
95
  }
96
+ export async function hasUnresolvedRepositoryOperation(repository) {
97
+ if (await anyMarkerExists(repository, OPERATION_MARKERS))
98
+ return true;
99
+ return hasUnmergedFiles((await repository.status()).files);
100
+ }
101
+ function assertNoUnresolved(status) {
102
+ if (hasUnmergedFiles(status.files))
103
+ throw new GitRepositoryError("conflict", "unresolved-operation");
104
+ }
59
105
  async function refCommit(repository, ref) {
60
- const value = await repository.raw([
61
- "rev-parse",
62
- "--verify",
63
- `${ref}^{commit}`,
64
- ]);
65
- const commit = value.trim();
66
- if (!/^[0-9a-f]{40}$/iu.test(commit)) {
106
+ const value = (await repository.raw(["rev-parse", "--verify", `${ref}^{commit}`])).trim();
107
+ if (!/^[0-9a-f]{40}$/iu.test(value))
67
108
  throw new GitRepositoryError("malformed-output", "ref-commit");
109
+ return value;
110
+ }
111
+ /** Hold Git's own ref lock so another Git process cannot move a checkout target. */
112
+ async function withLockedBranch(repository, branch, action) {
113
+ const gitPath = (await repository.raw([
114
+ "rev-parse",
115
+ "--git-path",
116
+ `${branchRef(branch)}.lock`,
117
+ ])).trim();
118
+ if (gitPath.length === 0)
119
+ throw new GitRepositoryError("malformed-output", "branch-lock");
120
+ const lockPath = resolve(repository.repositoryRoot, gitPath);
121
+ await mkdir(dirname(lockPath), { recursive: true });
122
+ // Exclusive creation also respects a lock already held by an external writer.
123
+ const lock = await open(lockPath, "wx");
124
+ try {
125
+ await action();
126
+ }
127
+ finally {
128
+ await lock.close();
129
+ await rm(lockPath);
68
130
  }
69
- return commit;
70
131
  }
71
132
  async function compareRefs(repository, left, right) {
72
- const output = await repository.raw([
133
+ const values = (await repository.raw([
73
134
  "rev-list",
74
135
  "--left-right",
75
136
  "--count",
76
137
  `${left}...${right}`,
77
- ]);
78
- const values = output.trim().split(/\s+/u).map(Number);
138
+ ]))
139
+ .trim()
140
+ .split(/\s+/u)
141
+ .map(Number);
79
142
  if (values.length !== 2 ||
80
143
  values.some((value) => !Number.isSafeInteger(value) || value < 0)) {
81
144
  throw new GitRepositoryError("malformed-output", "ref-relation");
82
145
  }
83
146
  return { ahead: values[0], behind: values[1] };
84
147
  }
85
- async function safelyAdvanceLocalPrimary(repository, primaryBranch, primaryRemoteRef, localPrimaryCommit) {
86
- await repository.raw([
87
- "update-ref",
88
- branchRef(primaryBranch),
89
- primaryRemoteRef,
90
- localPrimaryCommit,
91
- ]);
148
+ function pathsFromStatus(files) {
149
+ return [
150
+ ...new Set(files.flatMap((file) => [
151
+ normalize(file.path),
152
+ ...(file.from ? [normalize(file.from)] : []),
153
+ ])),
154
+ ];
155
+ }
156
+ function isWithin(path, parent) {
157
+ return path === parent || path.startsWith(`${parent}/`);
158
+ }
159
+ function dependencyPath(path) {
160
+ const name = path.slice(path.lastIndexOf("/") + 1);
161
+ return DEPENDENCY_NAMES.has(name);
92
162
  }
93
- function deferred(branch, reason, affectedPaths = []) {
163
+ function resultBase(branch, values = {}) {
94
164
  return {
95
- status: "deferred",
96
165
  branch,
97
- changed: false,
166
+ changed: values.changed ?? false,
98
167
  pushed: false,
99
- affectedPaths,
100
- reason,
168
+ affectedPaths: values.affectedPaths ?? [],
169
+ platformPaths: values.platformPaths ?? [],
170
+ dependencyPaths: values.dependencyPaths ?? [],
171
+ restartRequired: values.restartRequired ?? false,
172
+ preparationRequired: values.preparationRequired ?? false,
101
173
  };
102
174
  }
103
- function unsafe(branch, reason, options = {}) {
104
- return {
105
- status: "unsafe",
106
- branch,
107
- changed: options.changed ?? false,
108
- pushed: options.pushed ?? false,
109
- affectedPaths: options.affectedPaths ?? [],
110
- reason,
111
- };
175
+ function deferred(branch, reason, values = {}) {
176
+ return { ...resultBase(branch, values), status: "deferred", reason };
112
177
  }
113
- export async function assertIdentityBranchReady(repository, identityBranch) {
114
- assertBranch(identityBranch, "identity-branch");
115
- const currentBranch = await repository.currentBranch();
116
- if (currentBranch === "HEAD" || currentBranch.length === 0) {
117
- throw new GitRepositoryError("conflict", "current-branch");
178
+ function unsafe(branch, reason, values = {}) {
179
+ return { ...resultBase(branch, values), status: "unsafe", reason };
180
+ }
181
+ function parseTree(output) {
182
+ return output
183
+ .split("\0")
184
+ .filter(Boolean)
185
+ .flatMap((entry) => {
186
+ const tab = entry.indexOf("\t");
187
+ if (tab < 0)
188
+ return [];
189
+ const header = entry.slice(0, tab).split(/\s+/u);
190
+ const mode = header[0];
191
+ const object = header[2];
192
+ const path = normalize(entry.slice(tab + 1));
193
+ return mode && object && path ? [{ mode, object, path }] : [];
194
+ });
195
+ }
196
+ async function treeEntries(repository, tree, namespace) {
197
+ const args = ["ls-tree", "-r", "-z", tree];
198
+ if (namespace)
199
+ args.push("--", namespace);
200
+ return parseTree(await repository.raw(args));
201
+ }
202
+ function parseIndex(output) {
203
+ return output
204
+ .split("\0")
205
+ .filter(Boolean)
206
+ .flatMap((entry) => {
207
+ const tab = entry.indexOf("\t");
208
+ if (tab < 0)
209
+ return [];
210
+ const header = entry.slice(0, tab).split(/\s+/u);
211
+ const [mode, object, stage] = header;
212
+ const path = normalize(entry.slice(tab + 1));
213
+ return mode && object && stage && path
214
+ ? [{ mode, object, stage, path }]
215
+ : [];
216
+ });
217
+ }
218
+ async function rawWithIndex(repository, indexFile, args) {
219
+ if (!repository.rawWithEnv)
220
+ throw new GitRepositoryError("invalid-configuration", "private-index");
221
+ return repository.rawWithEnv(args, { GIT_INDEX_FILE: indexFile });
222
+ }
223
+ async function privateTree(repository, baseTree, namespace, replacement) {
224
+ const directory = await mkdtemp(join(tmpdir(), "bonfire-index-"));
225
+ const indexFile = join(directory, "index");
226
+ try {
227
+ await rawWithIndex(repository, indexFile, [
228
+ "read-tree",
229
+ "--reset",
230
+ baseTree,
231
+ ]);
232
+ const loaded = parseIndex(await rawWithIndex(repository, indexFile, [
233
+ "ls-files",
234
+ "-s",
235
+ "-z",
236
+ "--",
237
+ namespace,
238
+ ]));
239
+ for (const entry of loaded) {
240
+ await rawWithIndex(repository, indexFile, [
241
+ "update-index",
242
+ "--force-remove",
243
+ "--",
244
+ entry.path,
245
+ ]);
246
+ }
247
+ for (const entry of replacement) {
248
+ if (entry.stage !== "0")
249
+ throw new GitRepositoryError("conflict", "unmerged-index");
250
+ await rawWithIndex(repository, indexFile, [
251
+ "update-index",
252
+ "--add",
253
+ "--cacheinfo",
254
+ `${entry.mode},${entry.object},${entry.path}`,
255
+ ]);
256
+ }
257
+ return (await rawWithIndex(repository, indexFile, ["write-tree"])).trim();
118
258
  }
119
- if (currentBranch !== identityBranch) {
120
- throw new GitRepositoryError("conflict", "identity-branch");
259
+ finally {
260
+ await rm(directory, { recursive: true, force: true });
121
261
  }
122
- if (await hasUnresolvedRepositoryOperation(repository)) {
123
- throw new GitRepositoryError("conflict", "unresolved-operation");
262
+ }
263
+ /** Replace the complete namespace, rather than only paths found in HEAD. */
264
+ async function overlayIndexNamespace(repository, baseTree, namespace, replacement) {
265
+ return privateTree(repository, baseTree, namespace, replacement);
266
+ }
267
+ async function indexNamespace(repository, namespace) {
268
+ return parseIndex(await repository.raw(["ls-files", "-s", "-z", "--", namespace]));
269
+ }
270
+ async function composeTree(repository, primary, ownTree, namespace) {
271
+ // The replacement is loaded from the committed identity tree. In
272
+ // particular, removing the complete namespace of `primary` prevents
273
+ // primary-only paths from leaking into the designer-owned baseline.
274
+ return overlayIndexNamespace(repository, primary, namespace, (await treeEntries(repository, ownTree, namespace)).map((entry) => ({
275
+ ...entry,
276
+ stage: "0",
277
+ })));
278
+ }
279
+ async function treeChanges(repository, from, to, namespace) {
280
+ const output = await repository.raw([
281
+ "diff",
282
+ "--name-only",
283
+ "-z",
284
+ `${from}..${to}`,
285
+ "--",
286
+ ".",
287
+ ]);
288
+ return output
289
+ .split("\0")
290
+ .filter(Boolean)
291
+ .map(normalize)
292
+ .filter((path) => namespace === undefined || !isWithin(path, namespace));
293
+ }
294
+ async function platformChanges(repository, head, primary, namespace) {
295
+ return treeChanges(repository, head, primary, namespace);
296
+ }
297
+ async function filesystemPlatformCollision(repository, head, primary, paths) {
298
+ const headEntries = new Map((await treeEntries(repository, head)).map((entry) => [entry.path, entry]));
299
+ const primaryEntries = new Map((await treeEntries(repository, primary)).map((entry) => [
300
+ entry.path,
301
+ entry,
302
+ ]));
303
+ const collisions = [];
304
+ for (const path of paths) {
305
+ const segments = path.split("/");
306
+ for (let index = 1; index < segments.length; index += 1) {
307
+ const ancestor = segments.slice(0, index).join("/");
308
+ try {
309
+ const stats = await lstat(resolve(repository.repositoryRoot, ...ancestor.split("/")));
310
+ if (stats.isSymbolicLink() || !stats.isDirectory())
311
+ collisions.push(path);
312
+ }
313
+ catch (error) {
314
+ if (!(error &&
315
+ typeof error === "object" &&
316
+ "code" in error &&
317
+ error.code === "ENOENT"))
318
+ collisions.push(path);
319
+ }
320
+ }
321
+ const before = headEntries.get(path);
322
+ const after = primaryEntries.get(path);
323
+ const absolute = resolve(repository.repositoryRoot, ...path.split("/"));
324
+ let current;
325
+ try {
326
+ current = await lstat(absolute);
327
+ }
328
+ catch (error) {
329
+ if (error &&
330
+ typeof error === "object" &&
331
+ "code" in error &&
332
+ error.code === "ENOENT")
333
+ continue;
334
+ collisions.push(path);
335
+ continue;
336
+ }
337
+ // A path absent from HEAD is not necessarily safe merely because status
338
+ // omits it: ignored files and symlink/special parents are real state.
339
+ const trackedRegular = before !== undefined && isRegularPrototypeMode(before.mode);
340
+ const desiredRegular = after !== undefined && isRegularPrototypeMode(after.mode);
341
+ if (current.isSymbolicLink() ||
342
+ (!current.isFile() && !current.isDirectory())) {
343
+ collisions.push(path);
344
+ continue;
345
+ }
346
+ if (current.isDirectory()) {
347
+ // A tracked file-to-directory replacement is only safe when the
348
+ // destination path is absent; even an empty directory can make restore
349
+ // fail midway, while a non-empty one may contain ignored state.
350
+ if (desiredRegular)
351
+ collisions.push(path);
352
+ continue;
353
+ }
354
+ // A tracked regular file may be removed by the guarded tree transition;
355
+ // an untracked/ignored file is never safe to overwrite or remove.
356
+ if (!trackedRegular)
357
+ collisions.push(path);
124
358
  }
125
- const status = await repository.status();
126
- if (hasUnmergedFiles(status.files)) {
359
+ return [...new Set(collisions)];
360
+ }
361
+ async function protectedPlatformCollision(repository, head, primary, namespace, paths, excludeNamespace = true) {
362
+ const headEntries = new Map((await treeEntries(repository, head)).map((entry) => [entry.path, entry]));
363
+ const primaryEntries = new Map((await treeEntries(repository, primary)).map((entry) => [
364
+ entry.path,
365
+ entry,
366
+ ]));
367
+ return paths.filter((path) => {
368
+ if (excludeNamespace && isWithin(path, namespace))
369
+ return false;
370
+ const before = headEntries.get(path);
371
+ const after = primaryEntries.get(path);
372
+ const typeChangedToUnsafe = (before !== undefined && !isRegularPrototypeMode(before.mode)) ||
373
+ (after !== undefined && !isRegularPrototypeMode(after.mode));
374
+ return (typeChangedToUnsafe ||
375
+ (isProtectedPrototypePath(path) &&
376
+ (before?.mode !== after?.mode || before?.object !== after?.object)));
377
+ });
378
+ }
379
+ export async function assertIdentityBranchReady(repository, identityBranch) {
380
+ assertBranch(identityBranch, "identity-branch");
381
+ const branch = await repository.currentBranch();
382
+ if (branch === "HEAD" || branch.length === 0 || branch !== identityBranch)
383
+ throw new GitRepositoryError("conflict", "identity-branch");
384
+ if (await hasUnresolvedRepositoryOperation(repository))
127
385
  throw new GitRepositoryError("conflict", "unresolved-operation");
128
- }
129
386
  }
130
387
  /**
131
- * Synchronize an identity branch from primary without checking out local
132
- * primary. Remote-tracking refs are the source of truth whenever a configured
133
- * remote is available. The result distinguishes non-blocking attention from
134
- * unsafe repository state so callers can keep authentication/read-only flows
135
- * available.
388
+ * Reconcile only the committed platform baseline. Tree composition uses
389
+ * private indexes; the guarded Git index/worktree patch then updates the
390
+ * active index and platform worktree while leaving the namespace layer alone.
136
391
  */
137
392
  export async function synchronizeIdentityBranch(repository, options) {
138
393
  assertBranch(options.identityBranch, "identity-branch");
139
394
  assertBranch(options.primaryBranch, "primary-branch");
140
395
  assertBranch(options.remote, "remote");
141
- if (options.identityBranch === options.primaryBranch) {
396
+ if (options.identityBranch === options.primaryBranch)
142
397
  throw new GitRepositoryError("invalid-configuration", "identity-primary-branch");
143
- }
144
- const currentBranch = await repository.currentBranch();
145
- if (currentBranch === "HEAD" || currentBranch.length === 0) {
398
+ const namespace = namespaceFor(repository, options.prototypeRoot, options.identityBranch);
399
+ let currentBranch = await repository.currentBranch();
400
+ if (currentBranch === "HEAD" || currentBranch.length === 0)
146
401
  return unsafe(null, "detached-head");
147
- }
148
- if (await hasUnresolvedRepositoryOperation(repository)) {
402
+ const originalBranch = currentBranch;
403
+ const originalHead = await refCommit(repository, "HEAD");
404
+ if (await hasUnresolvedRepositoryOperation(repository))
149
405
  return unsafe(currentBranch, "unresolved-operation");
150
- }
151
- let status = await repository.status();
152
- if (hasUnmergedFiles(status.files)) {
153
- return unsafe(currentBranch, "unresolved-operation", {
154
- affectedPaths: status.files.map((file) => file.path),
155
- });
156
- }
157
406
  const configuredRemote = (await repository.remotes()).some((remote) => remote.name === options.remote);
158
407
  if (configuredRemote) {
159
408
  try {
@@ -161,154 +410,402 @@ export async function synchronizeIdentityBranch(repository, options) {
161
410
  }
162
411
  catch (error) {
163
412
  if (error instanceof GitRepositoryError &&
164
- error.kind === "invalid-configuration") {
413
+ error.kind === "invalid-configuration")
165
414
  throw error;
166
- }
167
415
  return deferred(currentBranch, "fetch-failed");
168
416
  }
169
417
  }
418
+ const afterFetchBranch = await repository.currentBranch();
419
+ if (afterFetchBranch !== currentBranch)
420
+ return unsafe(afterFetchBranch, "branch-changed");
421
+ currentBranch = afterFetchBranch;
422
+ if (await hasUnresolvedRepositoryOperation(repository))
423
+ return unsafe(currentBranch, "unresolved-operation");
170
424
  const branches = await repository.branches();
171
425
  const localPrimary = branches.all.includes(options.primaryBranch);
172
426
  const primaryRemote = remoteRef(options.remote, options.primaryBranch);
173
427
  const remotePrimary = configuredRemote && branches.all.includes(primaryRemote);
174
- if (configuredRemote ? !remotePrimary : !localPrimary) {
428
+ if (configuredRemote ? !remotePrimary : !localPrimary)
175
429
  return unsafe(currentBranch, "missing-primary");
176
- }
430
+ const sourceRef = remotePrimary ? primaryRemote : options.primaryBranch;
431
+ // Pin the source before planning. Never compose from a mutable remote ref.
432
+ const primarySha = await refCommit(repository, sourceRef);
177
433
  const localIdentity = branches.all.includes(options.identityBranch);
178
434
  const identityRemote = remoteRef(options.remote, options.identityBranch);
179
435
  const remoteIdentity = configuredRemote && branches.all.includes(identityRemote);
436
+ // Match the legacy branch-preparation behavior: a remote-only identity is
437
+ // based on its remote namespace, not silently recreated from primary.
438
+ const identityBaseRef = localIdentity
439
+ ? options.identityBranch
440
+ : remoteIdentity
441
+ ? identityRemote
442
+ : sourceRef;
180
443
  if (localIdentity && remoteIdentity) {
181
444
  const relation = await compareRefs(repository, options.identityBranch, identityRemote);
182
- if (relation.ahead > 0 && relation.behind > 0) {
445
+ if (relation.ahead > 0 && relation.behind > 0)
183
446
  return unsafe(currentBranch, "identity-divergence");
184
- }
185
447
  }
186
- let primaryAdvance;
187
- if (localPrimary && remotePrimary) {
188
- const relation = await compareRefs(repository, options.primaryBranch, primaryRemote);
189
- if (relation.ahead === 0 && relation.behind > 0) {
190
- primaryAdvance = {
191
- localCommit: await refCommit(repository, options.primaryBranch),
192
- remoteRef: primaryRemote,
193
- };
194
- }
448
+ let status = await repository.status();
449
+ assertNoUnresolved(status);
450
+ const dirtyPaths = pathsFromStatus(status.files);
451
+ const dirtyOutside = dirtyPaths.filter((path) => !isWithin(path, namespace));
452
+ if (dirtyOutside.length > 0 || (!status.clean && dirtyPaths.length === 0)) {
453
+ return deferred(currentBranch, "dirty-worktree", {
454
+ affectedPaths: dirtyPaths,
455
+ });
195
456
  }
196
- const sourceRef = remotePrimary ? primaryRemote : options.primaryBranch;
197
- const managedPaths = new Set(options.managedPaths ?? []);
198
- const managedChanges = status.files.flatMap((file) => [file.path, ...(file.from === undefined ? [] : [file.from])].filter((path) => managedPaths.has(path)));
199
- if (managedChanges.length > 0) {
200
- for (const path of managedChanges) {
201
- await restorePathFromRef(repository, sourceRef, path);
202
- }
203
- status = await repository.status();
457
+ // A checkout is itself a tree transition. Even namespace-only dirt is not
458
+ // safe to carry across it: checkout can replace ignored files that status
459
+ // does not report. Defer before selecting/creating the identity branch.
460
+ if (!localIdentity && dirtyPaths.length > 0) {
461
+ return deferred(currentBranch, "dirty-worktree", {
462
+ affectedPaths: dirtyPaths,
463
+ });
464
+ }
465
+ const head = await refCommit(repository, "HEAD");
466
+ const primaryPaths = await platformChanges(repository, head, primarySha, namespace);
467
+ const protectedPaths = await protectedPlatformCollision(repository, head, primarySha, namespace, primaryPaths);
468
+ const filesystemPaths = await filesystemPlatformCollision(repository, head, primarySha, primaryPaths);
469
+ if (protectedPaths.length > 0 || filesystemPaths.length > 0)
470
+ return deferred(currentBranch, "protected-collision", {
471
+ affectedPaths: [...new Set([...protectedPaths, ...filesystemPaths])],
472
+ });
473
+ const targetHead = await refCommit(repository, identityBaseRef);
474
+ const checkoutPaths = targetHead === originalHead
475
+ ? []
476
+ : await treeChanges(repository, originalHead, targetHead);
477
+ const checkoutPlatformPaths = checkoutPaths.filter((path) => !isWithin(path, namespace));
478
+ const checkoutDependencyPaths = checkoutPlatformPaths.filter(dependencyPath);
479
+ if (targetHead !== originalHead) {
480
+ // This preflight includes the target identity namespace. In particular,
481
+ // ignored files absent from the current tree must not be overwritten by
482
+ // selecting an existing remote/local identity branch.
483
+ const checkoutCollisions = await filesystemPlatformCollision(repository, originalHead, targetHead, checkoutPaths);
484
+ const checkoutProtected = await protectedPlatformCollision(repository, originalHead, targetHead, namespace, checkoutPaths, false);
485
+ if (checkoutCollisions.length > 0 || checkoutProtected.length > 0)
486
+ return deferred(currentBranch, "protected-collision", {
487
+ affectedPaths: [
488
+ ...new Set([...checkoutCollisions, ...checkoutProtected]),
489
+ ],
490
+ });
204
491
  }
205
- const remainingDirtyPaths = status.files
206
- .flatMap((file) => [
207
- file.path,
208
- ...(file.from === undefined ? [] : [file.from]),
209
- ])
210
- .filter((path) => !managedPaths.has(path));
211
- if (remainingDirtyPaths.length > 0 ||
212
- (!status.clean && status.files.length === 0)) {
213
- return deferred(currentBranch, "dirty-worktree", remainingDirtyPaths);
492
+ if (currentBranch !== options.identityBranch && dirtyPaths.length > 0) {
493
+ return deferred(currentBranch, "dirty-worktree", {
494
+ affectedPaths: dirtyPaths,
495
+ });
214
496
  }
215
- let identityCreated = false;
216
497
  if (!localIdentity) {
217
- identityCreated = true;
218
- if (remoteIdentity) {
498
+ try {
499
+ // Create from the exact preflighted commit, never a moving remote ref.
500
+ // -b refuses a concurrently created local branch instead of replacing it.
219
501
  await repository.checkout([
502
+ "--no-overwrite-ignore",
503
+ "--no-track",
220
504
  "-b",
221
505
  options.identityBranch,
222
- "--track",
223
- `${options.remote}/${options.identityBranch}`,
506
+ targetHead,
224
507
  ]);
508
+ currentBranch = options.identityBranch;
509
+ if (remoteIdentity)
510
+ await repository.raw([
511
+ "branch",
512
+ `--set-upstream-to=${identityRemote}`,
513
+ options.identityBranch,
514
+ ]);
225
515
  }
226
- else {
227
- await repository.checkout(["-b", options.identityBranch, sourceRef]);
516
+ catch {
517
+ const observedBranch = await repository.currentBranch();
518
+ return unsafe(observedBranch || currentBranch, "reconciliation-failed", {
519
+ changed: observedBranch !== originalBranch,
520
+ affectedPaths: checkoutPaths,
521
+ platformPaths: checkoutPlatformPaths,
522
+ dependencyPaths: checkoutDependencyPaths,
523
+ restartRequired: checkoutPlatformPaths.length > 0,
524
+ preparationRequired: checkoutDependencyPaths.length > 0,
525
+ });
228
526
  }
229
527
  }
230
528
  else if (currentBranch !== options.identityBranch) {
231
- await repository.checkout([options.identityBranch]);
232
- }
233
- if (primaryAdvance && currentBranch !== options.primaryBranch) {
234
- await safelyAdvanceLocalPrimary(repository, options.primaryBranch, primaryAdvance.remoteRef, primaryAdvance.localCommit);
235
- }
236
- if (localIdentity && remoteIdentity) {
237
- const relation = await compareRefs(repository, options.identityBranch, identityRemote);
238
- if (relation.ahead === 0 && relation.behind > 0) {
239
- await repository.merge([identityRemote, "--ff-only"]);
240
- }
241
- }
242
- const beforeMerge = await refCommit(repository, options.identityBranch);
243
- try {
244
- await repository.merge([sourceRef, "--no-edit"]);
245
- }
246
- catch (error) {
247
- if (!(error instanceof GitRepositoryError) || error.kind !== "conflict") {
248
- return unsafe(options.identityBranch, "merge-failed");
249
- }
250
- let affectedPaths = [];
251
529
  try {
252
- affectedPaths = (await repository.status()).files.map((file) => file.path);
253
- }
254
- catch {
255
- // Preserve the conflict classification even if status cannot be read.
256
- }
257
- try {
258
- await repository.raw(["merge", "--abort"]);
530
+ await withLockedBranch(repository, options.identityBranch, async () => {
531
+ // Validation without the lock would leave another check/use race.
532
+ // Existing-branch checkout does not write the branch ref, so Git's ref
533
+ // lock can remain held throughout the checkout itself.
534
+ if ((await refCommit(repository, branchRef(options.identityBranch))) !==
535
+ targetHead ||
536
+ (await repository.currentBranch()) !== originalBranch ||
537
+ (await refCommit(repository, "HEAD")) !== originalHead ||
538
+ !(await repository.status()).clean)
539
+ throw new GitRepositoryError("conflict", "checkout-target-changed");
540
+ await repository.checkout([
541
+ "--no-overwrite-ignore",
542
+ options.identityBranch,
543
+ ]);
544
+ currentBranch = options.identityBranch;
545
+ });
259
546
  }
260
547
  catch {
261
- return unsafe(options.identityBranch, "failed-abort", {
262
- affectedPaths,
548
+ const observedBranch = await repository.currentBranch();
549
+ return unsafe(observedBranch || currentBranch, "reconciliation-failed", {
550
+ changed: observedBranch !== originalBranch,
551
+ affectedPaths: checkoutPaths,
552
+ platformPaths: checkoutPlatformPaths,
553
+ dependencyPaths: checkoutDependencyPaths,
554
+ restartRequired: checkoutPlatformPaths.length > 0,
555
+ preparationRequired: checkoutDependencyPaths.length > 0,
263
556
  });
264
557
  }
265
- return deferred(options.identityBranch, "merge-conflict", affectedPaths);
266
558
  }
267
- const afterMerge = await refCommit(repository, options.identityBranch);
268
- const changed = beforeMerge !== afterMerge;
269
- const mergedPaths = changed
270
- ? (await repository.raw([
271
- "diff",
272
- "--name-only",
273
- "-z",
274
- `${beforeMerge}..${afterMerge}`,
275
- ]))
276
- .split("\0")
277
- .filter((path) => path.length > 0)
278
- : [];
279
- const affectedPaths = [
559
+ // Re-read after branch movement and before touching the index. This catches
560
+ // an external command or a fetch hook that changed the checkout meanwhile.
561
+ const branchBeforeMutation = await repository.currentBranch();
562
+ const checkoutChanged = currentBranch !== originalBranch;
563
+ const checkoutEffects = {
564
+ changed: checkoutChanged,
565
+ affectedPaths: checkoutPaths,
566
+ platformPaths: checkoutPlatformPaths,
567
+ dependencyPaths: checkoutDependencyPaths,
568
+ restartRequired: checkoutPlatformPaths.length > 0,
569
+ preparationRequired: checkoutDependencyPaths.length > 0,
570
+ };
571
+ if (branchBeforeMutation !== currentBranch ||
572
+ branchBeforeMutation === "HEAD") {
573
+ return unsafe(branchBeforeMutation || null, "branch-changed", {
574
+ ...checkoutEffects,
575
+ });
576
+ }
577
+ status = await repository.status();
578
+ if (!status.clean &&
579
+ pathsFromStatus(status.files).some((path) => !isWithin(path, namespace))) {
580
+ return deferred(currentBranch, "dirty-worktree", {
581
+ ...checkoutEffects,
582
+ affectedPaths: [
583
+ ...new Set([...checkoutPaths, ...pathsFromStatus(status.files)]),
584
+ ],
585
+ });
586
+ }
587
+ if (await hasUnresolvedRepositoryOperation(repository))
588
+ return unsafe(currentBranch, "unresolved-operation", checkoutEffects);
589
+ const latestHead = await refCommit(repository, "HEAD");
590
+ if (latestHead !== targetHead)
591
+ return unsafe(currentBranch, "reconciliation-failed", {
592
+ ...checkoutEffects,
593
+ changed: true,
594
+ });
595
+ const latestPrimarySha = await refCommit(repository, sourceRef);
596
+ const branchSelectionChanged = currentBranch !== originalBranch || latestHead !== originalHead;
597
+ if (latestPrimarySha !== primarySha)
598
+ return unsafe(currentBranch, "reconciliation-failed", {
599
+ ...checkoutEffects,
600
+ affectedPaths: [...new Set([...checkoutPaths, ...primaryPaths])],
601
+ });
602
+ const latestPrimaryPaths = await platformChanges(repository, latestHead, primarySha, namespace);
603
+ const latestProtected = await protectedPlatformCollision(repository, latestHead, primarySha, namespace, latestPrimaryPaths);
604
+ const latestFilesystem = await filesystemPlatformCollision(repository, latestHead, primarySha, latestPrimaryPaths);
605
+ if (latestProtected.length > 0 || latestFilesystem.length > 0)
606
+ return deferred(currentBranch, "protected-collision", {
607
+ ...checkoutEffects,
608
+ affectedPaths: [
609
+ ...new Set([...checkoutPaths, ...latestProtected, ...latestFilesystem]),
610
+ ],
611
+ });
612
+ const plannedPlatformPaths = [
613
+ ...new Set([...latestPrimaryPaths, ...checkoutPlatformPaths]),
614
+ ];
615
+ const plannedDependencyPaths = [
280
616
  ...new Set([
281
- ...managedChanges,
282
- ...mergedPaths,
283
- ...(identityCreated ? [...managedPaths] : []),
617
+ ...checkoutDependencyPaths,
618
+ ...latestPrimaryPaths.filter(dependencyPath),
284
619
  ]),
285
620
  ];
286
- if (!changed || !configuredRemote) {
621
+ if (latestPrimaryPaths.length === 0) {
287
622
  return {
623
+ ...resultBase(currentBranch, {
624
+ changed: branchSelectionChanged,
625
+ affectedPaths: checkoutPaths,
626
+ platformPaths: checkoutPlatformPaths,
627
+ dependencyPaths: checkoutDependencyPaths,
628
+ restartRequired: checkoutPlatformPaths.length > 0,
629
+ preparationRequired: checkoutDependencyPaths.length > 0,
630
+ }),
288
631
  status: "synchronized",
289
- branch: options.identityBranch,
290
- changed,
291
- pushed: false,
292
- affectedPaths,
293
632
  };
294
633
  }
634
+ const indexTree = (await repository.raw(["write-tree"])).trim();
635
+ const stagedNamespace = await indexNamespace(repository, namespace);
636
+ let baselineCommit;
637
+ let desiredIndexTree;
638
+ let patchDirectory;
639
+ let patchFile;
640
+ const dependencies = plannedDependencyPaths;
295
641
  try {
296
- await repository.push(options.remote, options.identityBranch, [
297
- "--set-upstream",
642
+ // All object creation and tree composition happen in temporary indexes.
643
+ // The live index is never used as a tree builder.
644
+ const composedTree = await composeTree(repository, primarySha, latestHead, namespace);
645
+ baselineCommit = (await repository.raw([
646
+ "commit-tree",
647
+ composedTree,
648
+ "-p",
649
+ latestHead,
650
+ "-m",
651
+ "chore: update committed platform baseline",
652
+ ])).trim();
653
+ if (!/^[0-9a-f]{40}$/iu.test(baselineCommit))
654
+ throw new GitRepositoryError("malformed-output", "baseline-commit");
655
+ if ((await repository.currentBranch()) !== currentBranch ||
656
+ (await refCommit(repository, "HEAD")) !== latestHead ||
657
+ (await refCommit(repository, sourceRef)) !== primarySha ||
658
+ (await repository.raw(["write-tree"])).trim() !== indexTree) {
659
+ throw new GitRepositoryError("conflict", "external-state");
660
+ }
661
+ desiredIndexTree = await overlayIndexNamespace(repository, baselineCommit, namespace, stagedNamespace);
662
+ // Apply the tree delta through Git's guarded index/worktree primitive.
663
+ // Unlike checkout/read-tree -u, git apply --index refuses to overwrite an
664
+ // untracked or ignored path that appears between preflight and mutation.
665
+ // The trees carry the same staged namespace, and the patch excludes that
666
+ // namespace, so staged and unstaged prototype files are not touched.
667
+ if ((await repository.raw(["write-tree"])).trim() !== indexTree)
668
+ throw new GitRepositoryError("conflict", "external-index");
669
+ patchDirectory = await mkdtemp(join(tmpdir(), "bonfire-patch-"));
670
+ patchFile = join(patchDirectory, "platform.patch");
671
+ const patch = await repository.raw([
672
+ "diff",
673
+ "--binary",
674
+ indexTree,
675
+ desiredIndexTree,
676
+ "--",
677
+ ".",
678
+ `:(exclude)${namespace}`,
679
+ ]);
680
+ await writeFile(patchFile, patch);
681
+ await repository.raw(["apply", "--index", patchFile]);
682
+ if ((await repository.currentBranch()) !== currentBranch ||
683
+ (await refCommit(repository, "HEAD")) !== latestHead ||
684
+ (await refCommit(repository, sourceRef)) !== primarySha ||
685
+ (await repository.raw(["write-tree"])).trim() !== desiredIndexTree)
686
+ throw new GitRepositoryError("conflict", "external-state");
687
+ // Ref movement is last. The expected old value is a CAS guard against an
688
+ // external branch writer.
689
+ await repository.raw([
690
+ "update-ref",
691
+ branchRef(options.identityBranch),
692
+ baselineCommit,
693
+ latestHead,
298
694
  ]);
695
+ const landedHead = await refCommit(repository, "HEAD");
696
+ if (landedHead !== baselineCommit)
697
+ throw new GitRepositoryError("conflict", "ref-transition");
698
+ if ((await repository.currentBranch()) !== currentBranch ||
699
+ (await refCommit(repository, sourceRef)) !== primarySha ||
700
+ (await repository.raw(["write-tree"])).trim() !== desiredIndexTree)
701
+ throw new GitRepositoryError("conflict", "external-state");
702
+ const finalStatus = await repository.status();
703
+ const unexpectedFinalPaths = pathsFromStatus(finalStatus.files).filter((path) => !isWithin(path, namespace));
704
+ if (unexpectedFinalPaths.length > 0)
705
+ throw new GitRepositoryError("conflict", "external-worktree");
299
706
  }
300
707
  catch {
301
- return unsafe(options.identityBranch, "push-failed", {
708
+ // A command wrapper may throw after running a command. First try the
709
+ // narrowly guarded inverse of our original patch. This is allowed only
710
+ // while the original branch/ref/source and exact intended index/worktree
711
+ // state still hold; git apply itself then protects intervening edits.
712
+ let recovered = false;
713
+ if (patchFile !== undefined && desiredIndexTree !== undefined) {
714
+ try {
715
+ const sameState = (await repository.currentBranch()) === currentBranch &&
716
+ (await refCommit(repository, "HEAD")) === latestHead &&
717
+ (await refCommit(repository, sourceRef)) === primarySha &&
718
+ (await repository.raw(["write-tree"])).trim() === desiredIndexTree;
719
+ if (sameState) {
720
+ await repository.raw([
721
+ "diff",
722
+ "--quiet",
723
+ desiredIndexTree,
724
+ "--",
725
+ ".",
726
+ `:(exclude)${namespace}`,
727
+ ]);
728
+ const observed = await repository.status();
729
+ const unexpected = pathsFromStatus(observed.files).filter((path) => !isWithin(path, namespace) &&
730
+ !latestPrimaryPaths.some((expected) => isWithin(path, expected) || isWithin(expected, path)));
731
+ if (unexpected.length === 0) {
732
+ await repository.raw(["apply", "--reverse", "--index", patchFile]);
733
+ const restored = await repository.status();
734
+ recovered =
735
+ (await repository.raw(["write-tree"])).trim() === indexTree &&
736
+ (await refCommit(repository, "HEAD")) === latestHead &&
737
+ (await repository.currentBranch()) === currentBranch &&
738
+ (await repository.raw([
739
+ "diff",
740
+ "--quiet",
741
+ indexTree,
742
+ "--",
743
+ ".",
744
+ `:(exclude)${namespace}`,
745
+ ])) === "" &&
746
+ pathsFromStatus(restored.files).every((path) => isWithin(path, namespace));
747
+ }
748
+ }
749
+ }
750
+ catch {
751
+ // Preserve the partial transition. Manual recovery must begin from
752
+ // the reported old HEAD and inspect the exact platform paths before
753
+ // applying the original patch in reverse; never reset or clean here.
754
+ }
755
+ }
756
+ if (patchDirectory !== undefined)
757
+ await rm(patchDirectory, { recursive: true, force: true }).catch(() => undefined);
758
+ let landed;
759
+ let indexChanged = false;
760
+ let worktreeChanged = false;
761
+ try {
762
+ landed = (await refCommit(repository, "HEAD")) !== latestHead;
763
+ }
764
+ catch {
765
+ // A malformed/missing ref is itself an incomplete transition.
766
+ landed = true;
767
+ }
768
+ if (desiredIndexTree !== undefined) {
769
+ try {
770
+ indexChanged =
771
+ (await repository.raw(["write-tree"])).trim() !== indexTree;
772
+ }
773
+ catch {
774
+ indexChanged = true;
775
+ }
776
+ // The guarded --index patch changes the index and worktree as one Git
777
+ // operation. An index-tree change is therefore the reliable
778
+ // observable for a transition that ran before an acknowledgement failed;
779
+ // a pure pre-side-effect rejection leaves both layers unchanged.
780
+ worktreeChanged = indexChanged;
781
+ }
782
+ const changed = recovered
783
+ ? branchSelectionChanged
784
+ : branchSelectionChanged || landed || indexChanged || worktreeChanged;
785
+ return unsafe(currentBranch, "reconciliation-failed", {
302
786
  changed,
303
- affectedPaths,
787
+ affectedPaths: [...new Set([...checkoutPaths, ...latestPrimaryPaths])],
788
+ dependencyPaths: dependencies,
789
+ platformPaths: plannedPlatformPaths,
790
+ restartRequired: checkoutPlatformPaths.length > 0 ||
791
+ (!recovered && (worktreeChanged || landed)),
792
+ preparationRequired: dependencies.length > 0 &&
793
+ (checkoutDependencyPaths.length > 0 ||
794
+ (!recovered && (landed || worktreeChanged))),
304
795
  });
305
796
  }
797
+ if (patchDirectory !== undefined)
798
+ await rm(patchDirectory, { recursive: true, force: true });
306
799
  return {
800
+ ...resultBase(currentBranch, {
801
+ changed: true,
802
+ affectedPaths: [...new Set([...checkoutPaths, ...latestPrimaryPaths])],
803
+ platformPaths: plannedPlatformPaths,
804
+ dependencyPaths: dependencies,
805
+ restartRequired: plannedPlatformPaths.length > 0,
806
+ preparationRequired: dependencies.length > 0,
807
+ }),
307
808
  status: "synchronized",
308
- branch: options.identityBranch,
309
- changed: true,
310
- pushed: true,
311
- affectedPaths,
312
809
  };
313
810
  }
314
811
  //# sourceMappingURL=repository-synchronization.js.map