@ebay-dt/bonfire-node 0.2.0-alpha.0 → 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.
- package/README.md +18 -5
- package/dist/deployment-provider.d.ts +2 -1
- package/dist/deployment-provider.d.ts.map +1 -1
- package/dist/deployment-provider.js +2 -1
- package/dist/deployment-provider.js.map +1 -1
- package/dist/duplicate.d.ts.map +1 -1
- package/dist/duplicate.js +103 -47
- package/dist/duplicate.js.map +1 -1
- package/dist/git-repository.d.ts +16 -1
- package/dist/git-repository.d.ts.map +1 -1
- package/dist/git-repository.js +127 -1
- package/dist/git-repository.js.map +1 -1
- package/dist/github-cli.d.ts.map +1 -1
- package/dist/github-cli.js +3 -1
- package/dist/github-cli.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/internal/prototype-templates.d.ts +0 -1
- package/dist/internal/prototype-templates.d.ts.map +1 -1
- package/dist/internal/prototype-templates.js +0 -5
- package/dist/internal/prototype-templates.js.map +1 -1
- package/dist/lifecycle/deployment-worktree.d.ts +36 -0
- package/dist/lifecycle/deployment-worktree.d.ts.map +1 -0
- package/dist/lifecycle/deployment-worktree.js +503 -0
- package/dist/lifecycle/deployment-worktree.js.map +1 -0
- package/dist/lifecycle/local-prototype-lifecycle.d.ts +15 -1
- package/dist/lifecycle/local-prototype-lifecycle.d.ts.map +1 -1
- package/dist/lifecycle/local-prototype-lifecycle.js +738 -98
- package/dist/lifecycle/local-prototype-lifecycle.js.map +1 -1
- package/dist/lifecycle/preparation-marker.d.ts +12 -0
- package/dist/lifecycle/preparation-marker.d.ts.map +1 -0
- package/dist/lifecycle/preparation-marker.js +76 -0
- package/dist/lifecycle/preparation-marker.js.map +1 -0
- package/dist/lifecycle/prototype-path-policy.d.ts +18 -0
- package/dist/lifecycle/prototype-path-policy.d.ts.map +1 -0
- package/dist/lifecycle/prototype-path-policy.js +429 -0
- package/dist/lifecycle/prototype-path-policy.js.map +1 -0
- package/dist/lifecycle/repository-summary.d.ts +7 -1
- package/dist/lifecycle/repository-summary.d.ts.map +1 -1
- package/dist/lifecycle/repository-summary.js +20 -4
- package/dist/lifecycle/repository-summary.js.map +1 -1
- package/dist/lifecycle/repository-synchronization.d.ts +13 -8
- package/dist/lifecycle/repository-synchronization.d.ts.map +1 -1
- package/dist/lifecycle/repository-synchronization.js +680 -139
- package/dist/lifecycle/repository-synchronization.js.map +1 -1
- package/dist/lifecycle/runtime-context.d.ts +2 -0
- package/dist/lifecycle/runtime-context.d.ts.map +1 -1
- package/dist/lifecycle/runtime-context.js +5 -1
- package/dist/lifecycle/runtime-context.js.map +1 -1
- package/package.json +2 -2
|
@@ -1,6 +1,8 @@
|
|
|
1
|
-
import { lstat } from "node:fs/promises";
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
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
|
|
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)
|
|
@@ -36,120 +69,340 @@ async function markerExists(repository, marker) {
|
|
|
36
69
|
return true;
|
|
37
70
|
}
|
|
38
71
|
catch (error) {
|
|
39
|
-
if (error &&
|
|
72
|
+
if (error &&
|
|
73
|
+
typeof error === "object" &&
|
|
74
|
+
"code" in error &&
|
|
75
|
+
error.code === "ENOENT")
|
|
40
76
|
return false;
|
|
41
|
-
}
|
|
42
77
|
throw error;
|
|
43
78
|
}
|
|
44
79
|
}
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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))
|
|
48
90
|
return true;
|
|
49
|
-
|
|
50
|
-
const status = await repository.status();
|
|
51
|
-
return hasUnmergedFiles(status.files);
|
|
91
|
+
return false;
|
|
52
92
|
}
|
|
53
93
|
function hasUnmergedFiles(files) {
|
|
54
94
|
return files.some((file) => file.index.includes("U") || file.workingDirectory.includes("U"));
|
|
55
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
|
+
}
|
|
56
105
|
async function refCommit(repository, ref) {
|
|
57
|
-
const value = await repository.raw(["rev-parse", "--verify", `${ref}^{commit}`]);
|
|
58
|
-
|
|
59
|
-
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))
|
|
60
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);
|
|
61
130
|
}
|
|
62
|
-
return commit;
|
|
63
131
|
}
|
|
64
132
|
async function compareRefs(repository, left, right) {
|
|
65
|
-
const
|
|
133
|
+
const values = (await repository.raw([
|
|
66
134
|
"rev-list",
|
|
67
135
|
"--left-right",
|
|
68
136
|
"--count",
|
|
69
137
|
`${left}...${right}`,
|
|
70
|
-
])
|
|
71
|
-
|
|
138
|
+
]))
|
|
139
|
+
.trim()
|
|
140
|
+
.split(/\s+/u)
|
|
141
|
+
.map(Number);
|
|
72
142
|
if (values.length !== 2 ||
|
|
73
143
|
values.some((value) => !Number.isSafeInteger(value) || value < 0)) {
|
|
74
144
|
throw new GitRepositoryError("malformed-output", "ref-relation");
|
|
75
145
|
}
|
|
76
146
|
return { ahead: values[0], behind: values[1] };
|
|
77
147
|
}
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
]
|
|
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);
|
|
85
162
|
}
|
|
86
|
-
function
|
|
163
|
+
function resultBase(branch, values = {}) {
|
|
87
164
|
return {
|
|
88
|
-
status: "deferred",
|
|
89
165
|
branch,
|
|
90
|
-
changed: false,
|
|
166
|
+
changed: values.changed ?? false,
|
|
91
167
|
pushed: false,
|
|
92
|
-
affectedPaths,
|
|
93
|
-
|
|
168
|
+
affectedPaths: values.affectedPaths ?? [],
|
|
169
|
+
platformPaths: values.platformPaths ?? [],
|
|
170
|
+
dependencyPaths: values.dependencyPaths ?? [],
|
|
171
|
+
restartRequired: values.restartRequired ?? false,
|
|
172
|
+
preparationRequired: values.preparationRequired ?? false,
|
|
94
173
|
};
|
|
95
174
|
}
|
|
96
|
-
function
|
|
97
|
-
return {
|
|
98
|
-
status: "unsafe",
|
|
99
|
-
branch,
|
|
100
|
-
changed: options.changed ?? false,
|
|
101
|
-
pushed: options.pushed ?? false,
|
|
102
|
-
affectedPaths: options.affectedPaths ?? [],
|
|
103
|
-
reason,
|
|
104
|
-
};
|
|
175
|
+
function deferred(branch, reason, values = {}) {
|
|
176
|
+
return { ...resultBase(branch, values), status: "deferred", reason };
|
|
105
177
|
}
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
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();
|
|
111
258
|
}
|
|
112
|
-
|
|
113
|
-
|
|
259
|
+
finally {
|
|
260
|
+
await rm(directory, { recursive: true, force: true });
|
|
114
261
|
}
|
|
115
|
-
|
|
116
|
-
|
|
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);
|
|
117
358
|
}
|
|
118
|
-
|
|
119
|
-
|
|
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))
|
|
120
385
|
throw new GitRepositoryError("conflict", "unresolved-operation");
|
|
121
|
-
}
|
|
122
386
|
}
|
|
123
387
|
/**
|
|
124
|
-
*
|
|
125
|
-
*
|
|
126
|
-
*
|
|
127
|
-
* unsafe repository state so callers can keep authentication/read-only flows
|
|
128
|
-
* 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.
|
|
129
391
|
*/
|
|
130
392
|
export async function synchronizeIdentityBranch(repository, options) {
|
|
131
393
|
assertBranch(options.identityBranch, "identity-branch");
|
|
132
394
|
assertBranch(options.primaryBranch, "primary-branch");
|
|
133
395
|
assertBranch(options.remote, "remote");
|
|
134
|
-
if (options.identityBranch === options.primaryBranch)
|
|
396
|
+
if (options.identityBranch === options.primaryBranch)
|
|
135
397
|
throw new GitRepositoryError("invalid-configuration", "identity-primary-branch");
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
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)
|
|
139
401
|
return unsafe(null, "detached-head");
|
|
140
|
-
|
|
141
|
-
|
|
402
|
+
const originalBranch = currentBranch;
|
|
403
|
+
const originalHead = await refCommit(repository, "HEAD");
|
|
404
|
+
if (await hasUnresolvedRepositoryOperation(repository))
|
|
142
405
|
return unsafe(currentBranch, "unresolved-operation");
|
|
143
|
-
}
|
|
144
|
-
const status = await repository.status();
|
|
145
|
-
if (hasUnmergedFiles(status.files)) {
|
|
146
|
-
return unsafe(currentBranch, "unresolved-operation", {
|
|
147
|
-
affectedPaths: status.files.map((file) => file.path),
|
|
148
|
-
});
|
|
149
|
-
}
|
|
150
|
-
if (!status.clean) {
|
|
151
|
-
return deferred(currentBranch, "dirty-worktree", status.files.map((file) => file.path));
|
|
152
|
-
}
|
|
153
406
|
const configuredRemote = (await repository.remotes()).some((remote) => remote.name === options.remote);
|
|
154
407
|
if (configuredRemote) {
|
|
155
408
|
try {
|
|
@@ -157,114 +410,402 @@ export async function synchronizeIdentityBranch(repository, options) {
|
|
|
157
410
|
}
|
|
158
411
|
catch (error) {
|
|
159
412
|
if (error instanceof GitRepositoryError &&
|
|
160
|
-
error.kind === "invalid-configuration")
|
|
413
|
+
error.kind === "invalid-configuration")
|
|
161
414
|
throw error;
|
|
162
|
-
}
|
|
163
415
|
return deferred(currentBranch, "fetch-failed");
|
|
164
416
|
}
|
|
165
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");
|
|
166
424
|
const branches = await repository.branches();
|
|
167
425
|
const localPrimary = branches.all.includes(options.primaryBranch);
|
|
168
426
|
const primaryRemote = remoteRef(options.remote, options.primaryBranch);
|
|
169
427
|
const remotePrimary = configuredRemote && branches.all.includes(primaryRemote);
|
|
170
|
-
if (configuredRemote ? !remotePrimary : !localPrimary)
|
|
428
|
+
if (configuredRemote ? !remotePrimary : !localPrimary)
|
|
171
429
|
return unsafe(currentBranch, "missing-primary");
|
|
172
|
-
|
|
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);
|
|
173
433
|
const localIdentity = branches.all.includes(options.identityBranch);
|
|
174
434
|
const identityRemote = remoteRef(options.remote, options.identityBranch);
|
|
175
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;
|
|
176
443
|
if (localIdentity && remoteIdentity) {
|
|
177
444
|
const relation = await compareRefs(repository, options.identityBranch, identityRemote);
|
|
178
|
-
if (relation.ahead > 0 && relation.behind > 0)
|
|
445
|
+
if (relation.ahead > 0 && relation.behind > 0)
|
|
179
446
|
return unsafe(currentBranch, "identity-divergence");
|
|
180
|
-
}
|
|
181
447
|
}
|
|
182
|
-
let
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
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
|
+
});
|
|
456
|
+
}
|
|
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
|
+
});
|
|
491
|
+
}
|
|
492
|
+
if (currentBranch !== options.identityBranch && dirtyPaths.length > 0) {
|
|
493
|
+
return deferred(currentBranch, "dirty-worktree", {
|
|
494
|
+
affectedPaths: dirtyPaths,
|
|
495
|
+
});
|
|
191
496
|
}
|
|
192
|
-
const sourceRef = remotePrimary ? primaryRemote : options.primaryBranch;
|
|
193
497
|
if (!localIdentity) {
|
|
194
|
-
|
|
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.
|
|
195
501
|
await repository.checkout([
|
|
502
|
+
"--no-overwrite-ignore",
|
|
503
|
+
"--no-track",
|
|
196
504
|
"-b",
|
|
197
505
|
options.identityBranch,
|
|
198
|
-
|
|
199
|
-
`${options.remote}/${options.identityBranch}`,
|
|
506
|
+
targetHead,
|
|
200
507
|
]);
|
|
508
|
+
currentBranch = options.identityBranch;
|
|
509
|
+
if (remoteIdentity)
|
|
510
|
+
await repository.raw([
|
|
511
|
+
"branch",
|
|
512
|
+
`--set-upstream-to=${identityRemote}`,
|
|
513
|
+
options.identityBranch,
|
|
514
|
+
]);
|
|
201
515
|
}
|
|
202
|
-
|
|
203
|
-
await repository.
|
|
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
|
+
});
|
|
204
526
|
}
|
|
205
527
|
}
|
|
206
528
|
else if (currentBranch !== options.identityBranch) {
|
|
207
|
-
await repository.checkout([options.identityBranch]);
|
|
208
|
-
}
|
|
209
|
-
if (primaryAdvance && currentBranch !== options.primaryBranch) {
|
|
210
|
-
await safelyAdvanceLocalPrimary(repository, options.primaryBranch, primaryAdvance.remoteRef, primaryAdvance.localCommit);
|
|
211
|
-
}
|
|
212
|
-
if (localIdentity && remoteIdentity) {
|
|
213
|
-
const relation = await compareRefs(repository, options.identityBranch, identityRemote);
|
|
214
|
-
if (relation.ahead === 0 && relation.behind > 0) {
|
|
215
|
-
await repository.merge([identityRemote, "--ff-only"]);
|
|
216
|
-
}
|
|
217
|
-
}
|
|
218
|
-
const beforeMerge = await refCommit(repository, options.identityBranch);
|
|
219
|
-
try {
|
|
220
|
-
await repository.merge([sourceRef, "--no-edit"]);
|
|
221
|
-
}
|
|
222
|
-
catch (error) {
|
|
223
|
-
if (!(error instanceof GitRepositoryError) || error.kind !== "conflict") {
|
|
224
|
-
return unsafe(options.identityBranch, "merge-failed");
|
|
225
|
-
}
|
|
226
|
-
let affectedPaths = [];
|
|
227
|
-
try {
|
|
228
|
-
affectedPaths = (await repository.status()).files.map((file) => file.path);
|
|
229
|
-
}
|
|
230
|
-
catch {
|
|
231
|
-
// Preserve the conflict classification even if status cannot be read.
|
|
232
|
-
}
|
|
233
529
|
try {
|
|
234
|
-
await repository.
|
|
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
|
+
});
|
|
235
546
|
}
|
|
236
547
|
catch {
|
|
237
|
-
|
|
238
|
-
|
|
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,
|
|
239
556
|
});
|
|
240
557
|
}
|
|
241
|
-
return deferred(options.identityBranch, "merge-conflict", affectedPaths);
|
|
242
558
|
}
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
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 = [
|
|
616
|
+
...new Set([
|
|
617
|
+
...checkoutDependencyPaths,
|
|
618
|
+
...latestPrimaryPaths.filter(dependencyPath),
|
|
619
|
+
]),
|
|
620
|
+
];
|
|
621
|
+
if (latestPrimaryPaths.length === 0) {
|
|
246
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
|
+
}),
|
|
247
631
|
status: "synchronized",
|
|
248
|
-
branch: options.identityBranch,
|
|
249
|
-
changed,
|
|
250
|
-
pushed: false,
|
|
251
|
-
affectedPaths: [],
|
|
252
632
|
};
|
|
253
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;
|
|
254
641
|
try {
|
|
255
|
-
|
|
256
|
-
|
|
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}`,
|
|
257
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,
|
|
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");
|
|
258
706
|
}
|
|
259
707
|
catch {
|
|
260
|
-
|
|
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", {
|
|
786
|
+
changed,
|
|
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))),
|
|
795
|
+
});
|
|
261
796
|
}
|
|
797
|
+
if (patchDirectory !== undefined)
|
|
798
|
+
await rm(patchDirectory, { recursive: true, force: true });
|
|
262
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
|
+
}),
|
|
263
808
|
status: "synchronized",
|
|
264
|
-
branch: options.identityBranch,
|
|
265
|
-
changed: true,
|
|
266
|
-
pushed: true,
|
|
267
|
-
affectedPaths: [],
|
|
268
809
|
};
|
|
269
810
|
}
|
|
270
811
|
//# sourceMappingURL=repository-synchronization.js.map
|