@zq-silk/yui 0.15.9 → 0.15.12
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/ARCHITECTURE.md +8 -4
- package/ARCHITECTURE.zh-CN.md +5 -2
- package/README.md +13 -5
- package/dist/agent/launchEnvironment.js +7 -0
- package/dist/agentRun/agentRun.js +3 -0
- package/dist/cli/commandCatalog.js +64 -16
- package/dist/cli/interactionPolicy.js +7 -3
- package/dist/cli/managedDiagnostics.js +1 -1
- package/dist/cli/updateOrchestrator.js +24 -1
- package/dist/cli/updatePorts.js +7 -3
- package/dist/cli/upgradeCommand.js +42 -2
- package/dist/cli.js +381 -107
- package/dist/commands/executionAuditCommands.js +10 -0
- package/dist/commands/globalRoleCommands.js +339 -4
- package/dist/commands/projectCommands.js +50 -22
- package/dist/commands/releaseCommands.js +18 -0
- package/dist/commands/taskActor.js +25 -0
- package/dist/commands/taskCommands.js +586 -96
- package/dist/commands/taskIntegrationCommands.js +19 -39
- package/dist/commands/taskIntegrationQueueCommands.js +1 -1
- package/dist/commands/taskOverviewCommand.js +4 -3
- package/dist/commands/taskPublicationAdoptCommand.js +127 -0
- package/dist/commands/taskPublicationCommands.js +11 -2
- package/dist/commands/taskPublicationVerifyCommand.js +23 -39
- package/dist/commands/taskRemoteDeliveryCommand.js +22 -11
- package/dist/commands/taskRoleRuntimeStatus.js +35 -0
- package/dist/context/runContextPack.js +3 -0
- package/dist/context/taskCatalog.js +187 -0
- package/dist/context/taskContext.js +55 -6
- package/dist/controller/agentHostObservation.js +155 -0
- package/dist/controller/clientRuntime.js +17 -2
- package/dist/controller/controller.js +14 -2
- package/dist/controller/fileSchedulerStoreAdapter.js +519 -25
- package/dist/controller/globalInputDelivery.js +132 -0
- package/dist/controller/jobControl.js +6 -2
- package/dist/controller/providerRetryAdmission.js +100 -0
- package/dist/controller/providerRetryDelivery.js +218 -0
- package/dist/controller/resourceInventory.js +14 -4
- package/dist/controller/resourceInventoryLinux.js +2 -6
- package/dist/controller/runtime.js +117 -7
- package/dist/controller/runtimeEventInbox.js +32 -3
- package/dist/controller/runtimeEventProcessor.js +26 -6
- package/dist/controller/runtimeHookRunFence.js +75 -19
- package/dist/controller/structuredProviderObservation.js +133 -70
- package/dist/coordination/workMailboxQueue.js +5 -0
- package/dist/execution/workItemExecutionProjection.js +1 -1
- package/dist/executor/agentExecutor.js +64 -4
- package/dist/executor/executorRegistry.js +3 -0
- package/dist/executor/fileRoleLaunchPlanner.js +78 -118
- package/dist/integration/deliveryObligation.js +2 -1
- package/dist/integration/gitIntegrationService.js +329 -386
- package/dist/integration/integrationAttempt.js +30 -4
- package/dist/integration/integrationQueueService.js +7 -7
- package/dist/integration/integrationSourceApplication.js +323 -0
- package/dist/lifecycle/exactRunTerminalization.js +4 -1
- package/dist/message/globalInterrupt.js +33 -0
- package/dist/message/globalProviderRetry.js +15 -0
- package/dist/message/inputControlResolution.js +106 -0
- package/dist/message/message.js +367 -0
- package/dist/message/messageContinuation.js +126 -3
- package/dist/message/taskInterrupt.js +34 -0
- package/dist/observability/executionAudit.js +19 -0
- package/dist/observability/orchestrationMetrics.js +1 -1
- package/dist/release/releaseHandover.js +22 -0
- package/dist/release/releaseWorkflowPorts.js +15 -7
- package/dist/repository/gitWorkspace.js +430 -107
- package/dist/repository/projectMaintenanceLock.js +75 -18
- package/dist/repository/taskWorkspaceCoordinator.js +182 -101
- package/dist/repository/taskWorkspacePreparer.js +205 -72
- package/dist/repository/workItemCandidateSnapshot.js +34 -0
- package/dist/repository/workspaceCleanupInspection.js +187 -0
- package/dist/resources/resourceDiscovery.js +3 -2
- package/dist/runtime/agentError.js +5 -3
- package/dist/runtime/agentHost.js +179 -82
- package/dist/runtime/agentHostCompatibility.js +127 -0
- package/dist/runtime/agentHostProtocol.js +53 -0
- package/dist/runtime/builtinAgentErrorMappers.js +91 -0
- package/dist/runtime/codexAppServerRuntime.js +34 -3
- package/dist/runtime/executionEnvironment.js +0 -19
- package/dist/runtime/launchBroker.js +6 -0
- package/dist/runtime/providerControl.js +5 -1
- package/dist/runtime/providerRetry.js +198 -0
- package/dist/runtime/providerRuntimeIdentity.js +28 -2
- package/dist/runtime/sessionReconciliation.js +4 -4
- package/dist/runtime/sessionTokenMetrics.js +15 -5
- package/dist/runtime/structuredProviderHost.js +6 -2
- package/dist/runtime/taskRuntimeIsolation.js +30 -6
- package/dist/runtime/taskUsageMetrics.js +275 -0
- package/dist/runtime/tmuxAdapters.js +5 -3
- package/dist/scheduler/activeRoleRunDelivery.js +12 -0
- package/dist/scheduler/leaderWakeupProcessor.js +5 -0
- package/dist/scheduler/operatorEvent.js +4 -0
- package/dist/scheduler/taskExecutionProjection.js +38 -6
- package/dist/scheduler/taskObservabilityProjection.js +6 -44
- package/dist/scheduler/wakeReason.js +7 -1
- package/dist/scheduler/wakeupQueue.js +2 -0
- package/dist/setup/setupCommand.js +26 -8
- package/dist/storage/homeLayout.js +130 -0
- package/dist/storage/migrations/collapseWorktreeLayout.js +963 -0
- package/dist/storage/migrations/integrationContinuation.js +104 -0
- package/dist/storage/migrations/unifyHomeLayout.js +925 -0
- package/dist/storage/sqliteSchema.js +167 -4
- package/dist/storage/sqliteStore.js +57 -1
- package/dist/storage/storageVersions.js +1 -1
- package/dist/storage/storeRpc.js +2 -0
- package/dist/storage/taskCatalog.js +123 -0
- package/dist/storage/taskStore.js +2 -0
- package/dist/storage/upgrade/upgradeOrchestrator.js +95 -2
- package/dist/task/archiveDiagnostics.js +129 -0
- package/dist/task/archivePreflight.js +124 -0
- package/dist/task/nextAction.js +44 -11
- package/dist/task/publicationAdoption.js +56 -0
- package/dist/task/publicationReference.js +10 -0
- package/dist/task/remoteDelivery.js +31 -16
- package/dist/web/assets/client/app.js +147 -17
- package/dist/web/assets/client/components.js +56 -13
- package/dist/web/assets/client/i18n.js +78 -4
- package/dist/web/assets/client/taskSurface.js +108 -1
- package/dist/web/assets/client/view.js +39 -8
- package/dist/web/assets/shell.js +29 -0
- package/dist/web/assets/styles/layout.js +8 -1
- package/dist/web/assets/styles/widgets.js +12 -0
- package/dist/web/webServer.js +131 -4
- package/dist/web/webSnapshot.js +16 -6
- package/dist/web/webTaskSurface.js +222 -5
- package/dist/workspace/cleanupInspection.js +63 -0
- package/dist/workspace/workItemChangeSetManager.js +111 -35
- package/docs/agent-result-consumption.md +4 -0
- package/docs/agent-result-consumption.zh-CN.md +3 -0
- package/docs/agent-runtime-drivers.md +7 -0
- package/docs/agent-runtime-drivers.zh-CN.md +5 -0
- package/docs/architecture/README.md +2 -0
- package/docs/architecture/README.zh-CN.md +3 -1
- package/docs/architecture/capabilities-and-resources.md +30 -5
- package/docs/architecture/capabilities-and-resources.zh-CN.md +23 -3
- package/docs/managed-turn-and-session-runtime.md +47 -0
- package/docs/managed-turn-and-session-runtime.zh-CN.md +40 -0
- package/docs/observability/README.md +62 -0
- package/docs/observability/README.zh-CN.md +47 -0
- package/docs/project-refresh.md +77 -0
- package/docs/project-refresh.zh-CN.md +59 -0
- package/docs/provider-retry.md +70 -0
- package/docs/release-workflow.md +39 -0
- package/docs/release-workflow.zh-CN.md +29 -0
- package/docs/sqlite-control-plane-design.md +223 -1
- package/docs/task-delivery.md +133 -13
- package/docs/task-delivery.zh-CN.md +99 -10
- package/docs/task-discovery.md +102 -0
- package/docs/task-discovery.zh-CN.md +86 -0
- package/docs/testing/verification-levels.md +40 -0
- package/docs/testing/verification-levels.zh-CN.md +23 -0
- package/i18n/README.zh-CN.md +13 -7
- package/package.json +1 -1
- package/skills/yui-leader/references/execution.md +154 -51
- package/skills/yui-leader/references/integration.md +52 -2
- package/skills/yui-operator/SKILL.md +19 -3
- package/skills/yui-reviewer/SKILL.md +4 -0
- package/skills/yui-runtime/SKILL.md +42 -0
- package/skills/yui-runtime/references/publication.md +42 -0
- package/skills/yui-runtime/references/recovery.md +24 -0
|
@@ -3,7 +3,18 @@ import { createHash, randomBytes } from "node:crypto";
|
|
|
3
3
|
import { lstat, mkdir, readFile, realpath, rm } from "node:fs/promises";
|
|
4
4
|
import { dirname, isAbsolute, join, relative, resolve } from "node:path";
|
|
5
5
|
import { promisify } from "node:util";
|
|
6
|
+
import { cleanupFailure } from "../workspace/cleanupInspection.js";
|
|
7
|
+
import { externalProgramConfigViolations } from "../artifacts/managedGit.js";
|
|
6
8
|
const executeFile = promisify(execFile);
|
|
9
|
+
/** Observed partial effects, not a rollback or permission to replay a refresh. */
|
|
10
|
+
export class GitWorkspaceRefreshError extends Error {
|
|
11
|
+
result;
|
|
12
|
+
constructor(message, result, options) {
|
|
13
|
+
super(message, options);
|
|
14
|
+
this.result = result;
|
|
15
|
+
this.name = "GitWorkspaceRefreshError";
|
|
16
|
+
}
|
|
17
|
+
}
|
|
7
18
|
/** A remote baseline merge stopped for an explicit Leader resolution. */
|
|
8
19
|
export class RemoteBaselineConflictError extends Error {
|
|
9
20
|
affectedPaths;
|
|
@@ -86,6 +97,7 @@ export class NodeGitWorkspace {
|
|
|
86
97
|
return git([
|
|
87
98
|
"-C", input.repositoryPath,
|
|
88
99
|
"diff", "--no-color", "--no-ext-diff",
|
|
100
|
+
...(input.exact ? ["--no-textconv", "--binary", "--full-index", "--no-renames"] : []),
|
|
89
101
|
input.fromCommit, input.toCommit
|
|
90
102
|
]);
|
|
91
103
|
}
|
|
@@ -172,47 +184,117 @@ export class NodeGitWorkspace {
|
|
|
172
184
|
const initial = await this.inspect(input.repositoryPath, "HEAD");
|
|
173
185
|
await this.#assertRefreshCheckout(initial.root, configuredStableRef, initial.baseCommit);
|
|
174
186
|
const stableBranch = configuredStableRef === "HEAD"
|
|
175
|
-
? await resolveRemoteHeadBranch(remote)
|
|
187
|
+
? await resolveRemoteHeadBranch(remote, initial.root)
|
|
176
188
|
: configuredStableRef;
|
|
177
189
|
if (stableBranch !== configuredStableRef) {
|
|
178
190
|
await this.#assertRefreshCheckout(initial.root, stableBranch, initial.baseCommit);
|
|
179
191
|
}
|
|
180
|
-
const
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
192
|
+
const mapping = await resolveFetchTracking(initial.root, remote, stableBranch);
|
|
193
|
+
const previous = mapping.status === "managed"
|
|
194
|
+
? await readDirectTrackingRef(initial.root, mapping.ref)
|
|
195
|
+
: null;
|
|
196
|
+
let tracking = mapping.status === "managed"
|
|
197
|
+
? { status: "current", remoteName: mapping.remoteName, ref: mapping.ref,
|
|
198
|
+
fromCommit: previous, toCommit: previous }
|
|
199
|
+
: { status: "unmanaged", reason: mapping.reason };
|
|
200
|
+
const temporaryRef = `refs/yui/project-refresh/${randomBytes(16).toString("hex")}`;
|
|
201
|
+
let fetchedCommit;
|
|
202
|
+
let verifiedCommit;
|
|
203
|
+
let failure;
|
|
204
|
+
let cleanupFailed = false;
|
|
205
|
+
const assertMapping = async () => {
|
|
206
|
+
const current = await resolveFetchTracking(initial.root, remote, stableBranch);
|
|
207
|
+
if (JSON.stringify(current) !== JSON.stringify(mapping)) {
|
|
208
|
+
throw new Error("Project fetch URL or tracking mapping changed during refresh.");
|
|
209
|
+
}
|
|
210
|
+
};
|
|
211
|
+
try {
|
|
212
|
+
// An empty refmap disables opportunistic tracking updates even when the
|
|
213
|
+
// Project URL is a named remote. Ignore configured prune/submodule effects.
|
|
214
|
+
await git([
|
|
215
|
+
"-C", initial.root, "fetch", "--no-tags", "--no-prune", "--no-prune-tags",
|
|
216
|
+
"--no-recurse-submodules", "--no-auto-maintenance", "--no-write-fetch-head", "--refmap=",
|
|
217
|
+
remote, `refs/heads/${stableBranch}:${temporaryRef}`
|
|
218
|
+
]);
|
|
219
|
+
fetchedCommit = await resolveFetchedCommit(initial.root, temporaryRef);
|
|
220
|
+
const advertisedCommit = await resolveRemoteBranchCommit(remote, stableBranch, initial.root);
|
|
221
|
+
if (fetchedCommit !== advertisedCommit) {
|
|
222
|
+
throw new Error(`Project remote stable branch changed while it was fetched: ${stableBranch}.`);
|
|
223
|
+
}
|
|
224
|
+
verifiedCommit = fetchedCommit;
|
|
225
|
+
await assertMapping();
|
|
226
|
+
await this.#assertRefreshCheckout(initial.root, stableBranch, initial.baseCommit);
|
|
227
|
+
if (fetchedCommit !== initial.baseCommit) {
|
|
228
|
+
if (!await this.isAncestor(initial.root, initial.baseCommit, fetchedCommit)) {
|
|
229
|
+
throw new Error(`Project checkout cannot be fast-forwarded from ${initial.baseCommit} to ${fetchedCommit}.`);
|
|
230
|
+
}
|
|
231
|
+
await git(["-C", initial.root, "merge", "--ff-only", "--no-edit", fetchedCommit]);
|
|
232
|
+
}
|
|
233
|
+
await this.#assertRefreshCheckout(initial.root, stableBranch, fetchedCommit);
|
|
234
|
+
await assertMapping();
|
|
235
|
+
if (mapping.status === "managed") {
|
|
236
|
+
// Do not follow a symbolic tracking ref into somebody else's branch.
|
|
237
|
+
await readDirectTrackingRef(initial.root, mapping.ref);
|
|
238
|
+
const old = previous ?? "0".repeat(fetchedCommit.length);
|
|
239
|
+
// Verify the stable branch and CAS the one configured destination in
|
|
240
|
+
// the same Git transaction. HEAD/index/worktree still use normal ff.
|
|
241
|
+
await git(["-C", initial.root, "update-ref", "--stdin"], { input: [
|
|
242
|
+
"start", "option no-deref",
|
|
243
|
+
`verify refs/heads/${stableBranch} ${fetchedCommit}`,
|
|
244
|
+
"option no-deref",
|
|
245
|
+
`update ${mapping.ref} ${fetchedCommit} ${old}`,
|
|
246
|
+
"prepare", "commit", ""
|
|
247
|
+
].join("\n") });
|
|
248
|
+
tracking = {
|
|
249
|
+
status: previous === fetchedCommit ? "current" : "updated",
|
|
250
|
+
remoteName: mapping.remoteName, ref: mapping.ref,
|
|
251
|
+
fromCommit: previous, toCommit: fetchedCommit
|
|
252
|
+
};
|
|
253
|
+
}
|
|
254
|
+
await this.#assertRefreshCheckout(initial.root, stableBranch, fetchedCommit);
|
|
255
|
+
await assertMapping();
|
|
256
|
+
if (mapping.status === "managed"
|
|
257
|
+
&& await readDirectTrackingRef(initial.root, mapping.ref) !== fetchedCommit) {
|
|
258
|
+
throw new Error(`Project tracking ref changed after refresh: ${mapping.ref}.`);
|
|
259
|
+
}
|
|
200
260
|
}
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
261
|
+
catch (error) {
|
|
262
|
+
failure = error;
|
|
263
|
+
}
|
|
264
|
+
finally {
|
|
265
|
+
if (fetchedCommit !== undefined) {
|
|
266
|
+
cleanupFailed = !await gitSucceeds([
|
|
267
|
+
"-C", initial.root, "update-ref", "--no-deref", "-d", temporaryRef, fetchedCommit
|
|
268
|
+
]);
|
|
269
|
+
if (cleanupFailed && failure === undefined) {
|
|
270
|
+
failure = new Error(`Project refresh temporary ref cleanup failed: ${temporaryRef}.`);
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
if (failure !== undefined) {
|
|
275
|
+
const head = await this.inspect(initial.root, "HEAD").then((value) => value.baseCommit, () => null);
|
|
276
|
+
const detail = failure instanceof Error ? failure.message : String(failure);
|
|
277
|
+
if (mapping.status === "managed") {
|
|
278
|
+
tracking = {
|
|
279
|
+
status: "failed", remoteName: mapping.remoteName, ref: mapping.ref,
|
|
280
|
+
fromCommit: previous,
|
|
281
|
+
toCommit: await readDirectTrackingRef(initial.root, mapping.ref).catch(() => null),
|
|
282
|
+
reason: detail
|
|
283
|
+
};
|
|
284
|
+
}
|
|
285
|
+
throw new GitWorkspaceRefreshError(`Project refresh failed: ${detail} HEAD ${initial.baseCommit} -> ${head ?? "unknown"}; ` +
|
|
286
|
+
(tracking.status === "unmanaged" ? `tracking unmanaged: ${tracking.reason}` :
|
|
287
|
+
`tracking ${tracking.ref}: ${tracking.fromCommit ?? "absent"} -> ${tracking.toCommit ?? "absent or unreadable"}`) +
|
|
288
|
+
(cleanupFailed ? `; temporary ref retained: ${temporaryRef}` : ""), { fromCommit: initial.baseCommit, toCommit: head,
|
|
289
|
+
changed: head === null ? null : head !== initial.baseCommit,
|
|
290
|
+
...(verifiedCommit === undefined ? {} : { verifiedCommit }), tracking,
|
|
291
|
+
...(cleanupFailed || fetchedCommit === undefined ? { temporaryRef } : {}) }, { cause: failure });
|
|
206
292
|
}
|
|
207
|
-
await git([
|
|
208
|
-
"-C", initial.root,
|
|
209
|
-
"merge", "--ff-only", "--no-edit", fetchedCommit
|
|
210
|
-
]);
|
|
211
|
-
await this.#assertRefreshCheckout(initial.root, stableBranch, fetchedCommit);
|
|
212
293
|
return {
|
|
213
294
|
fromCommit: initial.baseCommit,
|
|
214
|
-
toCommit:
|
|
215
|
-
changed:
|
|
295
|
+
toCommit: verifiedCommit,
|
|
296
|
+
changed: initial.baseCommit !== verifiedCommit,
|
|
297
|
+
tracking
|
|
216
298
|
};
|
|
217
299
|
}
|
|
218
300
|
/**
|
|
@@ -513,47 +595,17 @@ export class NodeGitWorkspace {
|
|
|
513
595
|
}
|
|
514
596
|
async inspectRemoteTracking(input) {
|
|
515
597
|
const root = (await this.inspect(input.repositoryPath)).root;
|
|
516
|
-
const wantedUrl = requireText(input.remoteUrl, "Project remote URL");
|
|
517
|
-
let output;
|
|
518
|
-
try {
|
|
519
|
-
output = await git([
|
|
520
|
-
"-C", root,
|
|
521
|
-
"config", "--get-regexp", "--null",
|
|
522
|
-
"^remote\\..*\\.url$"
|
|
523
|
-
]);
|
|
524
|
-
}
|
|
525
|
-
catch {
|
|
526
|
-
return null;
|
|
527
|
-
}
|
|
528
|
-
const records = output.split("\0").filter((record) => record.length > 0);
|
|
529
|
-
const matches = [];
|
|
530
|
-
for (const record of records) {
|
|
531
|
-
const separator = record.indexOf("\n");
|
|
532
|
-
if (separator < 0)
|
|
533
|
-
continue;
|
|
534
|
-
const key = record.slice(0, separator).trim();
|
|
535
|
-
const url = record.slice(separator + 1).trim();
|
|
536
|
-
const prefix = "remote.";
|
|
537
|
-
const suffix = ".url";
|
|
538
|
-
if (!key.startsWith(prefix) || !key.endsWith(suffix) || url !== wantedUrl)
|
|
539
|
-
continue;
|
|
540
|
-
matches.push({ remoteName: key.slice(prefix.length, -suffix.length), url });
|
|
541
|
-
}
|
|
542
|
-
if (matches.length !== 1)
|
|
543
|
-
return null;
|
|
544
|
-
const remoteName = requireText(matches[0].remoteName, "Git remote name");
|
|
545
598
|
const branch = await safeFetchBranch(input.branch);
|
|
546
|
-
const
|
|
547
|
-
if (
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
if (!await this.refExists(root, ref))
|
|
599
|
+
const mapping = await resolveFetchTracking(root, safeRemote(input.remoteUrl), branch);
|
|
600
|
+
if (mapping.status !== "managed")
|
|
601
|
+
return null;
|
|
602
|
+
if (!await this.refExists(root, mapping.ref))
|
|
551
603
|
return null;
|
|
552
604
|
return {
|
|
553
|
-
remoteName,
|
|
554
|
-
remoteUrl:
|
|
555
|
-
ref,
|
|
556
|
-
commit: (await this.inspect(root, ref)).baseCommit
|
|
605
|
+
remoteName: mapping.remoteName,
|
|
606
|
+
remoteUrl: mapping.remoteUrl,
|
|
607
|
+
ref: mapping.ref,
|
|
608
|
+
commit: (await this.inspect(root, mapping.ref)).baseCommit
|
|
557
609
|
};
|
|
558
610
|
}
|
|
559
611
|
async mergeBase(input) {
|
|
@@ -571,9 +623,13 @@ export class NodeGitWorkspace {
|
|
|
571
623
|
}
|
|
572
624
|
async isClean(repositoryPath) {
|
|
573
625
|
const root = (await this.inspect(repositoryPath)).root;
|
|
574
|
-
const status = await git([
|
|
575
|
-
"
|
|
576
|
-
|
|
626
|
+
const status = await git(["--no-optional-locks", "-C", root,
|
|
627
|
+
"status", "--porcelain=v1", "--untracked-files=all"]);
|
|
628
|
+
return status.length === 0;
|
|
629
|
+
}
|
|
630
|
+
async inspectClean(repositoryPath) {
|
|
631
|
+
const root = (await this.inspect(repositoryPath)).root;
|
|
632
|
+
const status = await readWorktreeStatus(root);
|
|
577
633
|
return status.length === 0;
|
|
578
634
|
}
|
|
579
635
|
async mergeWorktree(input) {
|
|
@@ -660,6 +716,11 @@ export class NodeGitWorkspace {
|
|
|
660
716
|
.filter((line) => line.length > 0);
|
|
661
717
|
}
|
|
662
718
|
async #assertRefreshCheckout(repositoryPath, stableRef, expectedCommit) {
|
|
719
|
+
// Status can take time. Re-read branch and HEAD after it so a checkout
|
|
720
|
+
// switched while status was running is not subsequently fast-forwarded.
|
|
721
|
+
if (!await this.isClean(repositoryPath)) {
|
|
722
|
+
throw new Error("Project checkout must be clean before it can be refreshed.");
|
|
723
|
+
}
|
|
663
724
|
if (stableRef !== "HEAD") {
|
|
664
725
|
const currentBranch = await this.headRef(repositoryPath);
|
|
665
726
|
if (currentBranch !== stableRef) {
|
|
@@ -671,15 +732,15 @@ export class NodeGitWorkspace {
|
|
|
671
732
|
if (head.baseCommit !== expectedCommit) {
|
|
672
733
|
throw new Error("Project checkout changed while it was being refreshed.");
|
|
673
734
|
}
|
|
674
|
-
if (!await this.isClean(head.root)) {
|
|
675
|
-
throw new Error("Project checkout must be clean before it can be refreshed.");
|
|
676
|
-
}
|
|
677
735
|
}
|
|
678
736
|
async ensureWorktree(input) {
|
|
679
737
|
return this.#ensureManagedWorktree({
|
|
680
738
|
repositoryPath: input.repositoryPath,
|
|
681
739
|
container: input.container,
|
|
682
|
-
identity:
|
|
740
|
+
identity: {
|
|
741
|
+
directory: input.directory,
|
|
742
|
+
branch: worktreeIdentity(input.taskSegment, input.roleName).branch
|
|
743
|
+
},
|
|
683
744
|
baseRef: input.baseRef
|
|
684
745
|
});
|
|
685
746
|
}
|
|
@@ -687,7 +748,10 @@ export class NodeGitWorkspace {
|
|
|
687
748
|
return this.#ensureManagedWorktree({
|
|
688
749
|
repositoryPath: input.repositoryPath,
|
|
689
750
|
container: input.container,
|
|
690
|
-
identity:
|
|
751
|
+
identity: {
|
|
752
|
+
directory: input.directory,
|
|
753
|
+
branch: integrationWorktreeIdentity(input.taskSegment, input.integrationId).branch
|
|
754
|
+
},
|
|
691
755
|
baseRef: input.baseRef,
|
|
692
756
|
allowRebase: true
|
|
693
757
|
});
|
|
@@ -734,19 +798,19 @@ export class NodeGitWorkspace {
|
|
|
734
798
|
if (state === "dirty")
|
|
735
799
|
return state;
|
|
736
800
|
const container = resolve(input.container);
|
|
737
|
-
const
|
|
738
|
-
const path = managedPath(container,
|
|
801
|
+
const branch = worktreeIdentity(input.taskSegment, input.roleName).branch;
|
|
802
|
+
const path = managedPath(container, input.directory);
|
|
739
803
|
const project = await this.inspect(input.repositoryPath);
|
|
740
804
|
if (state === "missing") {
|
|
741
805
|
if (input.deleteBranch === true) {
|
|
742
|
-
await
|
|
743
|
-
await deleteBranchIfPresent(project.root,
|
|
806
|
+
await removeMissingWorktreeRegistration(project.root, path, branch);
|
|
807
|
+
await deleteBranchIfPresent(project.root, branch);
|
|
744
808
|
}
|
|
745
809
|
return state;
|
|
746
810
|
}
|
|
747
811
|
await git(["-C", project.root, "worktree", "remove", "--", path]);
|
|
748
812
|
if (input.deleteBranch === true) {
|
|
749
|
-
await deleteBranchIfPresent(project.root,
|
|
813
|
+
await deleteBranchIfPresent(project.root, branch);
|
|
750
814
|
}
|
|
751
815
|
return "removed";
|
|
752
816
|
}
|
|
@@ -779,6 +843,42 @@ export class NodeGitWorkspace {
|
|
|
779
843
|
}
|
|
780
844
|
return "removed";
|
|
781
845
|
}
|
|
846
|
+
async removeTaskClone(input) {
|
|
847
|
+
const state = await this.inspectTaskClone(input);
|
|
848
|
+
if (state !== "clean")
|
|
849
|
+
return state;
|
|
850
|
+
await rm(managedPath(resolve(input.container), input.directory), { recursive: true });
|
|
851
|
+
return "removed";
|
|
852
|
+
}
|
|
853
|
+
async inspectTaskClone(input) {
|
|
854
|
+
const identity = worktreeIdentity(input.taskSegment, "main");
|
|
855
|
+
const expected = managedPath(resolve(input.container), input.directory);
|
|
856
|
+
if (resolve(input.path) !== expected || input.branch !== identity.branch) {
|
|
857
|
+
cleanupFailure("workspace-identity-mismatch", "Task clone does not match its recorded managed identity.", { branch: identity.branch }, { branch: input.branch, pathMatches: resolve(input.path) === expected });
|
|
858
|
+
}
|
|
859
|
+
const kind = await pathKind(expected);
|
|
860
|
+
if (kind === undefined)
|
|
861
|
+
return "missing";
|
|
862
|
+
if (kind === "symlink")
|
|
863
|
+
cleanupFailure("workspace-identity-mismatch", "Task clone must not be a symbolic link.", "real directory", "symbolic link");
|
|
864
|
+
await canonicalContainer(input.container, false);
|
|
865
|
+
if (await realpath(expected) !== expected || await pathKind(join(expected, ".git")) !== "directory") {
|
|
866
|
+
cleanupFailure("workspace-identity-mismatch", "Task clone is not a standalone owned Git directory.", "standalone owned clone", "different Git layout or symbolic ancestor");
|
|
867
|
+
}
|
|
868
|
+
const repository = await this.inspect(expected);
|
|
869
|
+
if (repository.root !== expected || repository.gitDirectory !== join(expected, ".git")) {
|
|
870
|
+
cleanupFailure("workspace-identity-mismatch", "Task clone Git ownership cannot be established.", "exact root and Git directory", "different Git ownership");
|
|
871
|
+
}
|
|
872
|
+
await assertExpectedBranch(expected, input.branch);
|
|
873
|
+
if (!await this.inspectClean(expected))
|
|
874
|
+
return "dirty";
|
|
875
|
+
const worktrees = (await git(["-C", expected, "worktree", "list", "--porcelain", "-z"]))
|
|
876
|
+
.split("\0").filter(line => line.startsWith("worktree "));
|
|
877
|
+
if (worktrees.length !== 1 || worktrees[0] !== `worktree ${expected}`) {
|
|
878
|
+
cleanupFailure("git-worktree-registrations", "Task clone still owns Git worktree registrations; retained.", 1, worktrees.length);
|
|
879
|
+
}
|
|
880
|
+
return "clean";
|
|
881
|
+
}
|
|
782
882
|
async inspectRecordedWorktree(input) {
|
|
783
883
|
const inspected = await inspectExactRecordedWorktree(input);
|
|
784
884
|
return inspected?.state ?? "missing";
|
|
@@ -824,23 +924,31 @@ export class NodeGitWorkspace {
|
|
|
824
924
|
}
|
|
825
925
|
async inspectWorktree(input) {
|
|
826
926
|
const container = resolve(input.container);
|
|
827
|
-
const path = managedPath(container,
|
|
927
|
+
const path = managedPath(container, input.directory);
|
|
828
928
|
const kind = await pathKind(path);
|
|
829
|
-
|
|
929
|
+
const branch = input.expectedBranch ?? worktreeIdentity(input.taskSegment, input.roleName).branch;
|
|
930
|
+
if (kind === undefined) {
|
|
931
|
+
const project = await this.inspect(input.repositoryPath);
|
|
932
|
+
await inspectMissingWorktreeRegistration(project.root, path, branch);
|
|
830
933
|
return "missing";
|
|
934
|
+
}
|
|
831
935
|
if (kind === "symlink")
|
|
832
|
-
|
|
936
|
+
cleanupFailure("workspace-identity-mismatch", "Managed worktree path must not be a symbolic link.", "real directory", "symbolic link");
|
|
833
937
|
const canonicalContainerPath = await canonicalContainer(container, false);
|
|
834
938
|
const project = await this.inspect(input.repositoryPath);
|
|
835
939
|
await assertOwnedWorktree(project, canonicalContainerPath, path);
|
|
836
|
-
|
|
940
|
+
await assertExpectedBranch(path, branch);
|
|
941
|
+
const porcelain = await readWorktreeStatus(path);
|
|
837
942
|
return porcelain.length > 0 ? "dirty" : "clean";
|
|
838
943
|
}
|
|
839
944
|
async removeIntegrationWorktree(input) {
|
|
840
945
|
return this.#removeManagedWorktree({
|
|
841
946
|
repositoryPath: input.repositoryPath,
|
|
842
947
|
container: input.container,
|
|
843
|
-
identity:
|
|
948
|
+
identity: {
|
|
949
|
+
directory: input.directory,
|
|
950
|
+
branch: integrationWorktreeIdentity(input.taskSegment, input.integrationId).branch
|
|
951
|
+
},
|
|
844
952
|
discardChanges: input.discardChanges
|
|
845
953
|
});
|
|
846
954
|
}
|
|
@@ -851,6 +959,9 @@ export class NodeGitWorkspace {
|
|
|
851
959
|
if (kind === "symlink")
|
|
852
960
|
throw new Error("Managed worktree path must not be a symbolic link.");
|
|
853
961
|
const repository = await this.inspect(input.repositoryPath);
|
|
962
|
+
if (kind === undefined) {
|
|
963
|
+
await removeMissingWorktreeRegistration(repository.root, path, input.identity.branch);
|
|
964
|
+
}
|
|
854
965
|
if (kind === "directory") {
|
|
855
966
|
const canonicalContainerPath = await canonicalContainer(container, false);
|
|
856
967
|
await assertOwnedWorktree(repository, canonicalContainerPath, path);
|
|
@@ -859,9 +970,7 @@ export class NodeGitWorkspace {
|
|
|
859
970
|
await git(["-C", repository.root, "worktree", "remove", "--force", "--", path]);
|
|
860
971
|
}
|
|
861
972
|
else {
|
|
862
|
-
const porcelain = await
|
|
863
|
-
"-C", path, "status", "--porcelain=v1", "--untracked-files=all"
|
|
864
|
-
]);
|
|
973
|
+
const porcelain = await readWorktreeStatus(path);
|
|
865
974
|
if (porcelain.length > 0)
|
|
866
975
|
return "dirty";
|
|
867
976
|
if (input.expectedBaseCommit !== undefined
|
|
@@ -884,6 +993,27 @@ export class NodeGitWorkspace {
|
|
|
884
993
|
return kind === "directory" ? "removed" : "missing";
|
|
885
994
|
}
|
|
886
995
|
}
|
|
996
|
+
/** Missing paths do not imply missing Git metadata. Never prune another
|
|
997
|
+
* workspace's registration as a side effect of cleaning this exact owner.
|
|
998
|
+
*/
|
|
999
|
+
async function removeMissingWorktreeRegistration(repositoryRoot, path, branch) {
|
|
1000
|
+
if (!await inspectMissingWorktreeRegistration(repositoryRoot, path, branch))
|
|
1001
|
+
return;
|
|
1002
|
+
if (await pathKind(path) !== undefined)
|
|
1003
|
+
throw new Error(`Worktree reappeared before metadata cleanup: ${path}.`);
|
|
1004
|
+
await git(["-C", repositoryRoot, "worktree", "remove", "--force", "--", path]);
|
|
1005
|
+
}
|
|
1006
|
+
async function inspectMissingWorktreeRegistration(repositoryRoot, path, branch) {
|
|
1007
|
+
const records = (await git(["-C", repositoryRoot, "worktree", "list", "--porcelain", "-z"])).split("\0\0");
|
|
1008
|
+
const fields = records.map(record => record.split("\0")).find(record => record.includes(`worktree ${path}`));
|
|
1009
|
+
if (fields === undefined)
|
|
1010
|
+
return false;
|
|
1011
|
+
if (!fields.includes(`branch refs/heads/${branch}`) || fields.some(field => field.startsWith("locked"))) {
|
|
1012
|
+
cleanupFailure("git-registration-mismatch", "Missing worktree has uncertain or locked Git ownership; metadata retained.", { branch, locked: false }, { branch: fields.find(field => field.startsWith("branch "))?.slice(7) ?? null,
|
|
1013
|
+
locked: fields.some(field => field.startsWith("locked")) });
|
|
1014
|
+
}
|
|
1015
|
+
return true;
|
|
1016
|
+
}
|
|
887
1017
|
async function deleteBranchIfPresent(repositoryRoot, branch) {
|
|
888
1018
|
const branchRef = `refs/heads/${branch}`;
|
|
889
1019
|
if (!await gitSucceeds([
|
|
@@ -942,7 +1072,7 @@ async function assertExpectedBranch(path, expected, allowRebase = false) {
|
|
|
942
1072
|
}
|
|
943
1073
|
const branch = await gitLine(["-C", path, "symbolic-ref", "--short", "HEAD"]);
|
|
944
1074
|
if (branch !== expected) {
|
|
945
|
-
|
|
1075
|
+
cleanupFailure("workspace-identity-mismatch", "Managed worktree is on an unexpected branch.", expected, branch);
|
|
946
1076
|
}
|
|
947
1077
|
}
|
|
948
1078
|
async function assertOwnedWorktree(project, container, path) {
|
|
@@ -955,14 +1085,14 @@ async function assertOwnedWorktree(project, container, path) {
|
|
|
955
1085
|
throw new Error("Managed worktree root does not match its deterministic path.");
|
|
956
1086
|
const common = await canonicalDirectory(await gitLine(["-C", path, "rev-parse", "--path-format=absolute", "--git-common-dir"]), "Managed Git common directory");
|
|
957
1087
|
if (common !== project.gitDirectory) {
|
|
958
|
-
|
|
1088
|
+
cleanupFailure("workspace-identity-mismatch", "Managed worktree belongs to another project.", "Task main Git common directory", "different Git common directory");
|
|
959
1089
|
}
|
|
960
1090
|
}
|
|
961
1091
|
async function inspectExactRecordedWorktree(input) {
|
|
962
1092
|
const container = resolve(input.container);
|
|
963
|
-
const
|
|
964
|
-
const expectedPath = managedPath(container,
|
|
965
|
-
if (resolve(input.path) !== expectedPath || input.branch !==
|
|
1093
|
+
const expectedBranch = worktreeIdentity(input.taskSegment, input.roleName).branch;
|
|
1094
|
+
const expectedPath = managedPath(container, input.directory);
|
|
1095
|
+
if (resolve(input.path) !== expectedPath || input.branch !== expectedBranch) {
|
|
966
1096
|
throw new Error("Recorded managed worktree identity is invalid.");
|
|
967
1097
|
}
|
|
968
1098
|
const kind = await pathKind(expectedPath);
|
|
@@ -1023,9 +1153,9 @@ async function resolveRefCommit(repositoryPath, ref) {
|
|
|
1023
1153
|
async function recordedWorktreeWithoutRepository(input, cause) {
|
|
1024
1154
|
if (await pathKind(input.repositoryPath) !== undefined)
|
|
1025
1155
|
throw cause;
|
|
1026
|
-
const
|
|
1027
|
-
const expectedPath = managedPath(resolve(input.container),
|
|
1028
|
-
if (resolve(input.path) !== expectedPath || input.branch !==
|
|
1156
|
+
const branch = worktreeIdentity(input.taskSegment, input.roleName).branch;
|
|
1157
|
+
const expectedPath = managedPath(resolve(input.container), input.directory);
|
|
1158
|
+
if (resolve(input.path) !== expectedPath || input.branch !== branch) {
|
|
1029
1159
|
throw cause;
|
|
1030
1160
|
}
|
|
1031
1161
|
if (await pathKind(expectedPath) === undefined)
|
|
@@ -1090,13 +1220,72 @@ async function pathKind(path) {
|
|
|
1090
1220
|
throw error;
|
|
1091
1221
|
}
|
|
1092
1222
|
}
|
|
1093
|
-
|
|
1223
|
+
/** Status can execute clean/process filters to compare equal-size changed
|
|
1224
|
+
* files. Preserve native normalization: report unknown instead of disabling
|
|
1225
|
+
* a filter and misclassifying its output as clean/dirty. Include scopes so a
|
|
1226
|
+
* native global/worktree include cannot hide the same execution requirement.
|
|
1227
|
+
*/
|
|
1228
|
+
async function readWorktreeStatus(path) {
|
|
1229
|
+
await assertStatusProgramsAbsent(path);
|
|
1230
|
+
return readStatusGit(path, ["status", "--porcelain=v1", "--untracked-files=all"]);
|
|
1231
|
+
}
|
|
1232
|
+
async function readStatusGit(path, args) {
|
|
1233
|
+
return git(["--no-optional-locks", "-c", "core.fsmonitor=false",
|
|
1234
|
+
"-c", "protocol.allow=never", "-c", "protocol.file.allow=never",
|
|
1235
|
+
"-C", path, ...args], { environment: { GIT_NO_LAZY_FETCH: "1" } });
|
|
1236
|
+
}
|
|
1237
|
+
async function assertStatusProgramsAbsent(path) {
|
|
1238
|
+
const configuration = await readStatusGit(path, ["config", "--list", "--null", "--includes"]);
|
|
1239
|
+
const filters = externalProgramConfigViolations(configuration)
|
|
1240
|
+
.filter(key => key.startsWith("filter.") && (key.endsWith(".clean") || key.endsWith(".process")));
|
|
1241
|
+
const files = (await readStatusGit(path, ["ls-files", "--stage", "-z"])).split("\0");
|
|
1242
|
+
if (filters.length > 0) {
|
|
1243
|
+
// Merely having native LFS configured globally is not a violation. Only
|
|
1244
|
+
// filters selected by tracked regular files' effective attributes can run
|
|
1245
|
+
// during status. check-attr reads those selections without executing them.
|
|
1246
|
+
const configured = new Set(filters.map(key => key.slice(7, key.lastIndexOf("."))));
|
|
1247
|
+
const tracked = [...new Set(files.filter(line => line.startsWith("100"))
|
|
1248
|
+
.map(line => line.slice(line.indexOf("\t") + 1)))];
|
|
1249
|
+
for (let offset = 0; offset < tracked.length; offset += 128) {
|
|
1250
|
+
const attributes = (await readStatusGit(path, ["check-attr", "-z", "filter", "--",
|
|
1251
|
+
...tracked.slice(offset, offset + 128)])).split("\0");
|
|
1252
|
+
for (let index = 2; index < attributes.length; index += 3) {
|
|
1253
|
+
if (!configured.has(attributes[index].toLowerCase()))
|
|
1254
|
+
continue;
|
|
1255
|
+
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");
|
|
1256
|
+
}
|
|
1257
|
+
}
|
|
1258
|
+
}
|
|
1259
|
+
// Status also examines initialized submodules. Inspect their configuration
|
|
1260
|
+
// before asking Git to recurse; uninitialized gitlinks execute nothing.
|
|
1261
|
+
for (const record of files.filter(line => line.startsWith("160000 "))) {
|
|
1262
|
+
const child = join(path, record.slice(record.indexOf("\t") + 1));
|
|
1263
|
+
assertContained(path, child);
|
|
1264
|
+
const gitEntry = await lstat(join(child, ".git")).catch(error => {
|
|
1265
|
+
if (isErrno(error, "ENOENT"))
|
|
1266
|
+
return undefined;
|
|
1267
|
+
throw error;
|
|
1268
|
+
});
|
|
1269
|
+
if (gitEntry === undefined)
|
|
1270
|
+
continue;
|
|
1271
|
+
if (await realpath(child) !== child
|
|
1272
|
+
|| (await readStatusGit(child, ["rev-parse", "--show-toplevel"])).trimEnd() !== child) {
|
|
1273
|
+
cleanupFailure("workspace-identity-mismatch", "Submodule identity cannot be safely inspected.", "owned submodule directory", "different or symbolic root");
|
|
1274
|
+
}
|
|
1275
|
+
await assertStatusProgramsAbsent(child);
|
|
1276
|
+
}
|
|
1277
|
+
}
|
|
1278
|
+
async function git(args, options = {}) {
|
|
1094
1279
|
try {
|
|
1095
|
-
const
|
|
1280
|
+
const execution = executeFile("git", [...args], {
|
|
1096
1281
|
encoding: "utf8",
|
|
1097
1282
|
maxBuffer: 1024 * 1024,
|
|
1098
|
-
timeout: 30_000
|
|
1283
|
+
timeout: 30_000,
|
|
1284
|
+
...(options.environment === undefined ? {} : { env: { ...process.env, ...options.environment } })
|
|
1099
1285
|
});
|
|
1286
|
+
if (options.input !== undefined)
|
|
1287
|
+
execution.child.stdin.end(options.input);
|
|
1288
|
+
const result = await execution;
|
|
1100
1289
|
return result.stdout;
|
|
1101
1290
|
}
|
|
1102
1291
|
catch (error) {
|
|
@@ -1124,6 +1313,134 @@ async function gitLine(args) {
|
|
|
1124
1313
|
}
|
|
1125
1314
|
return lines[0];
|
|
1126
1315
|
}
|
|
1316
|
+
/** Match one exact or one-star Git refspec, in either direction. */
|
|
1317
|
+
function refspecCapture(pattern, ref) {
|
|
1318
|
+
const star = pattern.indexOf("*");
|
|
1319
|
+
if (star < 0)
|
|
1320
|
+
return pattern === ref ? "" : null;
|
|
1321
|
+
const prefix = pattern.slice(0, star);
|
|
1322
|
+
const suffix = pattern.slice(star + 1);
|
|
1323
|
+
return ref.startsWith(prefix) && ref.endsWith(suffix)
|
|
1324
|
+
&& ref.length >= prefix.length + suffix.length
|
|
1325
|
+
? ref.slice(prefix.length, ref.length - suffix.length) : null;
|
|
1326
|
+
}
|
|
1327
|
+
async function parseFetchSpec(value) {
|
|
1328
|
+
const negative = value.startsWith("^");
|
|
1329
|
+
const text = value.replace(/^[+^]/u, "");
|
|
1330
|
+
const parts = text.split(":");
|
|
1331
|
+
const source = parts[0];
|
|
1332
|
+
const destination = parts[1] || undefined;
|
|
1333
|
+
if (parts.length > 2 || (negative && parts.length !== 1)
|
|
1334
|
+
|| !source.startsWith("refs/")
|
|
1335
|
+
|| !await gitSucceeds(["check-ref-format", "--refspec-pattern", source])
|
|
1336
|
+
|| (destination !== undefined && (!destination.startsWith("refs/")
|
|
1337
|
+
|| !await gitSucceeds(["check-ref-format", "--refspec-pattern", destination])
|
|
1338
|
+
|| source.includes("*") !== destination.includes("*"))))
|
|
1339
|
+
return null;
|
|
1340
|
+
return { source, ...(destination === undefined ? {} : { destination }), negative };
|
|
1341
|
+
}
|
|
1342
|
+
async function gitConfigValues(root, key) {
|
|
1343
|
+
try {
|
|
1344
|
+
return (await git(["-C", root, "config", "--null", "--get-all", key]))
|
|
1345
|
+
.split("\0").filter((value) => value.length > 0);
|
|
1346
|
+
}
|
|
1347
|
+
catch (error) {
|
|
1348
|
+
const cause = error instanceof Error ? error.cause : undefined;
|
|
1349
|
+
if (typeof cause === "object" && cause !== null && "code" in cause && cause.code === 1) {
|
|
1350
|
+
return [];
|
|
1351
|
+
}
|
|
1352
|
+
throw error;
|
|
1353
|
+
}
|
|
1354
|
+
}
|
|
1355
|
+
/**
|
|
1356
|
+
* Prove a unique configured fetch destination, never infer it from a remote
|
|
1357
|
+
* name. Git resolves insteadOf aliases; push URLs are deliberately irrelevant.
|
|
1358
|
+
* Unsafe/ambiguous configurations remain usable for HEAD-only refresh.
|
|
1359
|
+
*/
|
|
1360
|
+
async function resolveFetchTracking(root, remote, branch) {
|
|
1361
|
+
const wantedUrl = await gitLine(["-C", root, "ls-remote", "--get-url", "--", remote]);
|
|
1362
|
+
const names = (await git(["-C", root, "remote"])).trim().split("\n").filter(Boolean).sort();
|
|
1363
|
+
const remotes = await Promise.all(names.map(async (name) => ({
|
|
1364
|
+
name,
|
|
1365
|
+
urls: (await git(["-C", root, "remote", "get-url", "--all", "--", name])).trimEnd().split("\n"),
|
|
1366
|
+
specs: await gitConfigValues(root, `remote.${name}.fetch`)
|
|
1367
|
+
})));
|
|
1368
|
+
const evidence = JSON.stringify({ wantedUrl, remotes });
|
|
1369
|
+
const unmanaged = (reason) => ({ status: "unmanaged", reason, evidence });
|
|
1370
|
+
const matching = remotes.filter(({ urls }) => urls[0] === wantedUrl);
|
|
1371
|
+
if (matching.length !== 1 || matching[0].urls.length !== 1) {
|
|
1372
|
+
return unmanaged("Project fetch URL does not identify exactly one single-URL Git remote.");
|
|
1373
|
+
}
|
|
1374
|
+
const selected = matching[0];
|
|
1375
|
+
const candidates = await Promise.all(remotes.map(async (entry) => ({
|
|
1376
|
+
...entry, parsed: await Promise.all(entry.specs.map(parseFetchSpec))
|
|
1377
|
+
})));
|
|
1378
|
+
if (candidates.some(({ parsed: specs }) => specs.some((spec) => spec === null))) {
|
|
1379
|
+
return unmanaged("A configured fetch refspec is outside the supported full-ref exact/one-star mapping.");
|
|
1380
|
+
}
|
|
1381
|
+
const parsed = candidates.map((entry) => ({
|
|
1382
|
+
...entry, parsed: entry.parsed.filter((spec) => spec !== null)
|
|
1383
|
+
}));
|
|
1384
|
+
const source = `refs/heads/${branch}`;
|
|
1385
|
+
const excluded = (specs, ref) => specs.some((spec) => spec.negative && refspecCapture(spec.source, ref) !== null);
|
|
1386
|
+
const selectedSpecs = parsed.find(({ name }) => name === selected.name).parsed;
|
|
1387
|
+
if (excluded(selectedSpecs, source))
|
|
1388
|
+
return unmanaged(`Fetch refspec excludes ${source}.`);
|
|
1389
|
+
const destinations = new Set();
|
|
1390
|
+
for (const spec of selectedSpecs) {
|
|
1391
|
+
if (spec.negative || spec.destination === undefined)
|
|
1392
|
+
continue;
|
|
1393
|
+
const capture = refspecCapture(spec.source, source);
|
|
1394
|
+
if (capture !== null)
|
|
1395
|
+
destinations.add(spec.destination.replace("*", capture));
|
|
1396
|
+
}
|
|
1397
|
+
if (destinations.size !== 1) {
|
|
1398
|
+
return unmanaged(`Fetch refspec does not map ${source} to exactly one destination.`);
|
|
1399
|
+
}
|
|
1400
|
+
const ref = [...destinations][0];
|
|
1401
|
+
if (!ref.startsWith("refs/remotes/") || !await gitSucceeds(["check-ref-format", ref])) {
|
|
1402
|
+
return unmanaged(`Fetch destination is not a supported remote-tracking ref: ${ref}.`);
|
|
1403
|
+
}
|
|
1404
|
+
for (const entry of parsed) {
|
|
1405
|
+
for (const spec of entry.parsed) {
|
|
1406
|
+
if (spec.negative || spec.destination === undefined)
|
|
1407
|
+
continue;
|
|
1408
|
+
const capture = refspecCapture(spec.destination, ref);
|
|
1409
|
+
if (capture === null)
|
|
1410
|
+
continue;
|
|
1411
|
+
const otherSource = spec.source.replace("*", capture);
|
|
1412
|
+
if (!excluded(entry.parsed, otherSource)
|
|
1413
|
+
&& (entry.name !== selected.name || otherSource !== source)) {
|
|
1414
|
+
return unmanaged(`Another fetch source also manages ${ref}.`);
|
|
1415
|
+
}
|
|
1416
|
+
}
|
|
1417
|
+
}
|
|
1418
|
+
try {
|
|
1419
|
+
await readDirectTrackingRef(root, ref);
|
|
1420
|
+
}
|
|
1421
|
+
catch (error) {
|
|
1422
|
+
return unmanaged(error instanceof Error ? error.message : String(error));
|
|
1423
|
+
}
|
|
1424
|
+
return { status: "managed", remoteName: selected.name, remoteUrl: wantedUrl, ref, evidence };
|
|
1425
|
+
}
|
|
1426
|
+
async function readDirectTrackingRef(root, ref) {
|
|
1427
|
+
const output = await git([
|
|
1428
|
+
"-C", root, "for-each-ref", "--format=%(refname)%09%(objectname)%09%(symref)", "--", ref
|
|
1429
|
+
]);
|
|
1430
|
+
const record = output.split("\n").find((line) => line.split("\t")[0] === ref);
|
|
1431
|
+
if (record === undefined) {
|
|
1432
|
+
// for-each-ref omits dangling symbolic refs. Never treat those as absent.
|
|
1433
|
+
if (await gitSucceeds(["-C", root, "symbolic-ref", "--quiet", ref])) {
|
|
1434
|
+
throw new Error(`Tracking destination is symbolic: ${ref}.`);
|
|
1435
|
+
}
|
|
1436
|
+
return null;
|
|
1437
|
+
}
|
|
1438
|
+
const [, commit, symbolic] = record.split("\t");
|
|
1439
|
+
if (symbolic !== "" || commit === undefined || !isCommit(commit)) {
|
|
1440
|
+
throw new Error(`Tracking destination is not a direct object ref: ${ref}.`);
|
|
1441
|
+
}
|
|
1442
|
+
return commit.toLowerCase();
|
|
1443
|
+
}
|
|
1127
1444
|
function safeIdentity(value, label) {
|
|
1128
1445
|
const identity = requireText(value, label);
|
|
1129
1446
|
if ([".", "..", "__proto__", "prototype", "constructor"].includes(identity)
|
|
@@ -1151,10 +1468,13 @@ async function safeFetchBranch(value) {
|
|
|
1151
1468
|
}
|
|
1152
1469
|
return branch;
|
|
1153
1470
|
}
|
|
1154
|
-
async function resolveRemoteHeadBranch(remote) {
|
|
1471
|
+
async function resolveRemoteHeadBranch(remote, repositoryPath) {
|
|
1155
1472
|
let output;
|
|
1156
1473
|
try {
|
|
1157
|
-
output = await git([
|
|
1474
|
+
output = await git([
|
|
1475
|
+
...(repositoryPath === undefined ? [] : ["-C", repositoryPath]),
|
|
1476
|
+
"ls-remote", "--symref", remote, "HEAD"
|
|
1477
|
+
]);
|
|
1158
1478
|
}
|
|
1159
1479
|
catch (error) {
|
|
1160
1480
|
const detail = error instanceof Error ? error.message : "Git command failed.";
|
|
@@ -1177,11 +1497,14 @@ async function resolveRemoteHeadBranch(remote) {
|
|
|
1177
1497
|
throw invalidRemoteHead();
|
|
1178
1498
|
}
|
|
1179
1499
|
}
|
|
1180
|
-
async function resolveRemoteBranchCommit(remote, branch) {
|
|
1500
|
+
async function resolveRemoteBranchCommit(remote, branch, repositoryPath) {
|
|
1181
1501
|
const target = `refs/heads/${branch}`;
|
|
1182
1502
|
let output;
|
|
1183
1503
|
try {
|
|
1184
|
-
output = await git([
|
|
1504
|
+
output = await git([
|
|
1505
|
+
...(repositoryPath === undefined ? [] : ["-C", repositoryPath]),
|
|
1506
|
+
"ls-remote", "--refs", remote, target
|
|
1507
|
+
]);
|
|
1185
1508
|
}
|
|
1186
1509
|
catch (error) {
|
|
1187
1510
|
const detail = error instanceof Error ? error.message : "Git command failed.";
|