@zq-silk/yui 2.22.0 → 2.25.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +9 -4
- package/dist/artifacts/artifactCapability.js +6 -6
- package/dist/artifacts/gitArtifactRef.js +10 -10
- package/dist/artifacts/jobArtifact.js +5 -5
- package/dist/artifacts/managedGit.js +15 -12
- package/dist/artifacts/taskArtifactRepository.js +35 -33
- package/dist/cli/commandCatalog.js +2 -2
- package/dist/cli/updateCommand.js +20 -3
- package/dist/cli/updateOrchestrator.js +13 -0
- package/dist/cli/updatePorts.js +27 -0
- package/dist/cli.js +9 -2
- package/dist/commands/configCommands.js +64 -1
- package/dist/commands/projectCommands.js +9 -9
- package/dist/commands/taskCompletionGate.js +1 -1
- package/dist/commands/taskIntegrationCommands.js +1 -1
- package/dist/commands/taskPublicationAdoptCommand.js +1 -1
- package/dist/commands/taskRoleRuntimeStatus.js +4 -0
- package/dist/commands/taskUpstreamCommands.js +1 -1
- package/dist/config/configCatalog.js +3 -0
- package/dist/config/gitConfig.js +20 -0
- package/dist/config/yuiConfig.js +1 -0
- package/dist/context/currentSkills.js +32 -1
- package/dist/context/sessionBootstrapManifest.js +17 -4
- package/dist/context/sessionSkillNotice.js +33 -12
- package/dist/controlPlaneCli.js +4 -4
- package/dist/controller/clientRuntime.js +37 -18
- package/dist/controller/controller.js +16 -1
- package/dist/controller/fileSchedulerStoreAdapter.js +2 -0
- package/dist/controller/hostLifecycle.js +153 -0
- package/dist/controller/hostLifecycleChecks.js +27 -0
- package/dist/controller/hostUpgrade.js +344 -0
- package/dist/controller/jobArtifactControl.js +3 -3
- package/dist/controller/jobDefinitionControl.js +1 -1
- package/dist/controller/runtime.js +67 -16
- package/dist/controller/sessionNotify.js +5 -46
- package/dist/execution/codexThreadNaming.js +54 -43
- package/dist/executor/fileRoleLaunchPlanner.js +18 -3
- package/dist/integration/gitIntegrationService.js +1 -1
- package/dist/kernel/builtinCapabilities.js +3 -3
- package/dist/repository/gitCommand.js +153 -0
- package/dist/repository/gitCompatibility.js +3 -2
- package/dist/repository/gitWorkspace.js +579 -563
- package/dist/repository/gitWorktreeInventory.js +15 -19
- package/dist/repository/projectRefresh.js +1 -1
- package/dist/repository/taskBaseFreshness.js +1 -4
- package/dist/repository/taskWorkspacePreparer.js +1 -1
- package/dist/runtime/agentEndpoint.js +11 -1
- package/dist/runtime/agentEndpointIdentity.js +2 -0
- package/dist/runtime/agentHost.js +167 -42
- package/dist/runtime/agentHostImplementation.js +3 -1
- package/dist/runtime/codexAppServerRuntime.js +3 -0
- package/dist/runtime/codexInteractiveHost.js +62 -17
- package/dist/runtime/codexNativeAccess.js +1 -1
- package/dist/runtime/contextRecovery.js +15 -0
- package/dist/runtime/hostSessionLifecycle.js +83 -0
- package/dist/runtime/hostUpgradeGate.js +43 -0
- package/dist/runtime/lifecycleChecks.js +47 -0
- package/dist/runtime/lifecycleReservation.js +9 -1
- package/dist/runtime/nativeExecutable.js +5 -2
- package/dist/runtime/nativeTitleReceipt.js +31 -0
- package/dist/runtime/nativeTitleSync.js +192 -0
- package/dist/runtime/platform.js +11 -0
- package/dist/runtime/providerRetry.js +4 -4
- package/dist/runtime/structuredProviderHost.js +42 -10
- package/dist/storage/sqliteSchema.js +30 -0
- package/dist/storage/storageVersions.js +1 -1
- package/dist/storage/taskStore.js +7 -1
- package/dist/web/webConversation.js +2 -2
- package/dist/web/webMaterials.js +19 -19
- package/dist/web/webTaskSurface.js +3 -3
- package/dist/workspace/workItemChangeSetManager.js +3 -3
- package/docs/managed-turn-and-session-runtime.md +96 -1
- package/docs/managed-turn-and-session-runtime.zh-CN.md +70 -1
- package/docs/project-refresh.md +3 -2
- package/docs/project-refresh.zh-CN.md +2 -1
- package/docs/release-workflow.md +86 -2
- package/docs/release-workflow.zh-CN.md +65 -1
- package/docs/roles-and-configuration.md +34 -0
- package/docs/roles-and-configuration.zh-CN.md +27 -0
- package/docs/storage-baseline.md +14 -1
- package/docs/storage-baseline.zh-CN.md +7 -1
- package/docs/testing/verification-levels.md +7 -7
- package/docs/testing/verification-levels.zh-CN.md +6 -6
- package/i18n/README.zh-CN.md +8 -4
- package/package.json +1 -1
- package/skills/yui-runtime/SKILL.md +7 -2
- package/skills/yui-runtime/VERSION +1 -1
- package/dist/runtime/native/darwin-x64/claude-process-owner +0 -0
- package/dist/runtime/native/darwin-x64/process-identity +0 -0
|
@@ -1,13 +1,12 @@
|
|
|
1
|
-
import { execFile } from "node:child_process";
|
|
2
1
|
import { createHash, randomBytes } from "node:crypto";
|
|
3
2
|
import { lstat, mkdir, readFile, realpath, rm } from "node:fs/promises";
|
|
4
3
|
import { dirname, isAbsolute, join, relative, resolve } from "node:path";
|
|
5
|
-
import {
|
|
4
|
+
import { resolveGitLocalTimeoutSeconds, resolveGitRemoteInactivityTimeoutSeconds, resolveGitRemoteTimeoutSeconds } from "../config/gitConfig.js";
|
|
6
5
|
import { cleanupFailure } from "../workspace/cleanupInspection.js";
|
|
7
6
|
import { externalProgramConfigViolations } from "../artifacts/managedGit.js";
|
|
8
7
|
import { compatibleGitArguments, gitOutputLine, supportedGit } from "./gitCompatibility.js";
|
|
9
8
|
import { gitPath, readGitWorktrees } from "./gitWorktreeInventory.js";
|
|
10
|
-
|
|
9
|
+
import { executeGitCommand, GitCommandError } from "./gitCommand.js";
|
|
11
10
|
const isolatedFetchOptions = [
|
|
12
11
|
"--no-tags", "--no-prune", "--no-prune-tags", "--no-recurse-submodules",
|
|
13
12
|
"--no-auto-gc", "--no-write-commit-graph", "--no-write-fetch-head", "--refmap="
|
|
@@ -32,8 +31,28 @@ export class RemoteBaselineConflictError extends Error {
|
|
|
32
31
|
}
|
|
33
32
|
/** The small Git boundary used by project registration and Task workspaces. */
|
|
34
33
|
export class NodeGitWorkspace {
|
|
34
|
+
getConfig;
|
|
35
|
+
constructor(getConfig = () => ({})) {
|
|
36
|
+
this.getConfig = getConfig;
|
|
37
|
+
}
|
|
38
|
+
#localBudget() {
|
|
39
|
+
return { totalMs: resolveGitLocalTimeoutSeconds(this.getConfig().gitLocalTimeoutSeconds) * 1_000 };
|
|
40
|
+
}
|
|
41
|
+
#gitPath(cwd, args) {
|
|
42
|
+
return gitPath(cwd, args, this.#localBudget());
|
|
43
|
+
}
|
|
44
|
+
#readGitWorktrees(cwd) {
|
|
45
|
+
return readGitWorktrees(cwd, this.#localBudget());
|
|
46
|
+
}
|
|
47
|
+
#remoteBudget() {
|
|
48
|
+
const config = this.getConfig();
|
|
49
|
+
return {
|
|
50
|
+
inactivityMs: resolveGitRemoteInactivityTimeoutSeconds(config.gitRemoteInactivityTimeoutSeconds) * 1_000,
|
|
51
|
+
totalMs: resolveGitRemoteTimeoutSeconds(config.gitRemoteTimeoutSeconds) * 1_000
|
|
52
|
+
};
|
|
53
|
+
}
|
|
35
54
|
async resolveTree(repositoryPath, commit) {
|
|
36
|
-
return gitLine([
|
|
55
|
+
return this.#gitLine([
|
|
37
56
|
"-C", repositoryPath,
|
|
38
57
|
"rev-parse", "--verify", "--end-of-options", `${commit}^{tree}`
|
|
39
58
|
]);
|
|
@@ -46,7 +65,7 @@ export class NodeGitWorkspace {
|
|
|
46
65
|
const sourceTree = await this.resolveTree(input.repositoryPath, source);
|
|
47
66
|
const pageSize = 1000;
|
|
48
67
|
for (let skip = 0;; skip += pageSize) {
|
|
49
|
-
const output = await git([
|
|
68
|
+
const output = await this.#git([
|
|
50
69
|
"-C", input.repositoryPath,
|
|
51
70
|
"log", `--max-count=${pageSize}`, `--skip=${skip}`,
|
|
52
71
|
"--format=%H%x09%T", history
|
|
@@ -78,7 +97,7 @@ export class NodeGitWorkspace {
|
|
|
78
97
|
async treesAgreeOnPaths(input) {
|
|
79
98
|
if (input.paths.length === 0)
|
|
80
99
|
return false;
|
|
81
|
-
return gitSucceeds([
|
|
100
|
+
return this.#gitSucceeds([
|
|
82
101
|
"-C", input.repositoryPath,
|
|
83
102
|
"--literal-pathspecs",
|
|
84
103
|
"diff", "--quiet",
|
|
@@ -91,7 +110,7 @@ export class NodeGitWorkspace {
|
|
|
91
110
|
* projections use this as objective Git evidence.
|
|
92
111
|
*/
|
|
93
112
|
async changedFilesBetween(input) {
|
|
94
|
-
const output = await git([
|
|
113
|
+
const output = await this.#git([
|
|
95
114
|
"-C", input.repositoryPath,
|
|
96
115
|
"diff", "--name-only",
|
|
97
116
|
input.fromCommit, input.toCommit
|
|
@@ -100,7 +119,7 @@ export class NodeGitWorkspace {
|
|
|
100
119
|
}
|
|
101
120
|
/** Issue 07: exact unified diff text between two commits. */
|
|
102
121
|
async diffTextBetween(input) {
|
|
103
|
-
return git([
|
|
122
|
+
return this.#git([
|
|
104
123
|
"-C", input.repositoryPath,
|
|
105
124
|
"diff", "--no-color", "--no-ext-diff",
|
|
106
125
|
...(input.exact ? ["--no-textconv", "--binary", "--full-index", "--no-renames"] : []),
|
|
@@ -109,7 +128,7 @@ export class NodeGitWorkspace {
|
|
|
109
128
|
}
|
|
110
129
|
/** Issue 07: sums `git diff --numstat` added/deleted lines. */
|
|
111
130
|
async diffNumstatBetween(input) {
|
|
112
|
-
const output = await git([
|
|
131
|
+
const output = await this.#git([
|
|
113
132
|
"-C", input.repositoryPath,
|
|
114
133
|
"diff", "--numstat",
|
|
115
134
|
input.fromCommit, input.toCommit
|
|
@@ -144,11 +163,11 @@ export class NodeGitWorkspace {
|
|
|
144
163
|
*/
|
|
145
164
|
async resolveRemoteHead(input) {
|
|
146
165
|
const remote = safeRemote(input.remoteUrl);
|
|
147
|
-
const configuredBranch = await safeFetchBranch(input.branch);
|
|
166
|
+
const configuredBranch = await this.#safeFetchBranch(input.branch);
|
|
148
167
|
const branch = configuredBranch === "HEAD"
|
|
149
|
-
? await resolveRemoteHeadBranch(remote)
|
|
168
|
+
? await this.#resolveRemoteHeadBranch(remote, this.#remoteBudget())
|
|
150
169
|
: configuredBranch;
|
|
151
|
-
const commit = await resolveRemoteBranchCommit(remote, branch);
|
|
170
|
+
const commit = await this.#resolveRemoteBranchCommit(remote, branch, this.#remoteBudget());
|
|
152
171
|
return { branch, commit };
|
|
153
172
|
}
|
|
154
173
|
/** Fetch a remote branch into a managed worktree without changing HEAD. */
|
|
@@ -158,46 +177,46 @@ export class NodeGitWorkspace {
|
|
|
158
177
|
return { branch: fetched.branch, commit: fetched.commit };
|
|
159
178
|
}
|
|
160
179
|
finally {
|
|
161
|
-
await gitSucceeds(["-C", input.repositoryPath, "update-ref", "-d", fetched.fetchedRef]);
|
|
180
|
+
await this.#gitSucceeds(["-C", input.repositoryPath, "update-ref", "-d", fetched.fetchedRef]);
|
|
162
181
|
}
|
|
163
182
|
}
|
|
164
183
|
async #fetchRemoteHeadIntoWorktree(input) {
|
|
165
184
|
const repositoryPath = (await this.inspect(input.repositoryPath, "HEAD")).root;
|
|
166
185
|
const remote = safeRemote(input.remoteUrl);
|
|
167
|
-
const configuredBranch = await safeFetchBranch(input.branch);
|
|
186
|
+
const configuredBranch = await this.#safeFetchBranch(input.branch);
|
|
168
187
|
const branch = configuredBranch === "HEAD"
|
|
169
|
-
? await resolveRemoteHeadBranch(remote, repositoryPath)
|
|
188
|
+
? await this.#resolveRemoteHeadBranch(remote, this.#remoteBudget(), repositoryPath)
|
|
170
189
|
: configuredBranch;
|
|
171
190
|
const fetchedRef = remoteBaselineRef(remote, branch);
|
|
172
191
|
try {
|
|
173
|
-
await git([
|
|
192
|
+
await this.#git([
|
|
174
193
|
"-C", repositoryPath,
|
|
175
|
-
"fetch", ...isolatedFetchOptions,
|
|
194
|
+
"fetch", "--progress", ...isolatedFetchOptions,
|
|
176
195
|
remote,
|
|
177
196
|
`refs/heads/${branch}:${fetchedRef}`
|
|
178
|
-
]);
|
|
179
|
-
const commit = await resolveFetchedCommit(repositoryPath, fetchedRef);
|
|
197
|
+
], { budget: this.#remoteBudget() });
|
|
198
|
+
const commit = await this.#resolveFetchedCommit(repositoryPath, fetchedRef);
|
|
180
199
|
return { branch, commit, fetchedRef };
|
|
181
200
|
}
|
|
182
201
|
catch (error) {
|
|
183
|
-
await gitSucceeds(["-C", repositoryPath, "update-ref", "-d", fetchedRef]);
|
|
202
|
+
await this.#gitSucceeds(["-C", repositoryPath, "update-ref", "-d", fetchedRef]).catch(() => false);
|
|
184
203
|
throw error;
|
|
185
204
|
}
|
|
186
205
|
}
|
|
187
206
|
async refresh(input) {
|
|
188
207
|
const remote = safeRemote(input.remoteUrl);
|
|
189
|
-
const configuredStableRef = await safeFetchBranch(input.stableRef);
|
|
208
|
+
const configuredStableRef = await this.#safeFetchBranch(input.stableRef);
|
|
190
209
|
const initial = await this.inspect(input.repositoryPath, "HEAD");
|
|
191
210
|
await this.#assertRefreshCheckout(initial.root, configuredStableRef, initial.baseCommit);
|
|
192
211
|
const stableBranch = configuredStableRef === "HEAD"
|
|
193
|
-
? await resolveRemoteHeadBranch(remote, initial.root)
|
|
212
|
+
? await this.#resolveRemoteHeadBranch(remote, this.#remoteBudget(), initial.root)
|
|
194
213
|
: configuredStableRef;
|
|
195
214
|
if (stableBranch !== configuredStableRef) {
|
|
196
215
|
await this.#assertRefreshCheckout(initial.root, stableBranch, initial.baseCommit);
|
|
197
216
|
}
|
|
198
|
-
const mapping = await resolveFetchTracking(initial.root, remote, stableBranch);
|
|
217
|
+
const mapping = await this.#resolveFetchTracking(initial.root, remote, stableBranch);
|
|
199
218
|
const previous = mapping.status === "managed"
|
|
200
|
-
? await readDirectTrackingRef(initial.root, mapping.ref)
|
|
219
|
+
? await this.#readDirectTrackingRef(initial.root, mapping.ref)
|
|
201
220
|
: null;
|
|
202
221
|
let tracking = mapping.status === "managed"
|
|
203
222
|
? { status: "current", remoteName: mapping.remoteName, ref: mapping.ref,
|
|
@@ -209,7 +228,7 @@ export class NodeGitWorkspace {
|
|
|
209
228
|
let failure;
|
|
210
229
|
let cleanupFailed = false;
|
|
211
230
|
const assertMapping = async () => {
|
|
212
|
-
const current = await resolveFetchTracking(initial.root, remote, stableBranch);
|
|
231
|
+
const current = await this.#resolveFetchTracking(initial.root, remote, stableBranch);
|
|
213
232
|
if (JSON.stringify(current) !== JSON.stringify(mapping)) {
|
|
214
233
|
throw new Error("Project fetch URL or tracking mapping changed during refresh.");
|
|
215
234
|
}
|
|
@@ -217,12 +236,12 @@ export class NodeGitWorkspace {
|
|
|
217
236
|
try {
|
|
218
237
|
// An empty refmap disables opportunistic tracking updates even when the
|
|
219
238
|
// Project URL is a named remote. Ignore configured prune/submodule effects.
|
|
220
|
-
await git([
|
|
221
|
-
"-C", initial.root, "fetch", ...isolatedFetchOptions,
|
|
239
|
+
await this.#git([
|
|
240
|
+
"-C", initial.root, "fetch", "--progress", ...isolatedFetchOptions,
|
|
222
241
|
remote, `refs/heads/${stableBranch}:${temporaryRef}`
|
|
223
|
-
]);
|
|
224
|
-
fetchedCommit = await resolveFetchedCommit(initial.root, temporaryRef);
|
|
225
|
-
const advertisedCommit = await resolveRemoteBranchCommit(remote, stableBranch, initial.root);
|
|
242
|
+
], { budget: this.#remoteBudget() });
|
|
243
|
+
fetchedCommit = await this.#resolveFetchedCommit(initial.root, temporaryRef);
|
|
244
|
+
const advertisedCommit = await this.#resolveRemoteBranchCommit(remote, stableBranch, this.#remoteBudget(), initial.root);
|
|
226
245
|
if (fetchedCommit !== advertisedCommit) {
|
|
227
246
|
throw new Error(`Project remote stable branch changed while it was fetched: ${stableBranch}.`);
|
|
228
247
|
}
|
|
@@ -233,17 +252,17 @@ export class NodeGitWorkspace {
|
|
|
233
252
|
if (!await this.isAncestor(initial.root, initial.baseCommit, fetchedCommit)) {
|
|
234
253
|
throw new Error(`Project checkout cannot be fast-forwarded from ${initial.baseCommit} to ${fetchedCommit}.`);
|
|
235
254
|
}
|
|
236
|
-
await git(["-C", initial.root, "merge", "--ff-only", "--no-edit", fetchedCommit]);
|
|
255
|
+
await this.#git(["-C", initial.root, "merge", "--ff-only", "--no-edit", fetchedCommit]);
|
|
237
256
|
}
|
|
238
257
|
await this.#assertRefreshCheckout(initial.root, stableBranch, fetchedCommit);
|
|
239
258
|
await assertMapping();
|
|
240
259
|
if (mapping.status === "managed") {
|
|
241
260
|
// Do not follow a symbolic tracking ref into somebody else's branch.
|
|
242
|
-
await readDirectTrackingRef(initial.root, mapping.ref);
|
|
261
|
+
await this.#readDirectTrackingRef(initial.root, mapping.ref);
|
|
243
262
|
const old = previous ?? "0".repeat(fetchedCommit.length);
|
|
244
263
|
// Verify the stable branch and CAS the one configured destination in
|
|
245
264
|
// the same Git transaction. HEAD/index/worktree still use normal ff.
|
|
246
|
-
await git(["-C", initial.root, "update-ref", "--stdin"], { input: [
|
|
265
|
+
await this.#git(["-C", initial.root, "update-ref", "--stdin"], { input: [
|
|
247
266
|
"option no-deref",
|
|
248
267
|
`verify refs/heads/${stableBranch} ${fetchedCommit}`,
|
|
249
268
|
"option no-deref",
|
|
@@ -259,7 +278,7 @@ export class NodeGitWorkspace {
|
|
|
259
278
|
await this.#assertRefreshCheckout(initial.root, stableBranch, fetchedCommit);
|
|
260
279
|
await assertMapping();
|
|
261
280
|
if (mapping.status === "managed"
|
|
262
|
-
&& await readDirectTrackingRef(initial.root, mapping.ref) !== fetchedCommit) {
|
|
281
|
+
&& await this.#readDirectTrackingRef(initial.root, mapping.ref) !== fetchedCommit) {
|
|
263
282
|
throw new Error(`Project tracking ref changed after refresh: ${mapping.ref}.`);
|
|
264
283
|
}
|
|
265
284
|
}
|
|
@@ -268,9 +287,9 @@ export class NodeGitWorkspace {
|
|
|
268
287
|
}
|
|
269
288
|
finally {
|
|
270
289
|
if (fetchedCommit !== undefined) {
|
|
271
|
-
cleanupFailed = !await gitSucceeds([
|
|
290
|
+
cleanupFailed = !await this.#gitSucceeds([
|
|
272
291
|
"-C", initial.root, "update-ref", "--no-deref", "-d", temporaryRef, fetchedCommit
|
|
273
|
-
]);
|
|
292
|
+
]).catch(error => { failure ??= error; return false; });
|
|
274
293
|
if (cleanupFailed && failure === undefined) {
|
|
275
294
|
failure = new Error(`Project refresh temporary ref cleanup failed: ${temporaryRef}.`);
|
|
276
295
|
}
|
|
@@ -283,7 +302,7 @@ export class NodeGitWorkspace {
|
|
|
283
302
|
tracking = {
|
|
284
303
|
status: "failed", remoteName: mapping.remoteName, ref: mapping.ref,
|
|
285
304
|
fromCommit: previous,
|
|
286
|
-
toCommit: await readDirectTrackingRef(initial.root, mapping.ref).catch(() => null),
|
|
305
|
+
toCommit: await this.#readDirectTrackingRef(initial.root, mapping.ref).catch(() => null),
|
|
287
306
|
reason: detail
|
|
288
307
|
};
|
|
289
308
|
}
|
|
@@ -309,24 +328,24 @@ export class NodeGitWorkspace {
|
|
|
309
328
|
*/
|
|
310
329
|
async resolveRemoteBaseline(input) {
|
|
311
330
|
const remote = safeRemote(input.remoteUrl);
|
|
312
|
-
const configuredRef = await safeFetchBranch(input.developmentRef);
|
|
331
|
+
const configuredRef = await this.#safeFetchBranch(input.developmentRef);
|
|
313
332
|
const repository = await this.inspect(input.repositoryPath, "HEAD");
|
|
314
333
|
const branch = configuredRef === "HEAD"
|
|
315
|
-
? await resolveRemoteHeadBranch(remote, repository.root)
|
|
334
|
+
? await this.#resolveRemoteHeadBranch(remote, this.#remoteBudget(), repository.root)
|
|
316
335
|
: configuredRef;
|
|
317
336
|
const temporaryRef = remoteBaselineRef(remote, branch);
|
|
318
337
|
try {
|
|
319
|
-
await git([
|
|
338
|
+
await this.#git([
|
|
320
339
|
"-C", repository.root,
|
|
321
|
-
"fetch", ...isolatedFetchOptions,
|
|
340
|
+
"fetch", "--progress", ...isolatedFetchOptions,
|
|
322
341
|
remote,
|
|
323
342
|
`refs/heads/${branch}:${temporaryRef}`
|
|
324
|
-
]);
|
|
325
|
-
const fetchedCommit = await gitLine([
|
|
343
|
+
], { budget: this.#remoteBudget() });
|
|
344
|
+
const fetchedCommit = await this.#gitLine([
|
|
326
345
|
"-C", repository.root,
|
|
327
346
|
"rev-parse", "--verify", "--end-of-options", `${temporaryRef}^{commit}`
|
|
328
347
|
]);
|
|
329
|
-
const advertisedCommit = await resolveRemoteBranchCommit(remote, branch, repository.root);
|
|
348
|
+
const advertisedCommit = await this.#resolveRemoteBranchCommit(remote, branch, this.#remoteBudget(), repository.root);
|
|
330
349
|
if (fetchedCommit.toLowerCase() !== advertisedCommit) {
|
|
331
350
|
throw new Error(`Project remote development branch changed while it was fetched: ${branch}.`);
|
|
332
351
|
}
|
|
@@ -339,14 +358,14 @@ export class NodeGitWorkspace {
|
|
|
339
358
|
// A temporary namespace keeps the fetch independent from the stable
|
|
340
359
|
// branch and FETCH_HEAD. Cleanup is best effort so the original fetch
|
|
341
360
|
// or consistency error remains the actionable diagnosis.
|
|
342
|
-
await gitSucceeds([
|
|
361
|
+
await this.#gitSucceeds([
|
|
343
362
|
"-C", repository.root,
|
|
344
363
|
"update-ref", "-d", temporaryRef
|
|
345
|
-
]);
|
|
364
|
+
]).catch(() => false);
|
|
346
365
|
}
|
|
347
366
|
}
|
|
348
367
|
async clone(input) {
|
|
349
|
-
supportedGit();
|
|
368
|
+
supportedGit(this.#localBudget().totalMs);
|
|
350
369
|
const remote = safeRemote(input.remoteUrl);
|
|
351
370
|
requireText(input.destination, "Project destination");
|
|
352
371
|
const destination = resolve(input.destination);
|
|
@@ -361,23 +380,27 @@ export class NodeGitWorkspace {
|
|
|
361
380
|
await mkdir(destination, { mode: 0o700 });
|
|
362
381
|
const createdDirectory = await lstat(destination);
|
|
363
382
|
try {
|
|
364
|
-
await git([
|
|
365
|
-
"clone",
|
|
383
|
+
await this.#git([
|
|
384
|
+
"clone", "--progress",
|
|
366
385
|
...(branch === undefined ? [] : ["--branch", branch]),
|
|
367
386
|
"--",
|
|
368
387
|
remote,
|
|
369
388
|
destination
|
|
370
|
-
]);
|
|
389
|
+
], { budget: this.#remoteBudget() });
|
|
371
390
|
if (input.localBranch !== undefined) {
|
|
372
391
|
const localBranch = safeRef(input.localBranch);
|
|
373
|
-
if (!await gitSucceeds(["check-ref-format", "--branch", localBranch])) {
|
|
392
|
+
if (!await this.#gitSucceeds(["check-ref-format", "--branch", localBranch])) {
|
|
374
393
|
throw new Error(`Local clone branch is invalid: ${localBranch}.`);
|
|
375
394
|
}
|
|
376
|
-
await git(["-C", destination, "branch", "-M", "--", localBranch]);
|
|
395
|
+
await this.#git(["-C", destination, "branch", "-M", "--", localBranch]);
|
|
377
396
|
}
|
|
378
397
|
return await this.inspect(destination, "HEAD");
|
|
379
398
|
}
|
|
380
399
|
catch (error) {
|
|
400
|
+
if (error instanceof AggregateError && error.cause instanceof GitCommandError) {
|
|
401
|
+
error.message += ` Temporary clone retained at ${destination}; transport termination is unconfirmed.`;
|
|
402
|
+
throw error;
|
|
403
|
+
}
|
|
381
404
|
try {
|
|
382
405
|
const current = await lstat(destination).catch(failure => {
|
|
383
406
|
if (isErrno(failure, "ENOENT"))
|
|
@@ -408,23 +431,23 @@ export class NodeGitWorkspace {
|
|
|
408
431
|
catch (originalError) {
|
|
409
432
|
const root = (await this.inspect(repositoryPath, "HEAD")).root;
|
|
410
433
|
const name = safeRef(branch);
|
|
411
|
-
if (!await gitSucceeds(["check-ref-format", "--branch", name]))
|
|
434
|
+
if (!await this.#gitSucceeds(["check-ref-format", "--branch", name]))
|
|
412
435
|
throw originalError;
|
|
413
436
|
const remoteRef = `refs/remotes/origin/${name}`;
|
|
414
|
-
if (!await gitSucceeds([
|
|
437
|
+
if (!await this.#gitSucceeds([
|
|
415
438
|
"-C", root, "show-ref", "--verify", "--quiet", remoteRef
|
|
416
439
|
]))
|
|
417
440
|
throw originalError;
|
|
418
|
-
await git(["-C", root, "branch", "--", name, remoteRef]);
|
|
441
|
+
await this.#git(["-C", root, "branch", "--", name, remoteRef]);
|
|
419
442
|
return this.inspect(root, name);
|
|
420
443
|
}
|
|
421
444
|
}
|
|
422
445
|
async inspect(repositoryPath, baseRef = "HEAD") {
|
|
423
446
|
const requested = await canonicalDirectory(repositoryPath, "Project");
|
|
424
447
|
const ref = safeRef(baseRef);
|
|
425
|
-
const root = await canonicalDirectory(await gitPath(requested, ["--show-toplevel"]), "Project root");
|
|
426
|
-
const gitDirectory = await canonicalDirectory(await gitPath(root, ["--git-common-dir"]), "Git common directory");
|
|
427
|
-
const baseCommit = await gitLine([
|
|
448
|
+
const root = await canonicalDirectory(await this.#gitPath(requested, ["--show-toplevel"]), "Project root");
|
|
449
|
+
const gitDirectory = await canonicalDirectory(await this.#gitPath(root, ["--git-common-dir"]), "Git common directory");
|
|
450
|
+
const baseCommit = await this.#gitLine([
|
|
428
451
|
"-C", root, "rev-parse", "--verify", "--end-of-options", `${ref}^{commit}`
|
|
429
452
|
]);
|
|
430
453
|
if (!/^[a-f0-9]{40}(?:[a-f0-9]{24})?$/i.test(baseCommit)) {
|
|
@@ -443,7 +466,7 @@ export class NodeGitWorkspace {
|
|
|
443
466
|
// ref still points at <old>. Using the same commit for both new and old
|
|
444
467
|
// makes it a pure compare-and-swap probe — the ref does not move, but a
|
|
445
468
|
// concurrent advance between the last read and this call is detected.
|
|
446
|
-
await git([
|
|
469
|
+
await this.#git([
|
|
447
470
|
"-C", root, "update-ref",
|
|
448
471
|
safeRef(ref),
|
|
449
472
|
expectedCommit,
|
|
@@ -452,14 +475,14 @@ export class NodeGitWorkspace {
|
|
|
452
475
|
}
|
|
453
476
|
async refExists(repositoryPath, ref) {
|
|
454
477
|
const root = (await this.inspect(repositoryPath)).root;
|
|
455
|
-
return gitSucceeds([
|
|
478
|
+
return this.#gitSucceeds([
|
|
456
479
|
"-C", root, "rev-parse", "--verify", "--quiet", "--end-of-options",
|
|
457
480
|
`${safeRef(ref)}^{commit}`
|
|
458
481
|
]);
|
|
459
482
|
}
|
|
460
483
|
async listRefs(repositoryPath, pattern) {
|
|
461
484
|
const root = (await this.inspect(repositoryPath)).root;
|
|
462
|
-
const output = await git([
|
|
485
|
+
const output = await this.#git([
|
|
463
486
|
"-C", root, "for-each-ref", "--format=%(refname)", "--", safeRef(pattern)
|
|
464
487
|
]);
|
|
465
488
|
return output.length === 0 ? [] : output.split("\n").map((line) => line.trim())
|
|
@@ -474,9 +497,9 @@ export class NodeGitWorkspace {
|
|
|
474
497
|
const snapshots = [];
|
|
475
498
|
for (const ref of refs) {
|
|
476
499
|
const name = safeRef(ref);
|
|
477
|
-
const commit = await resolveRefCommit(source.root, name);
|
|
500
|
+
const commit = await this.#resolveRefCommit(source.root, name);
|
|
478
501
|
if (await this.refExists(destination.root, name)) {
|
|
479
|
-
const existing = await resolveRefCommit(destination.root, name);
|
|
502
|
+
const existing = await this.#resolveRefCommit(destination.root, name);
|
|
480
503
|
if (existing !== commit) {
|
|
481
504
|
throw new Error(`Destination ref already exists at a different commit: ${name}.`);
|
|
482
505
|
}
|
|
@@ -488,12 +511,12 @@ export class NodeGitWorkspace {
|
|
|
488
511
|
try {
|
|
489
512
|
for (const [index, snapshot] of snapshots.entries()) {
|
|
490
513
|
const importedRef = `${importRoot}/${index}`;
|
|
491
|
-
await git([
|
|
514
|
+
await this.#git([
|
|
492
515
|
"-C", destination.root,
|
|
493
516
|
"fetch", ...isolatedFetchOptions, "--",
|
|
494
517
|
source.root, `${snapshot.ref}:${importedRef}`
|
|
495
518
|
]);
|
|
496
|
-
const imported = await resolveRefCommit(destination.root, importedRef);
|
|
519
|
+
const imported = await this.#resolveRefCommit(destination.root, importedRef);
|
|
497
520
|
if (imported !== snapshot.commit) {
|
|
498
521
|
throw new Error(`Imported ref did not preserve its exact commit: ${snapshot.ref}.`);
|
|
499
522
|
}
|
|
@@ -502,18 +525,18 @@ export class NodeGitWorkspace {
|
|
|
502
525
|
// Freeze the source snapshots through publication. A moving local ref is
|
|
503
526
|
// never silently copied under an earlier/later identity.
|
|
504
527
|
for (const snapshot of snapshots) {
|
|
505
|
-
if (await resolveRefCommit(source.root, snapshot.ref) !== snapshot.commit) {
|
|
528
|
+
if (await this.#resolveRefCommit(source.root, snapshot.ref) !== snapshot.commit) {
|
|
506
529
|
throw new Error(`Source ref changed while it was being copied: ${snapshot.ref}.`);
|
|
507
530
|
}
|
|
508
531
|
}
|
|
509
532
|
for (const snapshot of snapshots) {
|
|
510
533
|
if (await this.refExists(destination.root, snapshot.ref)) {
|
|
511
|
-
if (await resolveRefCommit(destination.root, snapshot.ref) !== snapshot.commit) {
|
|
534
|
+
if (await this.#resolveRefCommit(destination.root, snapshot.ref) !== snapshot.commit) {
|
|
512
535
|
throw new Error(`Destination ref changed while it was being copied: ${snapshot.ref}.`);
|
|
513
536
|
}
|
|
514
537
|
continue;
|
|
515
538
|
}
|
|
516
|
-
await git([
|
|
539
|
+
await this.#git([
|
|
517
540
|
"-C", destination.root,
|
|
518
541
|
"update-ref", "--no-deref", snapshot.ref, snapshot.commit,
|
|
519
542
|
"0".repeat(snapshot.commit.length)
|
|
@@ -524,7 +547,7 @@ export class NodeGitWorkspace {
|
|
|
524
547
|
finally {
|
|
525
548
|
for (const entry of temporary) {
|
|
526
549
|
if (await this.refExists(destination.root, entry.ref)) {
|
|
527
|
-
await git([
|
|
550
|
+
await this.#git([
|
|
528
551
|
"-C", destination.root,
|
|
529
552
|
"update-ref", "-d", "--no-deref", entry.ref, entry.commit
|
|
530
553
|
]);
|
|
@@ -536,13 +559,13 @@ export class NodeGitWorkspace {
|
|
|
536
559
|
const root = (await this.inspect(input.repositoryPath)).root;
|
|
537
560
|
const source = safeRef(input.sourceRef);
|
|
538
561
|
const target = safeRef(input.archiveRef);
|
|
539
|
-
const commit = (await gitLine([
|
|
562
|
+
const commit = (await this.#gitLine([
|
|
540
563
|
"-C", root, "rev-parse", "--verify", "--end-of-options", `${source}^{commit}`
|
|
541
564
|
])).toLowerCase();
|
|
542
565
|
if (await this.refExists(root, target)) {
|
|
543
566
|
// Resumable: a previous attempt already created the archive ref. Only
|
|
544
567
|
// the same commit may be resumed; a different archive fails closed.
|
|
545
|
-
const archived = (await gitLine([
|
|
568
|
+
const archived = (await this.#gitLine([
|
|
546
569
|
"-C", root, "rev-parse", "--verify", "--end-of-options", `${target}^{commit}`
|
|
547
570
|
])).toLowerCase();
|
|
548
571
|
if (archived !== commit) {
|
|
@@ -551,14 +574,14 @@ export class NodeGitWorkspace {
|
|
|
551
574
|
}
|
|
552
575
|
else {
|
|
553
576
|
// Create the archive ref only if it does not exist yet (old value zero).
|
|
554
|
-
await git([
|
|
577
|
+
await this.#git([
|
|
555
578
|
"-C", root, "update-ref", "--no-deref", target, commit, "0".repeat(commit.length)
|
|
556
579
|
]);
|
|
557
580
|
}
|
|
558
581
|
// Delete the source only if it still exists and still points at the
|
|
559
582
|
// archived commit; an already-deleted source makes the archive a no-op.
|
|
560
583
|
if (await this.refExists(root, source)) {
|
|
561
|
-
await git([
|
|
584
|
+
await this.#git([
|
|
562
585
|
"-C", root, "update-ref", "-d", "--no-deref", source, commit
|
|
563
586
|
]);
|
|
564
587
|
}
|
|
@@ -572,7 +595,7 @@ export class NodeGitWorkspace {
|
|
|
572
595
|
const excluded = input.excludeWorktreePath === undefined
|
|
573
596
|
? undefined
|
|
574
597
|
: await realpath(resolve(input.excludeWorktreePath)).catch(() => undefined);
|
|
575
|
-
const records = await readGitWorktrees(root);
|
|
598
|
+
const records = await this.#readGitWorktrees(root);
|
|
576
599
|
for (const record of records) {
|
|
577
600
|
// A dead registration (its directory is gone) occupies nothing; a
|
|
578
601
|
// worktree on another ref or a detached HEAD does not occupy this one.
|
|
@@ -590,7 +613,7 @@ export class NodeGitWorkspace {
|
|
|
590
613
|
}
|
|
591
614
|
async isAncestor(repositoryPath, ancestor, descendant) {
|
|
592
615
|
const root = (await this.inspect(repositoryPath)).root;
|
|
593
|
-
return gitSucceeds([
|
|
616
|
+
return this.#gitSucceeds([
|
|
594
617
|
"-C", root,
|
|
595
618
|
"merge-base", "--is-ancestor",
|
|
596
619
|
safeRef(ancestor),
|
|
@@ -599,8 +622,8 @@ export class NodeGitWorkspace {
|
|
|
599
622
|
}
|
|
600
623
|
async inspectRemoteTracking(input) {
|
|
601
624
|
const root = (await this.inspect(input.repositoryPath)).root;
|
|
602
|
-
const branch = await safeFetchBranch(input.branch);
|
|
603
|
-
const mapping = await resolveFetchTracking(root, safeRemote(input.remoteUrl), branch);
|
|
625
|
+
const branch = await this.#safeFetchBranch(input.branch);
|
|
626
|
+
const mapping = await this.#resolveFetchTracking(root, safeRemote(input.remoteUrl), branch);
|
|
604
627
|
if (mapping.status !== "managed")
|
|
605
628
|
return null;
|
|
606
629
|
if (!await this.refExists(root, mapping.ref))
|
|
@@ -614,7 +637,7 @@ export class NodeGitWorkspace {
|
|
|
614
637
|
}
|
|
615
638
|
async mergeBase(input) {
|
|
616
639
|
const root = (await this.inspect(input.repositoryPath)).root;
|
|
617
|
-
return gitLine([
|
|
640
|
+
return this.#gitLine([
|
|
618
641
|
"-C", root,
|
|
619
642
|
"merge-base",
|
|
620
643
|
safeRef(input.leftCommit),
|
|
@@ -623,17 +646,17 @@ export class NodeGitWorkspace {
|
|
|
623
646
|
}
|
|
624
647
|
async headRef(repositoryPath) {
|
|
625
648
|
const requested = await canonicalDirectory(repositoryPath, "Project");
|
|
626
|
-
return gitLine(["-C", requested, "rev-parse", "--abbrev-ref", "HEAD"]);
|
|
649
|
+
return this.#gitLine(["-C", requested, "rev-parse", "--abbrev-ref", "HEAD"]);
|
|
627
650
|
}
|
|
628
651
|
async isClean(repositoryPath) {
|
|
629
652
|
const root = (await this.inspect(repositoryPath)).root;
|
|
630
|
-
const status = await git(["--no-optional-locks", "-C", root,
|
|
653
|
+
const status = await this.#git(["--no-optional-locks", "-C", root,
|
|
631
654
|
"status", "--porcelain=v1", "--untracked-files=all"]);
|
|
632
655
|
return status.length === 0;
|
|
633
656
|
}
|
|
634
657
|
async inspectClean(repositoryPath) {
|
|
635
658
|
const root = (await this.inspect(repositoryPath)).root;
|
|
636
|
-
const status = await readWorktreeStatus(root);
|
|
659
|
+
const status = await this.#readWorktreeStatus(root);
|
|
637
660
|
return status.length === 0;
|
|
638
661
|
}
|
|
639
662
|
async mergeWorktree(input) {
|
|
@@ -649,11 +672,11 @@ export class NodeGitWorkspace {
|
|
|
649
672
|
// Merge all selected Lane heads in one Git transaction. A conflict in
|
|
650
673
|
// any Lane therefore aborts the complete Candidate materialization
|
|
651
674
|
// instead of leaving an earlier Lane merged into the WorkItem target.
|
|
652
|
-
await git(["-C", target, "merge", "--no-edit", "--no-ff", ...sources]);
|
|
675
|
+
await this.#git(["-C", target, "merge", "--no-edit", "--no-ff", ...sources]);
|
|
653
676
|
}
|
|
654
677
|
catch (error) {
|
|
655
678
|
try {
|
|
656
|
-
await git(["-C", target, "merge", "--abort"]);
|
|
679
|
+
await this.#git(["-C", target, "merge", "--abort"]);
|
|
657
680
|
}
|
|
658
681
|
catch (abortError) {
|
|
659
682
|
throw new Error(`Git merge failed and conflict cleanup could not be completed for ${target}.`, { cause: abortError });
|
|
@@ -673,7 +696,7 @@ export class NodeGitWorkspace {
|
|
|
673
696
|
throw new Error(`Git reset target changed before compensation: ${target} (${currentHead}).`);
|
|
674
697
|
}
|
|
675
698
|
try {
|
|
676
|
-
await git(["-C", target, "reset", "--hard", restoreHead]);
|
|
699
|
+
await this.#git(["-C", target, "reset", "--hard", restoreHead]);
|
|
677
700
|
const restoredHead = (await this.inspect(target, "HEAD")).baseCommit;
|
|
678
701
|
if (restoredHead !== restoreHead || !await this.isClean(target)) {
|
|
679
702
|
throw new Error(`Git reset target did not restore its exact head: ${target}.`);
|
|
@@ -695,8 +718,8 @@ export class NodeGitWorkspace {
|
|
|
695
718
|
throw new Error(`Project checkout changed before reset: ${root} (${currentHead}).`);
|
|
696
719
|
}
|
|
697
720
|
// Verify the target commit resolves before moving the branch.
|
|
698
|
-
await gitLine(["-C", root, "rev-parse", "--verify", "--end-of-options", `${targetCommit}^{commit}`]);
|
|
699
|
-
await git(["-C", root, "reset", "--hard", targetCommit]);
|
|
721
|
+
await this.#gitLine(["-C", root, "rev-parse", "--verify", "--end-of-options", `${targetCommit}^{commit}`]);
|
|
722
|
+
await this.#git(["-C", root, "reset", "--hard", targetCommit]);
|
|
700
723
|
const restoredHead = (await this.inspect(root, "HEAD")).baseCommit;
|
|
701
724
|
if (restoredHead.toLowerCase() !== targetCommit.toLowerCase() || !await this.isClean(root)) {
|
|
702
725
|
throw new Error(`Project checkout did not reset to the exact target commit: ${root}.`);
|
|
@@ -704,12 +727,12 @@ export class NodeGitWorkspace {
|
|
|
704
727
|
}
|
|
705
728
|
async listWorktrees(repositoryPath) {
|
|
706
729
|
const root = await canonicalDirectory(repositoryPath, "Project");
|
|
707
|
-
return (await readGitWorktrees(root)).map(entry => entry.path);
|
|
730
|
+
return (await this.#readGitWorktrees(root)).map(entry => entry.path);
|
|
708
731
|
}
|
|
709
732
|
async listCommitsBetween(input) {
|
|
710
733
|
const root = await canonicalDirectory(input.repositoryPath, "Project");
|
|
711
734
|
const range = `${requireText(input.baseCommit, "Base commit")}..${requireText(input.headCommit, "Head commit")}`;
|
|
712
|
-
const output = await git(["-C", root, "log", "--oneline", range]);
|
|
735
|
+
const output = await this.#git(["-C", root, "log", "--oneline", range]);
|
|
713
736
|
return output
|
|
714
737
|
.split(/\r?\n/u)
|
|
715
738
|
.map((line) => line.trim())
|
|
@@ -767,9 +790,9 @@ export class NodeGitWorkspace {
|
|
|
767
790
|
// A worktree already created before a crash remains usable even if the
|
|
768
791
|
// original base branch/tag is later deleted.
|
|
769
792
|
const project = await this.inspect(input.repositoryPath);
|
|
770
|
-
await assertOwnedWorktree(project, container, path);
|
|
771
|
-
await assertExpectedBranch(path, identity.branch, input.allowRebase);
|
|
772
|
-
const head = await gitLine([
|
|
793
|
+
await this.#assertOwnedWorktree(project, container, path);
|
|
794
|
+
await this.#assertExpectedBranch(path, identity.branch, input.allowRebase);
|
|
795
|
+
const head = await this.#gitLine([
|
|
773
796
|
"-C", path, "rev-parse", "--verify", "--end-of-options", "HEAD^{commit}"
|
|
774
797
|
]);
|
|
775
798
|
return { path, branch: identity.branch, baseCommit: head.toLowerCase() };
|
|
@@ -777,18 +800,18 @@ export class NodeGitWorkspace {
|
|
|
777
800
|
const project = await this.inspect(input.repositoryPath, input.baseRef);
|
|
778
801
|
await canonicalContainer(dirname(path), true);
|
|
779
802
|
const branchRef = `refs/heads/${identity.branch}`;
|
|
780
|
-
const branchExists = await gitSucceeds([
|
|
803
|
+
const branchExists = await this.#gitSucceeds([
|
|
781
804
|
"-C", project.root, "show-ref", "--verify", "--quiet", branchRef
|
|
782
805
|
]);
|
|
783
|
-
await git(branchExists
|
|
806
|
+
await this.#git(branchExists
|
|
784
807
|
? ["-C", project.root, "worktree", "add", "--", path, identity.branch]
|
|
785
808
|
: [
|
|
786
809
|
"-C", project.root, "worktree", "add", "-b", identity.branch,
|
|
787
810
|
"--", path, project.baseCommit
|
|
788
811
|
]);
|
|
789
|
-
await assertOwnedWorktree(project, container, path);
|
|
790
|
-
await assertExpectedBranch(path, identity.branch);
|
|
791
|
-
const head = await gitLine([
|
|
812
|
+
await this.#assertOwnedWorktree(project, container, path);
|
|
813
|
+
await this.#assertExpectedBranch(path, identity.branch);
|
|
814
|
+
const head = await this.#gitLine([
|
|
792
815
|
"-C", path, "rev-parse", "--verify", "--end-of-options", "HEAD^{commit}"
|
|
793
816
|
]);
|
|
794
817
|
return { path, branch: identity.branch, baseCommit: head.toLowerCase() };
|
|
@@ -803,14 +826,14 @@ export class NodeGitWorkspace {
|
|
|
803
826
|
const project = await this.inspect(input.repositoryPath);
|
|
804
827
|
if (state === "missing") {
|
|
805
828
|
if (input.deleteBranch === true) {
|
|
806
|
-
await removeMissingWorktreeRegistration(project.root, path, branch);
|
|
807
|
-
await deleteBranchIfPresent(project.root, branch);
|
|
829
|
+
await this.#removeMissingWorktreeRegistration(project.root, path, branch);
|
|
830
|
+
await this.#deleteBranchIfPresent(project.root, branch);
|
|
808
831
|
}
|
|
809
832
|
return state;
|
|
810
833
|
}
|
|
811
|
-
await git(["-C", project.root, "worktree", "remove", "--", path]);
|
|
834
|
+
await this.#git(["-C", project.root, "worktree", "remove", "--", path]);
|
|
812
835
|
if (input.deleteBranch === true) {
|
|
813
|
-
await deleteBranchIfPresent(project.root, branch);
|
|
836
|
+
await this.#deleteBranchIfPresent(project.root, branch);
|
|
814
837
|
}
|
|
815
838
|
return "removed";
|
|
816
839
|
}
|
|
@@ -838,9 +861,9 @@ export class NodeGitWorkspace {
|
|
|
838
861
|
throw new Error("Expected a linked worktree, not a standalone clone.");
|
|
839
862
|
}
|
|
840
863
|
const project = await this.inspect(path, "HEAD");
|
|
841
|
-
await assertOwnedWorktree(project, resolve(identity.container), path);
|
|
864
|
+
await this.#assertOwnedWorktree(project, resolve(identity.container), path);
|
|
842
865
|
const branch = worktreeIdentity(identity.taskSegment, identity.roleName).branch;
|
|
843
|
-
await assertExpectedBranch(path, branch);
|
|
866
|
+
await this.#assertExpectedBranch(path, branch);
|
|
844
867
|
if (project.baseCommit !== identity.expectedCommit) {
|
|
845
868
|
throw new Error("Unadopted worktree HEAD changed since preparation; retained.");
|
|
846
869
|
}
|
|
@@ -849,10 +872,10 @@ export class NodeGitWorkspace {
|
|
|
849
872
|
// Use the worktree's proven common directory, never the changed catalog.
|
|
850
873
|
// Git still enforces locks/submodule restrictions. A Git error is not
|
|
851
874
|
// authority for direct filesystem deletion, even for unadopted work.
|
|
852
|
-
await git(["--git-dir", project.gitDirectory, "worktree", "remove", "--", path]);
|
|
875
|
+
await this.#git(["--git-dir", project.gitDirectory, "worktree", "remove", "--", path]);
|
|
853
876
|
worktreeRemoved = true;
|
|
854
877
|
if (identity.deleteBranch === true) {
|
|
855
|
-
await git(["--git-dir", project.gitDirectory, "update-ref", "-d", `refs/heads/${branch}`, identity.expectedCommit]);
|
|
878
|
+
await this.#git(["--git-dir", project.gitDirectory, "update-ref", "-d", `refs/heads/${branch}`, identity.expectedCommit]);
|
|
856
879
|
}
|
|
857
880
|
return "removed";
|
|
858
881
|
}
|
|
@@ -867,7 +890,7 @@ export class NodeGitWorkspace {
|
|
|
867
890
|
if (state !== "clean")
|
|
868
891
|
return state;
|
|
869
892
|
if (input.expectedCommit !== undefined
|
|
870
|
-
&& await resolveRefCommit(input.path, "HEAD") !== input.expectedCommit) {
|
|
893
|
+
&& await this.#resolveRefCommit(input.path, "HEAD") !== input.expectedCommit) {
|
|
871
894
|
throw new Error(`Unadopted Task clone HEAD changed since preparation; retained at ${input.path}.`);
|
|
872
895
|
}
|
|
873
896
|
await rm(managedPath(resolve(input.container), input.directory), { recursive: true });
|
|
@@ -892,23 +915,23 @@ export class NodeGitWorkspace {
|
|
|
892
915
|
if (repository.root !== expected || repository.gitDirectory !== join(expected, ".git")) {
|
|
893
916
|
cleanupFailure("workspace-identity-mismatch", "Task clone Git ownership cannot be established.", "exact root and Git directory", "different Git ownership");
|
|
894
917
|
}
|
|
895
|
-
await assertExpectedBranch(expected, input.branch);
|
|
918
|
+
await this.#assertExpectedBranch(expected, input.branch);
|
|
896
919
|
if (!await this.inspectClean(expected))
|
|
897
920
|
return "dirty";
|
|
898
|
-
const worktrees = await readGitWorktrees(expected);
|
|
921
|
+
const worktrees = await this.#readGitWorktrees(expected);
|
|
899
922
|
if (worktrees.length !== 1 || worktrees[0].path !== expected) {
|
|
900
923
|
cleanupFailure("git-worktree-registrations", "Task clone still owns Git worktree registrations; retained.", 1, worktrees.length);
|
|
901
924
|
}
|
|
902
925
|
return "clean";
|
|
903
926
|
}
|
|
904
927
|
async inspectRecordedWorktree(input) {
|
|
905
|
-
const inspected = await inspectExactRecordedWorktree(input);
|
|
928
|
+
const inspected = await this.#inspectExactRecordedWorktree(input);
|
|
906
929
|
return inspected?.state ?? "missing";
|
|
907
930
|
}
|
|
908
931
|
async removeRecordedWorktree(input) {
|
|
909
932
|
let inspected;
|
|
910
933
|
try {
|
|
911
|
-
inspected = await inspectExactRecordedWorktree(input);
|
|
934
|
+
inspected = await this.#inspectExactRecordedWorktree(input);
|
|
912
935
|
}
|
|
913
936
|
catch (error) {
|
|
914
937
|
// A deleted external checkout takes the worktree's common dir with it,
|
|
@@ -925,18 +948,18 @@ export class NodeGitWorkspace {
|
|
|
925
948
|
return "dirty";
|
|
926
949
|
// Revalidate immediately before asking the worktree's own Git common-dir
|
|
927
950
|
// to remove it. This never relies on the Project catalog's former path.
|
|
928
|
-
const current = await inspectExactRecordedWorktree(input);
|
|
951
|
+
const current = await this.#inspectExactRecordedWorktree(input);
|
|
929
952
|
if (current === undefined)
|
|
930
953
|
return "missing";
|
|
931
954
|
if (current.state === "dirty")
|
|
932
955
|
return "dirty";
|
|
933
|
-
await retainCommitRef(current.destinationRoot, input.retainedRef, current.head);
|
|
934
|
-
await git(["-C", current.path, "worktree", "remove", "--", current.path]);
|
|
956
|
+
await this.#retainCommitRef(current.destinationRoot, input.retainedRef, current.head);
|
|
957
|
+
await this.#git(["-C", current.path, "worktree", "remove", "--", current.path]);
|
|
935
958
|
if (await pathKind(current.path) !== undefined) {
|
|
936
959
|
throw new Error(`Recorded managed worktree remained after removal: ${current.path}.`);
|
|
937
960
|
}
|
|
938
961
|
if (current.gitDirectory !== current.destinationGitDirectory) {
|
|
939
|
-
await git([
|
|
962
|
+
await this.#git([
|
|
940
963
|
`--git-dir=${current.gitDirectory}`,
|
|
941
964
|
"update-ref", "-d", "--no-deref", `refs/heads/${safeRef(input.branch)}`,
|
|
942
965
|
current.head
|
|
@@ -951,16 +974,16 @@ export class NodeGitWorkspace {
|
|
|
951
974
|
const branch = input.expectedBranch ?? worktreeIdentity(input.taskSegment, input.roleName).branch;
|
|
952
975
|
if (kind === undefined) {
|
|
953
976
|
const project = await this.inspect(input.repositoryPath);
|
|
954
|
-
await inspectMissingWorktreeRegistration(project.root, path, branch, input.expectedBranch !== undefined);
|
|
977
|
+
await this.#inspectMissingWorktreeRegistration(project.root, path, branch, input.expectedBranch !== undefined);
|
|
955
978
|
return "missing";
|
|
956
979
|
}
|
|
957
980
|
if (kind === "symlink")
|
|
958
981
|
cleanupFailure("workspace-identity-mismatch", "Managed worktree path must not be a symbolic link.", "real directory", "symbolic link");
|
|
959
982
|
const canonicalContainerPath = await canonicalContainer(container, false);
|
|
960
983
|
const project = await this.inspect(input.repositoryPath);
|
|
961
|
-
await assertOwnedWorktree(project, canonicalContainerPath, path);
|
|
962
|
-
await assertExpectedBranch(path, branch, input.expectedBranch !== undefined);
|
|
963
|
-
const porcelain = await readWorktreeStatus(path);
|
|
984
|
+
await this.#assertOwnedWorktree(project, canonicalContainerPath, path);
|
|
985
|
+
await this.#assertExpectedBranch(path, branch, input.expectedBranch !== undefined);
|
|
986
|
+
const porcelain = await this.#readWorktreeStatus(path);
|
|
964
987
|
return porcelain.length > 0 ? "dirty" : "clean";
|
|
965
988
|
}
|
|
966
989
|
async removeIntegrationWorktree(input) {
|
|
@@ -982,68 +1005,468 @@ export class NodeGitWorkspace {
|
|
|
982
1005
|
throw new Error("Managed worktree path must not be a symbolic link.");
|
|
983
1006
|
const repository = await this.inspect(input.repositoryPath);
|
|
984
1007
|
if (kind === undefined) {
|
|
985
|
-
await removeMissingWorktreeRegistration(repository.root, path, input.identity.branch, true);
|
|
1008
|
+
await this.#removeMissingWorktreeRegistration(repository.root, path, input.identity.branch, true);
|
|
986
1009
|
}
|
|
987
1010
|
if (kind === "directory") {
|
|
988
1011
|
const canonicalContainerPath = await canonicalContainer(container, false);
|
|
989
|
-
await assertOwnedWorktree(repository, canonicalContainerPath, path);
|
|
990
|
-
await assertExpectedBranch(path, input.identity.branch, true);
|
|
1012
|
+
await this.#assertOwnedWorktree(repository, canonicalContainerPath, path);
|
|
1013
|
+
await this.#assertExpectedBranch(path, input.identity.branch, true);
|
|
991
1014
|
if (input.discardChanges === true) {
|
|
992
|
-
await git(["-C", repository.root, "worktree", "remove", "--force", "--", path]);
|
|
1015
|
+
await this.#git(["-C", repository.root, "worktree", "remove", "--force", "--", path]);
|
|
993
1016
|
}
|
|
994
1017
|
else {
|
|
995
|
-
const porcelain = await readWorktreeStatus(path);
|
|
1018
|
+
const porcelain = await this.#readWorktreeStatus(path);
|
|
996
1019
|
if (porcelain.length > 0)
|
|
997
1020
|
return "dirty";
|
|
998
1021
|
if (input.expectedBaseCommit !== undefined
|
|
999
1022
|
&& input.allowCommittedChanges !== true) {
|
|
1000
|
-
const head = await gitLine([
|
|
1023
|
+
const head = await this.#gitLine([
|
|
1001
1024
|
"-C", path, "rev-parse", "--verify", "--end-of-options", "HEAD^{commit}"
|
|
1002
1025
|
]);
|
|
1003
1026
|
if (head.toLowerCase() !== input.expectedBaseCommit.toLowerCase())
|
|
1004
1027
|
return "dirty";
|
|
1005
1028
|
}
|
|
1006
|
-
await git(["-C", repository.root, "worktree", "remove", "--", path]);
|
|
1029
|
+
await this.#git(["-C", repository.root, "worktree", "remove", "--", path]);
|
|
1007
1030
|
}
|
|
1008
1031
|
}
|
|
1009
1032
|
const branchRef = `refs/heads/${input.identity.branch}`;
|
|
1010
|
-
if (await gitSucceeds([
|
|
1033
|
+
if (await this.#gitSucceeds([
|
|
1011
1034
|
"-C", repository.root, "show-ref", "--verify", "--quiet", branchRef
|
|
1012
1035
|
])) {
|
|
1013
|
-
await git(["-C", repository.root, "branch", "-D", "--", input.identity.branch]);
|
|
1036
|
+
await this.#git(["-C", repository.root, "branch", "-D", "--", input.identity.branch]);
|
|
1014
1037
|
}
|
|
1015
1038
|
return kind === "directory" ? "removed" : "missing";
|
|
1016
1039
|
}
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1040
|
+
/** Missing paths do not imply missing Git metadata. Never prune another
|
|
1041
|
+
* workspace's registration as a side effect of cleaning this exact owner.
|
|
1042
|
+
*/
|
|
1043
|
+
async #removeMissingWorktreeRegistration(repositoryRoot, path, branch, allowDetached = false) {
|
|
1044
|
+
if (!await this.#inspectMissingWorktreeRegistration(repositoryRoot, path, branch, allowDetached))
|
|
1045
|
+
return;
|
|
1046
|
+
if (await pathKind(path) !== undefined)
|
|
1047
|
+
throw new Error(`Worktree reappeared before metadata cleanup: ${path}.`);
|
|
1048
|
+
await this.#git(["-C", repositoryRoot, "worktree", "remove", "--force", "--", path]);
|
|
1049
|
+
}
|
|
1050
|
+
async #inspectMissingWorktreeRegistration(repositoryRoot, path, branch, allowDetached = false) {
|
|
1051
|
+
const entry = (await this.#readGitWorktrees(repositoryRoot)).find(record => record.path === path);
|
|
1052
|
+
if (entry === undefined)
|
|
1053
|
+
return false;
|
|
1054
|
+
const pinnedDetached = allowDetached && entry.detached
|
|
1055
|
+
&& await this.#gitSucceeds(["-C", repositoryRoot, "show-ref", "--verify", "--quiet", `refs/heads/${branch}`])
|
|
1056
|
+
&& entry.head === await this.#resolveRefCommit(repositoryRoot, `refs/heads/${branch}`);
|
|
1057
|
+
if ((entry.branch !== `refs/heads/${branch}` && !pinnedDetached) || entry.locked) {
|
|
1058
|
+
cleanupFailure("git-registration-mismatch", "Missing worktree has uncertain or locked Git ownership; metadata retained.", { branch, locked: false }, { branch: entry.branch ?? null, locked: entry.locked });
|
|
1059
|
+
}
|
|
1060
|
+
return true;
|
|
1061
|
+
}
|
|
1062
|
+
async #deleteBranchIfPresent(repositoryRoot, branch) {
|
|
1063
|
+
const branchRef = `refs/heads/${branch}`;
|
|
1064
|
+
if (!await this.#gitSucceeds([
|
|
1065
|
+
"-C", repositoryRoot, "show-ref", "--verify", "--quiet", branchRef
|
|
1066
|
+
]))
|
|
1067
|
+
return;
|
|
1068
|
+
await this.#git(["-C", repositoryRoot, "branch", "-D", "--", branch]);
|
|
1069
|
+
}
|
|
1070
|
+
async #assertExpectedBranch(path, expected, allowRebase = false) {
|
|
1071
|
+
if (allowRebase && !await this.#gitSucceeds(["-C", path, "symbolic-ref", "--quiet", "HEAD"])) {
|
|
1072
|
+
// Rebase temporarily detaches HEAD. Git's per-worktree metadata must still
|
|
1073
|
+
// identify this exact Integration branch; unrelated detachment is invalid.
|
|
1074
|
+
for (const backend of ["rebase-merge", "rebase-apply"]) {
|
|
1075
|
+
const headNamePath = await this.#gitPath(path, ["--git-path", `${backend}/head-name`]);
|
|
1076
|
+
let headName;
|
|
1077
|
+
try {
|
|
1078
|
+
headName = (await readFile(headNamePath, "utf8")).trim();
|
|
1079
|
+
}
|
|
1080
|
+
catch (error) {
|
|
1081
|
+
if (error.code === "ENOENT")
|
|
1082
|
+
continue;
|
|
1083
|
+
throw error;
|
|
1084
|
+
}
|
|
1085
|
+
if (headName === `refs/heads/${expected}`)
|
|
1086
|
+
return;
|
|
1087
|
+
throw new Error(`Managed worktree is rebasing an unexpected branch: ${headName}.`);
|
|
1088
|
+
}
|
|
1089
|
+
// A terminal Integration can legitimately retain a detached checkout.
|
|
1090
|
+
// Its exact owned path/common-dir were checked by the caller; require its
|
|
1091
|
+
// retained branch to pin the same commit before allowing clean removal.
|
|
1092
|
+
if (await this.#resolveRefCommit(path, "HEAD") === await this.#resolveRefCommit(path, `refs/heads/${expected}`))
|
|
1093
|
+
return;
|
|
1094
|
+
cleanupFailure("workspace-identity-mismatch", "Detached worktree is not pinned by its owned branch or rebase.", expected, "unproven detached HEAD");
|
|
1095
|
+
}
|
|
1096
|
+
const branch = await this.#gitLine(["-C", path, "symbolic-ref", "--short", "HEAD"]);
|
|
1097
|
+
if (branch !== expected) {
|
|
1098
|
+
cleanupFailure("workspace-identity-mismatch", "Managed worktree is on an unexpected branch.", expected, branch);
|
|
1099
|
+
}
|
|
1100
|
+
}
|
|
1101
|
+
async #assertOwnedWorktree(project, container, path) {
|
|
1102
|
+
const canonicalPath = await canonicalDirectory(path, "Managed worktree");
|
|
1103
|
+
assertContained(container, canonicalPath);
|
|
1104
|
+
if (canonicalPath !== path)
|
|
1105
|
+
throw new Error("Managed worktree resolves through a symbolic link.");
|
|
1106
|
+
const root = await canonicalDirectory(await this.#gitPath(path, ["--show-toplevel"]), "Managed worktree root");
|
|
1107
|
+
if (root !== path)
|
|
1108
|
+
throw new Error("Managed worktree root does not match its deterministic path.");
|
|
1109
|
+
const common = await canonicalDirectory(await this.#gitPath(path, ["--git-common-dir"]), "Managed Git common directory");
|
|
1110
|
+
if (common !== project.gitDirectory) {
|
|
1111
|
+
cleanupFailure("workspace-identity-mismatch", "Managed worktree belongs to another project.", "Task main Git common directory", "different Git common directory");
|
|
1112
|
+
}
|
|
1113
|
+
}
|
|
1114
|
+
async #inspectExactRecordedWorktree(input) {
|
|
1115
|
+
const container = await canonicalContainer(input.container, false);
|
|
1116
|
+
const expectedBranch = worktreeIdentity(input.taskSegment, input.roleName).branch;
|
|
1117
|
+
const expectedPath = managedPath(container, input.directory);
|
|
1118
|
+
if (resolve(input.path) !== expectedPath || input.branch !== expectedBranch) {
|
|
1119
|
+
throw new Error("Recorded managed worktree identity is invalid.");
|
|
1120
|
+
}
|
|
1121
|
+
const kind = await pathKind(expectedPath);
|
|
1122
|
+
if (kind === undefined)
|
|
1123
|
+
return undefined;
|
|
1124
|
+
if (kind === "symlink") {
|
|
1125
|
+
throw new Error("Recorded managed worktree path must not be a symbolic link.");
|
|
1126
|
+
}
|
|
1127
|
+
const canonicalContainerPath = await canonicalContainer(container, false);
|
|
1128
|
+
const path = await canonicalDirectory(expectedPath, "Recorded managed worktree");
|
|
1129
|
+
assertContained(canonicalContainerPath, path);
|
|
1130
|
+
if (path !== expectedPath) {
|
|
1131
|
+
throw new Error("Recorded managed worktree resolves through a symbolic link.");
|
|
1132
|
+
}
|
|
1133
|
+
const root = await canonicalDirectory(await this.#gitPath(path, ["--show-toplevel"]), "Recorded managed worktree root");
|
|
1134
|
+
if (root !== path) {
|
|
1135
|
+
throw new Error("Recorded managed worktree root does not match its deterministic path.");
|
|
1136
|
+
}
|
|
1137
|
+
const branch = await this.#gitLine(["-C", path, "symbolic-ref", "--short", "HEAD"]);
|
|
1138
|
+
if (branch !== input.branch) {
|
|
1139
|
+
throw new Error(`Recorded managed worktree is on an unexpected branch: ${branch}.`);
|
|
1140
|
+
}
|
|
1141
|
+
const gitDirectory = await canonicalDirectory(await this.#gitPath(path, ["--git-common-dir"]), "Recorded managed Git common directory");
|
|
1142
|
+
const destination = await this.inspect(input.repositoryPath);
|
|
1143
|
+
const head = await this.#resolveRefCommit(path, "HEAD");
|
|
1144
|
+
const retained = await this.#resolveRefCommit(destination.root, `refs/heads/${input.branch}`);
|
|
1145
|
+
if (retained !== head) {
|
|
1146
|
+
throw new Error(`Recorded managed worktree is not retained by the current Project: ${input.branch}.`);
|
|
1147
|
+
}
|
|
1148
|
+
const status = await this.#git(["-C", path, "status", "--porcelain=v1", "--untracked-files=all"]);
|
|
1149
|
+
return {
|
|
1150
|
+
path,
|
|
1151
|
+
gitDirectory,
|
|
1152
|
+
destinationGitDirectory: destination.gitDirectory,
|
|
1153
|
+
destinationRoot: destination.root,
|
|
1154
|
+
head,
|
|
1155
|
+
state: status.length === 0 ? "clean" : "dirty"
|
|
1156
|
+
};
|
|
1157
|
+
}
|
|
1158
|
+
async #resolveRefCommit(repositoryPath, ref) {
|
|
1159
|
+
const commit = (await this.#gitLine([
|
|
1160
|
+
"-C", repositoryPath,
|
|
1161
|
+
"rev-parse", "--verify", "--end-of-options", `${safeRef(ref)}^{commit}`
|
|
1162
|
+
])).toLowerCase();
|
|
1163
|
+
if (!isCommit(commit))
|
|
1164
|
+
throw new Error("Git returned an invalid ref commit.");
|
|
1165
|
+
return commit;
|
|
1166
|
+
}
|
|
1167
|
+
async #retainCommitRef(repositoryPath, ref, commit) {
|
|
1168
|
+
const target = safeRef(ref);
|
|
1169
|
+
if (await this.#gitSucceeds([
|
|
1170
|
+
"-C", repositoryPath,
|
|
1171
|
+
"rev-parse", "--verify", "--quiet", "--end-of-options", `${target}^{commit}`
|
|
1172
|
+
])) {
|
|
1173
|
+
if (await this.#resolveRefCommit(repositoryPath, target) !== commit) {
|
|
1174
|
+
throw new Error(`Retained ref already exists at a different commit: ${target}.`);
|
|
1175
|
+
}
|
|
1176
|
+
return;
|
|
1177
|
+
}
|
|
1178
|
+
await this.#git([
|
|
1179
|
+
"-C", repositoryPath,
|
|
1180
|
+
"update-ref", "--no-deref", target, commit, "0".repeat(commit.length)
|
|
1181
|
+
]);
|
|
1182
|
+
}
|
|
1183
|
+
/** Status can execute clean/process filters to compare equal-size changed
|
|
1184
|
+
* files. Preserve native normalization: report unknown instead of disabling
|
|
1185
|
+
* a filter and misclassifying its output as clean/dirty. Include scopes so a
|
|
1186
|
+
* native global/worktree include cannot hide the same execution requirement.
|
|
1187
|
+
*/
|
|
1188
|
+
async #readWorktreeStatus(path) {
|
|
1189
|
+
await this.#assertStatusProgramsAbsent(path);
|
|
1190
|
+
return this.#readStatusGit(path, ["status", "--porcelain=v1", "--untracked-files=all"]);
|
|
1191
|
+
}
|
|
1192
|
+
async #readStatusGit(path, args) {
|
|
1193
|
+
return this.#git(["--no-optional-locks", "-c", "core.fsmonitor=",
|
|
1194
|
+
"-c", "protocol.allow=never", "-c", "protocol.file.allow=never",
|
|
1195
|
+
"-C", path, ...args], { environment: { GIT_NO_LAZY_FETCH: "1" } });
|
|
1196
|
+
}
|
|
1197
|
+
async #assertStatusProgramsAbsent(path) {
|
|
1198
|
+
const configuration = await this.#readStatusGit(path, ["config", "--list", "--null", "--includes"]);
|
|
1199
|
+
const filters = externalProgramConfigViolations(configuration)
|
|
1200
|
+
.filter(key => key.startsWith("filter.") && (key.endsWith(".clean") || key.endsWith(".process")));
|
|
1201
|
+
const files = (await this.#readStatusGit(path, ["ls-files", "--stage", "-z"])).split("\0");
|
|
1202
|
+
if (filters.length > 0) {
|
|
1203
|
+
// Merely having native LFS configured globally is not a violation. Only
|
|
1204
|
+
// filters selected by tracked regular files' effective attributes can run
|
|
1205
|
+
// during status. check-attr reads those selections without executing them.
|
|
1206
|
+
const configured = new Set(filters.map(key => key.slice(7, key.lastIndexOf("."))));
|
|
1207
|
+
const tracked = [...new Set(files.filter(line => line.startsWith("100"))
|
|
1208
|
+
.map(line => line.slice(line.indexOf("\t") + 1)))];
|
|
1209
|
+
for (let offset = 0; offset < tracked.length; offset += 128) {
|
|
1210
|
+
const attributes = (await this.#readStatusGit(path, ["check-attr", "-z", "filter", "--",
|
|
1211
|
+
...tracked.slice(offset, offset + 128)])).split("\0");
|
|
1212
|
+
for (let index = 2; index < attributes.length; index += 3) {
|
|
1213
|
+
if (!configured.has(attributes[index].toLowerCase()))
|
|
1214
|
+
continue;
|
|
1215
|
+
cleanupFailure("git-status-requires-filter", "Git status may execute a selected filter program; read-only inspection will not run it.", "status without external filter execution", { trackedPath: attributes[index - 2], filter: attributes[index] }, "unknown");
|
|
1216
|
+
}
|
|
1217
|
+
}
|
|
1218
|
+
}
|
|
1219
|
+
// Status also examines initialized submodules. Inspect their configuration
|
|
1220
|
+
// before asking Git to recurse; uninitialized gitlinks execute nothing.
|
|
1221
|
+
for (const record of files.filter(line => line.startsWith("160000 "))) {
|
|
1222
|
+
const child = join(path, record.slice(record.indexOf("\t") + 1));
|
|
1223
|
+
assertContained(path, child);
|
|
1224
|
+
const gitEntry = await lstat(join(child, ".git")).catch(error => {
|
|
1225
|
+
if (isErrno(error, "ENOENT"))
|
|
1226
|
+
return undefined;
|
|
1227
|
+
throw error;
|
|
1228
|
+
});
|
|
1229
|
+
if (gitEntry === undefined)
|
|
1230
|
+
continue;
|
|
1231
|
+
if (await realpath(child) !== child
|
|
1232
|
+
|| (await this.#readStatusGit(child, ["rev-parse", "--show-toplevel"])).trimEnd() !== child) {
|
|
1233
|
+
cleanupFailure("workspace-identity-mismatch", "Submodule identity cannot be safely inspected.", "owned submodule directory", "different or symbolic root");
|
|
1234
|
+
}
|
|
1235
|
+
await this.#assertStatusProgramsAbsent(child);
|
|
1236
|
+
}
|
|
1237
|
+
}
|
|
1238
|
+
async #git(args, options = {}) {
|
|
1239
|
+
const localBudget = this.#localBudget();
|
|
1240
|
+
const program = supportedGit(localBudget.totalMs);
|
|
1241
|
+
const invocation = compatibleGitArguments(args);
|
|
1242
|
+
const result = await executeGitCommand(program.path, invocation, {
|
|
1243
|
+
...options,
|
|
1244
|
+
budget: options.budget ?? localBudget,
|
|
1245
|
+
stderrTail: options.budget?.inactivityMs !== undefined,
|
|
1246
|
+
...(options.environment === undefined ? {} : { environment: { ...process.env, ...options.environment } })
|
|
1247
|
+
});
|
|
1248
|
+
return result.stdout.toString("utf8");
|
|
1249
|
+
}
|
|
1250
|
+
async #gitSucceeds(args) {
|
|
1251
|
+
supportedGit(this.#localBudget().totalMs);
|
|
1252
|
+
try {
|
|
1253
|
+
await this.#git(args);
|
|
1254
|
+
return true;
|
|
1255
|
+
}
|
|
1256
|
+
catch (error) {
|
|
1257
|
+
if (error instanceof GitCommandError && error.timeoutKind === undefined
|
|
1258
|
+
&& error.signal === null && typeof error.code === "number")
|
|
1259
|
+
return false;
|
|
1260
|
+
throw error;
|
|
1261
|
+
}
|
|
1262
|
+
}
|
|
1263
|
+
async #gitLine(args) {
|
|
1264
|
+
const lines = gitOutputLine(await this.#git(args)).split("\n");
|
|
1265
|
+
if (lines.length !== 1 || lines[0]?.length === 0 || lines[0]?.includes("\0")) {
|
|
1266
|
+
throw new Error("Git returned invalid output.");
|
|
1267
|
+
}
|
|
1268
|
+
return lines[0];
|
|
1269
|
+
}
|
|
1270
|
+
async #parseFetchSpec(value) {
|
|
1271
|
+
const negative = value.startsWith("^");
|
|
1272
|
+
const text = value.replace(/^[+^]/u, "");
|
|
1273
|
+
const parts = text.split(":");
|
|
1274
|
+
const source = parts[0];
|
|
1275
|
+
const destination = parts[1] || undefined;
|
|
1276
|
+
if (parts.length > 2 || (negative && parts.length !== 1)
|
|
1277
|
+
|| !source.startsWith("refs/")
|
|
1278
|
+
|| !await this.#gitSucceeds(["check-ref-format", "--refspec-pattern", source])
|
|
1279
|
+
|| (destination !== undefined && (!destination.startsWith("refs/")
|
|
1280
|
+
|| !await this.#gitSucceeds(["check-ref-format", "--refspec-pattern", destination])
|
|
1281
|
+
|| source.includes("*") !== destination.includes("*"))))
|
|
1282
|
+
return null;
|
|
1283
|
+
return { source, ...(destination === undefined ? {} : { destination }), negative };
|
|
1284
|
+
}
|
|
1285
|
+
async #gitConfigValues(root, key) {
|
|
1286
|
+
try {
|
|
1287
|
+
return (await this.#git(["-C", root, "config", "--null", "--get-all", key]))
|
|
1288
|
+
.split("\0").filter((value) => value.length > 0);
|
|
1289
|
+
}
|
|
1290
|
+
catch (error) {
|
|
1291
|
+
const cause = error instanceof Error ? error.cause : undefined;
|
|
1292
|
+
if (typeof cause === "object" && cause !== null && "code" in cause && cause.code === 1) {
|
|
1293
|
+
return [];
|
|
1294
|
+
}
|
|
1295
|
+
throw error;
|
|
1296
|
+
}
|
|
1297
|
+
}
|
|
1298
|
+
/**
|
|
1299
|
+
* Prove a unique configured fetch destination, never infer it from a remote
|
|
1300
|
+
* name. Git resolves insteadOf aliases; push URLs are deliberately irrelevant.
|
|
1301
|
+
* Unsafe/ambiguous configurations remain usable for HEAD-only refresh.
|
|
1302
|
+
*/
|
|
1303
|
+
async #resolveFetchTracking(root, remote, branch) {
|
|
1304
|
+
const wantedUrl = await this.#gitLine(["-C", root, "ls-remote", "--get-url", "--", remote]);
|
|
1305
|
+
const names = (await this.#git(["-C", root, "remote"])).trim().split("\n").filter(Boolean).sort();
|
|
1306
|
+
const remotes = await Promise.all(names.map(async (name) => ({
|
|
1307
|
+
name,
|
|
1308
|
+
urls: (await this.#git(["-C", root, "remote", "get-url", "--all", "--", name])).trimEnd().split("\n"),
|
|
1309
|
+
specs: await this.#gitConfigValues(root, `remote.${name}.fetch`)
|
|
1310
|
+
})));
|
|
1311
|
+
const evidence = JSON.stringify({ wantedUrl, remotes });
|
|
1312
|
+
const unmanaged = (reason) => ({ status: "unmanaged", reason, evidence });
|
|
1313
|
+
const matching = remotes.filter(({ urls }) => urls[0] === wantedUrl);
|
|
1314
|
+
if (matching.length !== 1 || matching[0].urls.length !== 1) {
|
|
1315
|
+
return unmanaged("Project fetch URL does not identify exactly one single-URL Git remote.");
|
|
1316
|
+
}
|
|
1317
|
+
const selected = matching[0];
|
|
1318
|
+
const candidates = await Promise.all(remotes.map(async (entry) => ({
|
|
1319
|
+
...entry, parsed: await Promise.all(entry.specs.map(value => this.#parseFetchSpec(value)))
|
|
1320
|
+
})));
|
|
1321
|
+
if (candidates.some(({ parsed: specs }) => specs.some((spec) => spec === null))) {
|
|
1322
|
+
return unmanaged("A configured fetch refspec is outside the supported full-ref exact/one-star mapping.");
|
|
1323
|
+
}
|
|
1324
|
+
const parsed = candidates.map((entry) => ({
|
|
1325
|
+
...entry, parsed: entry.parsed.filter((spec) => spec !== null)
|
|
1326
|
+
}));
|
|
1327
|
+
const source = `refs/heads/${branch}`;
|
|
1328
|
+
const excluded = (specs, ref) => specs.some((spec) => spec.negative && refspecCapture(spec.source, ref) !== null);
|
|
1329
|
+
const selectedSpecs = parsed.find(({ name }) => name === selected.name).parsed;
|
|
1330
|
+
if (excluded(selectedSpecs, source))
|
|
1331
|
+
return unmanaged(`Fetch refspec excludes ${source}.`);
|
|
1332
|
+
const destinations = new Set();
|
|
1333
|
+
for (const spec of selectedSpecs) {
|
|
1334
|
+
if (spec.negative || spec.destination === undefined)
|
|
1335
|
+
continue;
|
|
1336
|
+
const capture = refspecCapture(spec.source, source);
|
|
1337
|
+
if (capture !== null)
|
|
1338
|
+
destinations.add(spec.destination.replace("*", capture));
|
|
1339
|
+
}
|
|
1340
|
+
if (destinations.size !== 1) {
|
|
1341
|
+
return unmanaged(`Fetch refspec does not map ${source} to exactly one destination.`);
|
|
1342
|
+
}
|
|
1343
|
+
const ref = [...destinations][0];
|
|
1344
|
+
if (!ref.startsWith("refs/remotes/") || !await this.#gitSucceeds(["check-ref-format", ref])) {
|
|
1345
|
+
return unmanaged(`Fetch destination is not a supported remote-tracking ref: ${ref}.`);
|
|
1346
|
+
}
|
|
1347
|
+
for (const entry of parsed) {
|
|
1348
|
+
for (const spec of entry.parsed) {
|
|
1349
|
+
if (spec.negative || spec.destination === undefined)
|
|
1350
|
+
continue;
|
|
1351
|
+
const capture = refspecCapture(spec.destination, ref);
|
|
1352
|
+
if (capture === null)
|
|
1353
|
+
continue;
|
|
1354
|
+
const otherSource = spec.source.replace("*", capture);
|
|
1355
|
+
if (!excluded(entry.parsed, otherSource)
|
|
1356
|
+
&& (entry.name !== selected.name || otherSource !== source)) {
|
|
1357
|
+
return unmanaged(`Another fetch source also manages ${ref}.`);
|
|
1358
|
+
}
|
|
1359
|
+
}
|
|
1360
|
+
}
|
|
1361
|
+
try {
|
|
1362
|
+
await this.#readDirectTrackingRef(root, ref);
|
|
1363
|
+
}
|
|
1364
|
+
catch (error) {
|
|
1365
|
+
return unmanaged(error instanceof Error ? error.message : String(error));
|
|
1366
|
+
}
|
|
1367
|
+
return { status: "managed", remoteName: selected.name, remoteUrl: wantedUrl, ref, evidence };
|
|
1368
|
+
}
|
|
1369
|
+
async #readDirectTrackingRef(root, ref) {
|
|
1370
|
+
const output = await this.#git([
|
|
1371
|
+
"-C", root, "for-each-ref", "--format=%(refname)%09%(objectname)%09%(symref)", "--", ref
|
|
1372
|
+
]);
|
|
1373
|
+
const record = output.split("\n").find((line) => line.split("\t")[0] === ref);
|
|
1374
|
+
if (record === undefined) {
|
|
1375
|
+
// for-each-ref omits dangling symbolic refs. Never treat those as absent.
|
|
1376
|
+
if (await this.#gitSucceeds(["-C", root, "symbolic-ref", "--quiet", ref])) {
|
|
1377
|
+
throw new Error(`Tracking destination is symbolic: ${ref}.`);
|
|
1378
|
+
}
|
|
1379
|
+
return null;
|
|
1380
|
+
}
|
|
1381
|
+
const [, commit, symbolic] = record.split("\t");
|
|
1382
|
+
if (symbolic !== "" || commit === undefined || !isCommit(commit)) {
|
|
1383
|
+
throw new Error(`Tracking destination is not a direct object ref: ${ref}.`);
|
|
1384
|
+
}
|
|
1385
|
+
return commit.toLowerCase();
|
|
1386
|
+
}
|
|
1387
|
+
async #safeFetchBranch(value) {
|
|
1388
|
+
supportedGit(this.#localBudget().totalMs);
|
|
1389
|
+
const configuredRef = safeRef(value);
|
|
1390
|
+
if (configuredRef === "HEAD")
|
|
1391
|
+
return configuredRef;
|
|
1392
|
+
const branchPrefix = "refs/heads/";
|
|
1393
|
+
const branch = configuredRef.startsWith(branchPrefix)
|
|
1394
|
+
? configuredRef.slice(branchPrefix.length)
|
|
1395
|
+
: configuredRef;
|
|
1396
|
+
if (!await this.#gitSucceeds(["check-ref-format", "--branch", branch])) {
|
|
1397
|
+
throw new Error("Git stable branch is invalid.");
|
|
1398
|
+
}
|
|
1399
|
+
return branch;
|
|
1400
|
+
}
|
|
1401
|
+
async #resolveRemoteHeadBranch(remote, budget, repositoryPath) {
|
|
1402
|
+
let output;
|
|
1403
|
+
try {
|
|
1404
|
+
output = await this.#git([
|
|
1405
|
+
...(repositoryPath === undefined ? [] : ["-C", repositoryPath]),
|
|
1406
|
+
"ls-remote", "--symref", remote, "HEAD"
|
|
1407
|
+
], { budget });
|
|
1408
|
+
}
|
|
1409
|
+
catch (error) {
|
|
1410
|
+
const detail = error instanceof Error ? error.message : "Git command failed.";
|
|
1411
|
+
throw new Error(`Project remote HEAD could not be resolved: ${detail}`, { cause: error });
|
|
1412
|
+
}
|
|
1413
|
+
const symbolicLines = output.split("\n").filter((line) => line.startsWith("ref: "));
|
|
1414
|
+
const prefix = "ref: refs/heads/";
|
|
1415
|
+
const suffix = "\tHEAD";
|
|
1416
|
+
const symbolic = symbolicLines.length === 1 ? symbolicLines[0] : "";
|
|
1417
|
+
if (!symbolic.startsWith(prefix) || !symbolic.endsWith(suffix)) {
|
|
1418
|
+
throw invalidRemoteHead();
|
|
1419
|
+
}
|
|
1420
|
+
const branch = symbolic.slice(prefix.length, -suffix.length);
|
|
1421
|
+
if (branch === "HEAD")
|
|
1422
|
+
throw invalidRemoteHead();
|
|
1423
|
+
try {
|
|
1424
|
+
return await this.#safeFetchBranch(branch);
|
|
1425
|
+
}
|
|
1426
|
+
catch (error) {
|
|
1427
|
+
if (error instanceof GitCommandError)
|
|
1428
|
+
throw error;
|
|
1429
|
+
throw invalidRemoteHead();
|
|
1430
|
+
}
|
|
1431
|
+
}
|
|
1432
|
+
async #resolveRemoteBranchCommit(remote, branch, budget, repositoryPath) {
|
|
1433
|
+
const target = `refs/heads/${branch}`;
|
|
1434
|
+
let output;
|
|
1435
|
+
try {
|
|
1436
|
+
output = await this.#git([
|
|
1437
|
+
...(repositoryPath === undefined ? [] : ["-C", repositoryPath]),
|
|
1438
|
+
"ls-remote", "--refs", remote, target
|
|
1439
|
+
], { budget });
|
|
1440
|
+
}
|
|
1441
|
+
catch (error) {
|
|
1442
|
+
const detail = error instanceof Error ? error.message : "Git command failed.";
|
|
1443
|
+
throw new Error(`Project remote development branch could not be resolved: ${branch}: ${detail}`, { cause: error });
|
|
1444
|
+
}
|
|
1445
|
+
const matches = output
|
|
1446
|
+
.split("\n")
|
|
1447
|
+
.map((line) => line.trim())
|
|
1448
|
+
.filter((line) => line.length > 0)
|
|
1449
|
+
.map((line) => line.split(/\s+/u))
|
|
1450
|
+
.filter(([commit, ref]) => ref === target && commit !== undefined);
|
|
1451
|
+
if (matches.length !== 1) {
|
|
1452
|
+
throw new Error(`Project remote development branch could not be resolved: ${branch}.`);
|
|
1453
|
+
}
|
|
1454
|
+
const commit = matches[0][0];
|
|
1455
|
+
if (!isCommit(commit)) {
|
|
1456
|
+
throw new Error(`Project remote development branch returned an invalid commit: ${branch}.`);
|
|
1457
|
+
}
|
|
1458
|
+
return commit.toLowerCase();
|
|
1459
|
+
}
|
|
1460
|
+
async #resolveFetchedCommit(repositoryPath, fetchedRef) {
|
|
1461
|
+
const commit = (await this.#gitLine([
|
|
1462
|
+
"-C", repositoryPath,
|
|
1463
|
+
"rev-parse", "--verify", "--end-of-options", `${fetchedRef}^{commit}`
|
|
1464
|
+
])).toLowerCase();
|
|
1465
|
+
if (!/^[a-f0-9]{40}(?:[a-f0-9]{24})?$/u.test(commit)) {
|
|
1466
|
+
throw new Error("Git returned an invalid fetched remote commit.");
|
|
1467
|
+
}
|
|
1468
|
+
return commit;
|
|
1469
|
+
}
|
|
1047
1470
|
}
|
|
1048
1471
|
export function worktreeIdentity(taskId, roleName) {
|
|
1049
1472
|
const taskKey = safeIdentity(taskId, "Task id");
|
|
@@ -1071,103 +1494,6 @@ function gitRefSegment(identity) {
|
|
|
1071
1494
|
const digest = createHash("sha256").update(identity).digest("hex").slice(0, 24);
|
|
1072
1495
|
return `encoded-${digest}`;
|
|
1073
1496
|
}
|
|
1074
|
-
async function assertExpectedBranch(path, expected, allowRebase = false) {
|
|
1075
|
-
if (allowRebase && !await gitSucceeds(["-C", path, "symbolic-ref", "--quiet", "HEAD"])) {
|
|
1076
|
-
// Rebase temporarily detaches HEAD. Git's per-worktree metadata must still
|
|
1077
|
-
// identify this exact Integration branch; unrelated detachment is invalid.
|
|
1078
|
-
for (const backend of ["rebase-merge", "rebase-apply"]) {
|
|
1079
|
-
const headNamePath = await gitPath(path, ["--git-path", `${backend}/head-name`]);
|
|
1080
|
-
let headName;
|
|
1081
|
-
try {
|
|
1082
|
-
headName = (await readFile(headNamePath, "utf8")).trim();
|
|
1083
|
-
}
|
|
1084
|
-
catch (error) {
|
|
1085
|
-
if (error.code === "ENOENT")
|
|
1086
|
-
continue;
|
|
1087
|
-
throw error;
|
|
1088
|
-
}
|
|
1089
|
-
if (headName === `refs/heads/${expected}`)
|
|
1090
|
-
return;
|
|
1091
|
-
throw new Error(`Managed worktree is rebasing an unexpected branch: ${headName}.`);
|
|
1092
|
-
}
|
|
1093
|
-
// A terminal Integration can legitimately retain a detached checkout.
|
|
1094
|
-
// Its exact owned path/common-dir were checked by the caller; require its
|
|
1095
|
-
// retained branch to pin the same commit before allowing clean removal.
|
|
1096
|
-
if (await resolveRefCommit(path, "HEAD") === await resolveRefCommit(path, `refs/heads/${expected}`))
|
|
1097
|
-
return;
|
|
1098
|
-
cleanupFailure("workspace-identity-mismatch", "Detached worktree is not pinned by its owned branch or rebase.", expected, "unproven detached HEAD");
|
|
1099
|
-
}
|
|
1100
|
-
const branch = await gitLine(["-C", path, "symbolic-ref", "--short", "HEAD"]);
|
|
1101
|
-
if (branch !== expected) {
|
|
1102
|
-
cleanupFailure("workspace-identity-mismatch", "Managed worktree is on an unexpected branch.", expected, branch);
|
|
1103
|
-
}
|
|
1104
|
-
}
|
|
1105
|
-
async function assertOwnedWorktree(project, container, path) {
|
|
1106
|
-
const canonicalPath = await canonicalDirectory(path, "Managed worktree");
|
|
1107
|
-
assertContained(container, canonicalPath);
|
|
1108
|
-
if (canonicalPath !== path)
|
|
1109
|
-
throw new Error("Managed worktree resolves through a symbolic link.");
|
|
1110
|
-
const root = await canonicalDirectory(await gitPath(path, ["--show-toplevel"]), "Managed worktree root");
|
|
1111
|
-
if (root !== path)
|
|
1112
|
-
throw new Error("Managed worktree root does not match its deterministic path.");
|
|
1113
|
-
const common = await canonicalDirectory(await gitPath(path, ["--git-common-dir"]), "Managed Git common directory");
|
|
1114
|
-
if (common !== project.gitDirectory) {
|
|
1115
|
-
cleanupFailure("workspace-identity-mismatch", "Managed worktree belongs to another project.", "Task main Git common directory", "different Git common directory");
|
|
1116
|
-
}
|
|
1117
|
-
}
|
|
1118
|
-
async function inspectExactRecordedWorktree(input) {
|
|
1119
|
-
const container = await canonicalContainer(input.container, false);
|
|
1120
|
-
const expectedBranch = worktreeIdentity(input.taskSegment, input.roleName).branch;
|
|
1121
|
-
const expectedPath = managedPath(container, input.directory);
|
|
1122
|
-
if (resolve(input.path) !== expectedPath || input.branch !== expectedBranch) {
|
|
1123
|
-
throw new Error("Recorded managed worktree identity is invalid.");
|
|
1124
|
-
}
|
|
1125
|
-
const kind = await pathKind(expectedPath);
|
|
1126
|
-
if (kind === undefined)
|
|
1127
|
-
return undefined;
|
|
1128
|
-
if (kind === "symlink") {
|
|
1129
|
-
throw new Error("Recorded managed worktree path must not be a symbolic link.");
|
|
1130
|
-
}
|
|
1131
|
-
const canonicalContainerPath = await canonicalContainer(container, false);
|
|
1132
|
-
const path = await canonicalDirectory(expectedPath, "Recorded managed worktree");
|
|
1133
|
-
assertContained(canonicalContainerPath, path);
|
|
1134
|
-
if (path !== expectedPath) {
|
|
1135
|
-
throw new Error("Recorded managed worktree resolves through a symbolic link.");
|
|
1136
|
-
}
|
|
1137
|
-
const root = await canonicalDirectory(await gitPath(path, ["--show-toplevel"]), "Recorded managed worktree root");
|
|
1138
|
-
if (root !== path) {
|
|
1139
|
-
throw new Error("Recorded managed worktree root does not match its deterministic path.");
|
|
1140
|
-
}
|
|
1141
|
-
const branch = await gitLine(["-C", path, "symbolic-ref", "--short", "HEAD"]);
|
|
1142
|
-
if (branch !== input.branch) {
|
|
1143
|
-
throw new Error(`Recorded managed worktree is on an unexpected branch: ${branch}.`);
|
|
1144
|
-
}
|
|
1145
|
-
const gitDirectory = await canonicalDirectory(await gitPath(path, ["--git-common-dir"]), "Recorded managed Git common directory");
|
|
1146
|
-
const destination = await new NodeGitWorkspace().inspect(input.repositoryPath);
|
|
1147
|
-
const head = await resolveRefCommit(path, "HEAD");
|
|
1148
|
-
const retained = await resolveRefCommit(destination.root, `refs/heads/${input.branch}`);
|
|
1149
|
-
if (retained !== head) {
|
|
1150
|
-
throw new Error(`Recorded managed worktree is not retained by the current Project: ${input.branch}.`);
|
|
1151
|
-
}
|
|
1152
|
-
const status = await git(["-C", path, "status", "--porcelain=v1", "--untracked-files=all"]);
|
|
1153
|
-
return {
|
|
1154
|
-
path,
|
|
1155
|
-
gitDirectory,
|
|
1156
|
-
destinationGitDirectory: destination.gitDirectory,
|
|
1157
|
-
destinationRoot: destination.root,
|
|
1158
|
-
head,
|
|
1159
|
-
state: status.length === 0 ? "clean" : "dirty"
|
|
1160
|
-
};
|
|
1161
|
-
}
|
|
1162
|
-
async function resolveRefCommit(repositoryPath, ref) {
|
|
1163
|
-
const commit = (await gitLine([
|
|
1164
|
-
"-C", repositoryPath,
|
|
1165
|
-
"rev-parse", "--verify", "--end-of-options", `${safeRef(ref)}^{commit}`
|
|
1166
|
-
])).toLowerCase();
|
|
1167
|
-
if (!isCommit(commit))
|
|
1168
|
-
throw new Error("Git returned an invalid ref commit.");
|
|
1169
|
-
return commit;
|
|
1170
|
-
}
|
|
1171
1497
|
/**
|
|
1172
1498
|
* Classify a recorded worktree whose Project repository can no longer be
|
|
1173
1499
|
* inspected. A deleted external checkout takes the worktree's common dir with
|
|
@@ -1191,22 +1517,6 @@ async function recordedWorktreeWithoutRepository(input, cause) {
|
|
|
1191
1517
|
+ `The Project repository is gone, so the worktree's Git state cannot be verified; `
|
|
1192
1518
|
+ `remove the directory manually once its contents are safe.`);
|
|
1193
1519
|
}
|
|
1194
|
-
async function retainCommitRef(repositoryPath, ref, commit) {
|
|
1195
|
-
const target = safeRef(ref);
|
|
1196
|
-
if (await gitSucceeds([
|
|
1197
|
-
"-C", repositoryPath,
|
|
1198
|
-
"rev-parse", "--verify", "--quiet", "--end-of-options", `${target}^{commit}`
|
|
1199
|
-
])) {
|
|
1200
|
-
if (await resolveRefCommit(repositoryPath, target) !== commit) {
|
|
1201
|
-
throw new Error(`Retained ref already exists at a different commit: ${target}.`);
|
|
1202
|
-
}
|
|
1203
|
-
return;
|
|
1204
|
-
}
|
|
1205
|
-
await git([
|
|
1206
|
-
"-C", repositoryPath,
|
|
1207
|
-
"update-ref", "--no-deref", target, commit, "0".repeat(commit.length)
|
|
1208
|
-
]);
|
|
1209
|
-
}
|
|
1210
1520
|
async function canonicalContainer(path, create) {
|
|
1211
1521
|
const lexical = resolve(path);
|
|
1212
1522
|
if (create)
|
|
@@ -1269,102 +1579,6 @@ async function pathKind(path) {
|
|
|
1269
1579
|
throw error;
|
|
1270
1580
|
}
|
|
1271
1581
|
}
|
|
1272
|
-
/** Status can execute clean/process filters to compare equal-size changed
|
|
1273
|
-
* files. Preserve native normalization: report unknown instead of disabling
|
|
1274
|
-
* a filter and misclassifying its output as clean/dirty. Include scopes so a
|
|
1275
|
-
* native global/worktree include cannot hide the same execution requirement.
|
|
1276
|
-
*/
|
|
1277
|
-
async function readWorktreeStatus(path) {
|
|
1278
|
-
await assertStatusProgramsAbsent(path);
|
|
1279
|
-
return readStatusGit(path, ["status", "--porcelain=v1", "--untracked-files=all"]);
|
|
1280
|
-
}
|
|
1281
|
-
async function readStatusGit(path, args) {
|
|
1282
|
-
return git(["--no-optional-locks", "-c", "core.fsmonitor=",
|
|
1283
|
-
"-c", "protocol.allow=never", "-c", "protocol.file.allow=never",
|
|
1284
|
-
"-C", path, ...args], { environment: { GIT_NO_LAZY_FETCH: "1" } });
|
|
1285
|
-
}
|
|
1286
|
-
async function assertStatusProgramsAbsent(path) {
|
|
1287
|
-
const configuration = await readStatusGit(path, ["config", "--list", "--null", "--includes"]);
|
|
1288
|
-
const filters = externalProgramConfigViolations(configuration)
|
|
1289
|
-
.filter(key => key.startsWith("filter.") && (key.endsWith(".clean") || key.endsWith(".process")));
|
|
1290
|
-
const files = (await readStatusGit(path, ["ls-files", "--stage", "-z"])).split("\0");
|
|
1291
|
-
if (filters.length > 0) {
|
|
1292
|
-
// Merely having native LFS configured globally is not a violation. Only
|
|
1293
|
-
// filters selected by tracked regular files' effective attributes can run
|
|
1294
|
-
// during status. check-attr reads those selections without executing them.
|
|
1295
|
-
const configured = new Set(filters.map(key => key.slice(7, key.lastIndexOf("."))));
|
|
1296
|
-
const tracked = [...new Set(files.filter(line => line.startsWith("100"))
|
|
1297
|
-
.map(line => line.slice(line.indexOf("\t") + 1)))];
|
|
1298
|
-
for (let offset = 0; offset < tracked.length; offset += 128) {
|
|
1299
|
-
const attributes = (await readStatusGit(path, ["check-attr", "-z", "filter", "--",
|
|
1300
|
-
...tracked.slice(offset, offset + 128)])).split("\0");
|
|
1301
|
-
for (let index = 2; index < attributes.length; index += 3) {
|
|
1302
|
-
if (!configured.has(attributes[index].toLowerCase()))
|
|
1303
|
-
continue;
|
|
1304
|
-
cleanupFailure("git-status-requires-filter", "Git status may execute a selected filter program; read-only inspection will not run it.", "status without external filter execution", { trackedPath: attributes[index - 2], filter: attributes[index] }, "unknown");
|
|
1305
|
-
}
|
|
1306
|
-
}
|
|
1307
|
-
}
|
|
1308
|
-
// Status also examines initialized submodules. Inspect their configuration
|
|
1309
|
-
// before asking Git to recurse; uninitialized gitlinks execute nothing.
|
|
1310
|
-
for (const record of files.filter(line => line.startsWith("160000 "))) {
|
|
1311
|
-
const child = join(path, record.slice(record.indexOf("\t") + 1));
|
|
1312
|
-
assertContained(path, child);
|
|
1313
|
-
const gitEntry = await lstat(join(child, ".git")).catch(error => {
|
|
1314
|
-
if (isErrno(error, "ENOENT"))
|
|
1315
|
-
return undefined;
|
|
1316
|
-
throw error;
|
|
1317
|
-
});
|
|
1318
|
-
if (gitEntry === undefined)
|
|
1319
|
-
continue;
|
|
1320
|
-
if (await realpath(child) !== child
|
|
1321
|
-
|| (await readStatusGit(child, ["rev-parse", "--show-toplevel"])).trimEnd() !== child) {
|
|
1322
|
-
cleanupFailure("workspace-identity-mismatch", "Submodule identity cannot be safely inspected.", "owned submodule directory", "different or symbolic root");
|
|
1323
|
-
}
|
|
1324
|
-
await assertStatusProgramsAbsent(child);
|
|
1325
|
-
}
|
|
1326
|
-
}
|
|
1327
|
-
async function git(args, options = {}) {
|
|
1328
|
-
const program = supportedGit();
|
|
1329
|
-
const invocation = compatibleGitArguments(args);
|
|
1330
|
-
try {
|
|
1331
|
-
const execution = executeFile(program.path, invocation, {
|
|
1332
|
-
encoding: "utf8",
|
|
1333
|
-
maxBuffer: 1024 * 1024,
|
|
1334
|
-
timeout: 30_000,
|
|
1335
|
-
...(options.environment === undefined ? {} : { env: { ...process.env, ...options.environment } })
|
|
1336
|
-
});
|
|
1337
|
-
if (options.input !== undefined)
|
|
1338
|
-
execution.child.stdin.end(options.input);
|
|
1339
|
-
const result = await execution;
|
|
1340
|
-
return result.stdout;
|
|
1341
|
-
}
|
|
1342
|
-
catch (error) {
|
|
1343
|
-
const stderr = typeof error === "object" && error !== null && "stderr" in error
|
|
1344
|
-
? String(error.stderr).trim()
|
|
1345
|
-
: "";
|
|
1346
|
-
throw new Error(stderr.length === 0 ? "Git command failed." : `Git command failed: ${stderr}`, {
|
|
1347
|
-
cause: error
|
|
1348
|
-
});
|
|
1349
|
-
}
|
|
1350
|
-
}
|
|
1351
|
-
async function gitSucceeds(args) {
|
|
1352
|
-
supportedGit();
|
|
1353
|
-
try {
|
|
1354
|
-
await git(args);
|
|
1355
|
-
return true;
|
|
1356
|
-
}
|
|
1357
|
-
catch {
|
|
1358
|
-
return false;
|
|
1359
|
-
}
|
|
1360
|
-
}
|
|
1361
|
-
async function gitLine(args) {
|
|
1362
|
-
const lines = gitOutputLine(await git(args)).split("\n");
|
|
1363
|
-
if (lines.length !== 1 || lines[0]?.length === 0 || lines[0]?.includes("\0")) {
|
|
1364
|
-
throw new Error("Git returned invalid output.");
|
|
1365
|
-
}
|
|
1366
|
-
return lines[0];
|
|
1367
|
-
}
|
|
1368
1582
|
/** Match one exact or one-star Git refspec, in either direction. */
|
|
1369
1583
|
function refspecCapture(pattern, ref) {
|
|
1370
1584
|
const star = pattern.indexOf("*");
|
|
@@ -1376,123 +1590,6 @@ function refspecCapture(pattern, ref) {
|
|
|
1376
1590
|
&& ref.length >= prefix.length + suffix.length
|
|
1377
1591
|
? ref.slice(prefix.length, ref.length - suffix.length) : null;
|
|
1378
1592
|
}
|
|
1379
|
-
async function parseFetchSpec(value) {
|
|
1380
|
-
const negative = value.startsWith("^");
|
|
1381
|
-
const text = value.replace(/^[+^]/u, "");
|
|
1382
|
-
const parts = text.split(":");
|
|
1383
|
-
const source = parts[0];
|
|
1384
|
-
const destination = parts[1] || undefined;
|
|
1385
|
-
if (parts.length > 2 || (negative && parts.length !== 1)
|
|
1386
|
-
|| !source.startsWith("refs/")
|
|
1387
|
-
|| !await gitSucceeds(["check-ref-format", "--refspec-pattern", source])
|
|
1388
|
-
|| (destination !== undefined && (!destination.startsWith("refs/")
|
|
1389
|
-
|| !await gitSucceeds(["check-ref-format", "--refspec-pattern", destination])
|
|
1390
|
-
|| source.includes("*") !== destination.includes("*"))))
|
|
1391
|
-
return null;
|
|
1392
|
-
return { source, ...(destination === undefined ? {} : { destination }), negative };
|
|
1393
|
-
}
|
|
1394
|
-
async function gitConfigValues(root, key) {
|
|
1395
|
-
try {
|
|
1396
|
-
return (await git(["-C", root, "config", "--null", "--get-all", key]))
|
|
1397
|
-
.split("\0").filter((value) => value.length > 0);
|
|
1398
|
-
}
|
|
1399
|
-
catch (error) {
|
|
1400
|
-
const cause = error instanceof Error ? error.cause : undefined;
|
|
1401
|
-
if (typeof cause === "object" && cause !== null && "code" in cause && cause.code === 1) {
|
|
1402
|
-
return [];
|
|
1403
|
-
}
|
|
1404
|
-
throw error;
|
|
1405
|
-
}
|
|
1406
|
-
}
|
|
1407
|
-
/**
|
|
1408
|
-
* Prove a unique configured fetch destination, never infer it from a remote
|
|
1409
|
-
* name. Git resolves insteadOf aliases; push URLs are deliberately irrelevant.
|
|
1410
|
-
* Unsafe/ambiguous configurations remain usable for HEAD-only refresh.
|
|
1411
|
-
*/
|
|
1412
|
-
async function resolveFetchTracking(root, remote, branch) {
|
|
1413
|
-
const wantedUrl = await gitLine(["-C", root, "ls-remote", "--get-url", "--", remote]);
|
|
1414
|
-
const names = (await git(["-C", root, "remote"])).trim().split("\n").filter(Boolean).sort();
|
|
1415
|
-
const remotes = await Promise.all(names.map(async (name) => ({
|
|
1416
|
-
name,
|
|
1417
|
-
urls: (await git(["-C", root, "remote", "get-url", "--all", "--", name])).trimEnd().split("\n"),
|
|
1418
|
-
specs: await gitConfigValues(root, `remote.${name}.fetch`)
|
|
1419
|
-
})));
|
|
1420
|
-
const evidence = JSON.stringify({ wantedUrl, remotes });
|
|
1421
|
-
const unmanaged = (reason) => ({ status: "unmanaged", reason, evidence });
|
|
1422
|
-
const matching = remotes.filter(({ urls }) => urls[0] === wantedUrl);
|
|
1423
|
-
if (matching.length !== 1 || matching[0].urls.length !== 1) {
|
|
1424
|
-
return unmanaged("Project fetch URL does not identify exactly one single-URL Git remote.");
|
|
1425
|
-
}
|
|
1426
|
-
const selected = matching[0];
|
|
1427
|
-
const candidates = await Promise.all(remotes.map(async (entry) => ({
|
|
1428
|
-
...entry, parsed: await Promise.all(entry.specs.map(parseFetchSpec))
|
|
1429
|
-
})));
|
|
1430
|
-
if (candidates.some(({ parsed: specs }) => specs.some((spec) => spec === null))) {
|
|
1431
|
-
return unmanaged("A configured fetch refspec is outside the supported full-ref exact/one-star mapping.");
|
|
1432
|
-
}
|
|
1433
|
-
const parsed = candidates.map((entry) => ({
|
|
1434
|
-
...entry, parsed: entry.parsed.filter((spec) => spec !== null)
|
|
1435
|
-
}));
|
|
1436
|
-
const source = `refs/heads/${branch}`;
|
|
1437
|
-
const excluded = (specs, ref) => specs.some((spec) => spec.negative && refspecCapture(spec.source, ref) !== null);
|
|
1438
|
-
const selectedSpecs = parsed.find(({ name }) => name === selected.name).parsed;
|
|
1439
|
-
if (excluded(selectedSpecs, source))
|
|
1440
|
-
return unmanaged(`Fetch refspec excludes ${source}.`);
|
|
1441
|
-
const destinations = new Set();
|
|
1442
|
-
for (const spec of selectedSpecs) {
|
|
1443
|
-
if (spec.negative || spec.destination === undefined)
|
|
1444
|
-
continue;
|
|
1445
|
-
const capture = refspecCapture(spec.source, source);
|
|
1446
|
-
if (capture !== null)
|
|
1447
|
-
destinations.add(spec.destination.replace("*", capture));
|
|
1448
|
-
}
|
|
1449
|
-
if (destinations.size !== 1) {
|
|
1450
|
-
return unmanaged(`Fetch refspec does not map ${source} to exactly one destination.`);
|
|
1451
|
-
}
|
|
1452
|
-
const ref = [...destinations][0];
|
|
1453
|
-
if (!ref.startsWith("refs/remotes/") || !await gitSucceeds(["check-ref-format", ref])) {
|
|
1454
|
-
return unmanaged(`Fetch destination is not a supported remote-tracking ref: ${ref}.`);
|
|
1455
|
-
}
|
|
1456
|
-
for (const entry of parsed) {
|
|
1457
|
-
for (const spec of entry.parsed) {
|
|
1458
|
-
if (spec.negative || spec.destination === undefined)
|
|
1459
|
-
continue;
|
|
1460
|
-
const capture = refspecCapture(spec.destination, ref);
|
|
1461
|
-
if (capture === null)
|
|
1462
|
-
continue;
|
|
1463
|
-
const otherSource = spec.source.replace("*", capture);
|
|
1464
|
-
if (!excluded(entry.parsed, otherSource)
|
|
1465
|
-
&& (entry.name !== selected.name || otherSource !== source)) {
|
|
1466
|
-
return unmanaged(`Another fetch source also manages ${ref}.`);
|
|
1467
|
-
}
|
|
1468
|
-
}
|
|
1469
|
-
}
|
|
1470
|
-
try {
|
|
1471
|
-
await readDirectTrackingRef(root, ref);
|
|
1472
|
-
}
|
|
1473
|
-
catch (error) {
|
|
1474
|
-
return unmanaged(error instanceof Error ? error.message : String(error));
|
|
1475
|
-
}
|
|
1476
|
-
return { status: "managed", remoteName: selected.name, remoteUrl: wantedUrl, ref, evidence };
|
|
1477
|
-
}
|
|
1478
|
-
async function readDirectTrackingRef(root, ref) {
|
|
1479
|
-
const output = await git([
|
|
1480
|
-
"-C", root, "for-each-ref", "--format=%(refname)%09%(objectname)%09%(symref)", "--", ref
|
|
1481
|
-
]);
|
|
1482
|
-
const record = output.split("\n").find((line) => line.split("\t")[0] === ref);
|
|
1483
|
-
if (record === undefined) {
|
|
1484
|
-
// for-each-ref omits dangling symbolic refs. Never treat those as absent.
|
|
1485
|
-
if (await gitSucceeds(["-C", root, "symbolic-ref", "--quiet", ref])) {
|
|
1486
|
-
throw new Error(`Tracking destination is symbolic: ${ref}.`);
|
|
1487
|
-
}
|
|
1488
|
-
return null;
|
|
1489
|
-
}
|
|
1490
|
-
const [, commit, symbolic] = record.split("\t");
|
|
1491
|
-
if (symbolic !== "" || commit === undefined || !isCommit(commit)) {
|
|
1492
|
-
throw new Error(`Tracking destination is not a direct object ref: ${ref}.`);
|
|
1493
|
-
}
|
|
1494
|
-
return commit.toLowerCase();
|
|
1495
|
-
}
|
|
1496
1593
|
function safeIdentity(value, label) {
|
|
1497
1594
|
const identity = requireText(value, label);
|
|
1498
1595
|
if ([".", "..", "__proto__", "prototype", "constructor"].includes(identity)
|
|
@@ -1507,87 +1604,6 @@ function safeRef(value) {
|
|
|
1507
1604
|
throw new Error("Git base ref is invalid.");
|
|
1508
1605
|
return ref;
|
|
1509
1606
|
}
|
|
1510
|
-
async function safeFetchBranch(value) {
|
|
1511
|
-
supportedGit();
|
|
1512
|
-
const configuredRef = safeRef(value);
|
|
1513
|
-
if (configuredRef === "HEAD")
|
|
1514
|
-
return configuredRef;
|
|
1515
|
-
const branchPrefix = "refs/heads/";
|
|
1516
|
-
const branch = configuredRef.startsWith(branchPrefix)
|
|
1517
|
-
? configuredRef.slice(branchPrefix.length)
|
|
1518
|
-
: configuredRef;
|
|
1519
|
-
if (!await gitSucceeds(["check-ref-format", "--branch", branch])) {
|
|
1520
|
-
throw new Error("Git stable branch is invalid.");
|
|
1521
|
-
}
|
|
1522
|
-
return branch;
|
|
1523
|
-
}
|
|
1524
|
-
async function resolveRemoteHeadBranch(remote, repositoryPath) {
|
|
1525
|
-
let output;
|
|
1526
|
-
try {
|
|
1527
|
-
output = await git([
|
|
1528
|
-
...(repositoryPath === undefined ? [] : ["-C", repositoryPath]),
|
|
1529
|
-
"ls-remote", "--symref", remote, "HEAD"
|
|
1530
|
-
]);
|
|
1531
|
-
}
|
|
1532
|
-
catch (error) {
|
|
1533
|
-
const detail = error instanceof Error ? error.message : "Git command failed.";
|
|
1534
|
-
throw new Error(`Project remote HEAD could not be resolved: ${detail}`, { cause: error });
|
|
1535
|
-
}
|
|
1536
|
-
const symbolicLines = output.split("\n").filter((line) => line.startsWith("ref: "));
|
|
1537
|
-
const prefix = "ref: refs/heads/";
|
|
1538
|
-
const suffix = "\tHEAD";
|
|
1539
|
-
const symbolic = symbolicLines.length === 1 ? symbolicLines[0] : "";
|
|
1540
|
-
if (!symbolic.startsWith(prefix) || !symbolic.endsWith(suffix)) {
|
|
1541
|
-
throw invalidRemoteHead();
|
|
1542
|
-
}
|
|
1543
|
-
const branch = symbolic.slice(prefix.length, -suffix.length);
|
|
1544
|
-
if (branch === "HEAD")
|
|
1545
|
-
throw invalidRemoteHead();
|
|
1546
|
-
try {
|
|
1547
|
-
return await safeFetchBranch(branch);
|
|
1548
|
-
}
|
|
1549
|
-
catch {
|
|
1550
|
-
throw invalidRemoteHead();
|
|
1551
|
-
}
|
|
1552
|
-
}
|
|
1553
|
-
async function resolveRemoteBranchCommit(remote, branch, repositoryPath) {
|
|
1554
|
-
const target = `refs/heads/${branch}`;
|
|
1555
|
-
let output;
|
|
1556
|
-
try {
|
|
1557
|
-
output = await git([
|
|
1558
|
-
...(repositoryPath === undefined ? [] : ["-C", repositoryPath]),
|
|
1559
|
-
"ls-remote", "--refs", remote, target
|
|
1560
|
-
]);
|
|
1561
|
-
}
|
|
1562
|
-
catch (error) {
|
|
1563
|
-
const detail = error instanceof Error ? error.message : "Git command failed.";
|
|
1564
|
-
throw new Error(`Project remote development branch could not be resolved: ${branch}: ${detail}`, { cause: error });
|
|
1565
|
-
}
|
|
1566
|
-
const matches = output
|
|
1567
|
-
.split("\n")
|
|
1568
|
-
.map((line) => line.trim())
|
|
1569
|
-
.filter((line) => line.length > 0)
|
|
1570
|
-
.map((line) => line.split(/\s+/u))
|
|
1571
|
-
.filter(([commit, ref]) => ref === target && commit !== undefined);
|
|
1572
|
-
if (matches.length !== 1) {
|
|
1573
|
-
throw new Error(`Project remote development branch could not be resolved: ${branch}.`);
|
|
1574
|
-
}
|
|
1575
|
-
const commit = matches[0][0];
|
|
1576
|
-
if (!isCommit(commit)) {
|
|
1577
|
-
throw new Error(`Project remote development branch returned an invalid commit: ${branch}.`);
|
|
1578
|
-
}
|
|
1579
|
-
return commit.toLowerCase();
|
|
1580
|
-
}
|
|
1581
|
-
async function resolveFetchedCommit(repositoryPath, fetchedRef) {
|
|
1582
|
-
const commit = (await gitLine([
|
|
1583
|
-
"-C", repositoryPath,
|
|
1584
|
-
"rev-parse", "--verify", "--end-of-options", `${fetchedRef}^{commit}`
|
|
1585
|
-
])).toLowerCase();
|
|
1586
|
-
if (!/^[a-f0-9]{40}(?:[a-f0-9]{24})?$/u.test(commit)) {
|
|
1587
|
-
throw new Error("Git returned an invalid fetched remote commit.");
|
|
1588
|
-
}
|
|
1589
|
-
return commit;
|
|
1590
|
-
}
|
|
1591
1607
|
function remoteBaselineRef(remote, branch) {
|
|
1592
1608
|
const identity = `${remote}\0${branch}\0${process.pid}\0${Date.now()}\0${Math.random()}`;
|
|
1593
1609
|
const digest = createHash("sha256").update(identity).digest("hex");
|