@zq-silk/yui 0.15.11 → 0.16.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.
Files changed (280) hide show
  1. package/ARCHITECTURE.md +11 -6
  2. package/ARCHITECTURE.zh-CN.md +8 -4
  3. package/README.md +13 -5
  4. package/dist/agentRun/agentRun.js +3 -0
  5. package/dist/artifacts/artifactCommitLock.js +16 -55
  6. package/dist/artifacts/taskArtifactRepository.js +3 -3
  7. package/dist/cli/agentConfigurationPicker.js +1 -1
  8. package/dist/cli/commandCatalog.js +92 -48
  9. package/dist/cli/completion.js +18 -18
  10. package/dist/cli/completionWizard.js +1 -1
  11. package/dist/cli/dynamicCompletion.js +1 -1
  12. package/dist/cli/interactionPolicy.js +11 -3
  13. package/dist/cli/invocationAuthority.js +64 -0
  14. package/dist/cli/managedDiagnostics.js +2 -2
  15. package/dist/cli/parseRepeatable.js +35 -0
  16. package/dist/cli/updatePorts.js +26 -5
  17. package/dist/cli.js +1303 -1235
  18. package/dist/commands/agentCommands.js +4 -4
  19. package/dist/commands/capabilityCommands.js +1 -1
  20. package/dist/commands/configCommands.js +6 -57
  21. package/dist/commands/configOverview.js +2 -2
  22. package/dist/commands/durableJobCommands.js +12 -6
  23. package/dist/commands/executionAuditCommands.js +10 -0
  24. package/dist/commands/globalRoleCommands.js +78 -29
  25. package/dist/commands/grantCommands.js +0 -3
  26. package/dist/commands/jobCommands.js +1 -4
  27. package/dist/commands/operatorCommands.js +3 -3
  28. package/dist/commands/projectCommands.js +45 -25
  29. package/dist/commands/resourcesCommands.js +10 -40
  30. package/dist/commands/roleConfiguration.js +2 -6
  31. package/dist/commands/taskActivationCommands.js +4 -8
  32. package/dist/commands/taskAgentCapabilities.js +9 -0
  33. package/dist/commands/taskCommandSupport.js +155 -0
  34. package/dist/commands/taskCommandTypes.js +1 -0
  35. package/dist/commands/taskCommands.js +355 -732
  36. package/dist/commands/taskCompletionGate.js +1 -1
  37. package/dist/commands/taskExecutionCommands.js +1 -1
  38. package/dist/commands/taskFactCommands.js +325 -0
  39. package/dist/commands/taskInputCommands.js +12 -42
  40. package/dist/commands/taskIntegrationCommands.js +20 -58
  41. package/dist/commands/taskNextActionCommand.js +7 -6
  42. package/dist/commands/taskPublicationAdoptCommand.js +127 -0
  43. package/dist/commands/taskPublicationCommands.js +12 -3
  44. package/dist/commands/taskPublicationVerifyCommand.js +24 -40
  45. package/dist/commands/taskRemoteDeliveryCommand.js +5 -86
  46. package/dist/commands/taskUpstreamCommands.js +19 -8
  47. package/dist/commands/workflowCommands.js +6 -1
  48. package/dist/completion/completionInstaller.js +26 -26
  49. package/dist/completion/completionState.js +26 -26
  50. package/dist/completion/fileCompletionManager.js +6 -11
  51. package/dist/config/configCatalog.js +0 -2
  52. package/dist/config/timeZone.js +14 -0
  53. package/dist/config/yuiConfig.js +0 -29
  54. package/dist/context/runContextPack.js +5 -3
  55. package/dist/context/taskCatalog.js +185 -0
  56. package/dist/context/taskContext.js +152 -151
  57. package/dist/context/wakeRunReferences.js +11 -0
  58. package/dist/controller/agentCapabilities.js +58 -0
  59. package/dist/controller/agentRuntimeObserver.js +6 -11
  60. package/dist/controller/clientRuntime.js +22 -14
  61. package/dist/controller/controller.js +20 -24
  62. package/dist/controller/fileSchedulerStoreAdapter.js +191 -225
  63. package/dist/controller/globalInputDelivery.js +13 -0
  64. package/dist/controller/jobClient.js +4 -4
  65. package/dist/controller/jobControl.js +60 -25
  66. package/dist/controller/jobSupervisor.js +4 -4
  67. package/dist/controller/providerRetryAdmission.js +100 -0
  68. package/dist/controller/providerRetryDelivery.js +218 -0
  69. package/dist/controller/runtime.js +85 -43
  70. package/dist/controller/runtimeEventProcessor.js +0 -5
  71. package/dist/controller/sessionOwnerReconciliation.js +5 -0
  72. package/dist/controller/taskAgentError.js +35 -0
  73. package/dist/coordination/workMailboxQueue.js +2 -24
  74. package/dist/core/controllerClient.js +2 -2
  75. package/dist/core/fileLockOwner.js +74 -0
  76. package/dist/decision/decision.js +17 -0
  77. package/dist/doctor/doctor.js +18 -9
  78. package/dist/event/taskEvent.js +12 -0
  79. package/dist/execution/workItemExecutionProjection.js +2 -2
  80. package/dist/executor/agentCapabilityConfig.js +43 -0
  81. package/dist/executor/agentConfigurationCatalog.js +49 -11
  82. package/dist/executor/agentExecutor.js +5 -8
  83. package/dist/executor/fileRoleLaunchPlanner.js +11 -53
  84. package/dist/executor/taskAgentCapabilities.js +64 -0
  85. package/dist/integration/deliveryObligation.js +1 -69
  86. package/dist/integration/gitIntegrationService.js +79 -90
  87. package/dist/integration/integrationAttempt.js +3 -14
  88. package/dist/integration/integrationSourceApplication.js +4 -12
  89. package/dist/integration/manifestTags.js +2 -2
  90. package/dist/job/durableJob.js +4 -25
  91. package/dist/job/jobAssignmentScope.js +22 -0
  92. package/dist/job/jobOperation.js +16 -0
  93. package/dist/job/jobRunner.js +2 -1
  94. package/dist/job/stepDirectory.js +15 -0
  95. package/dist/kernel/builtinCapabilities.js +15 -11
  96. package/dist/kernel/kernelPorts.js +1 -17
  97. package/dist/lifecycle/exactRunTerminalization.js +5 -10
  98. package/dist/message/globalProviderRetry.js +15 -0
  99. package/dist/message/inputControlResolution.js +3 -4
  100. package/dist/message/message.js +17 -20
  101. package/dist/message/messageContinuation.js +1 -1
  102. package/dist/milestone/milestone.js +11 -0
  103. package/dist/observability/executionAudit.js +19 -0
  104. package/dist/observability/orchestrationMetrics.js +24 -1
  105. package/dist/observability/runtimeIdentity.js +1 -17
  106. package/dist/output/agentConfigurationPresentation.js +2 -0
  107. package/dist/output/timePresentation.js +1 -14
  108. package/dist/plugins/pluginService.js +12 -4
  109. package/dist/release/releaseWorkflowPorts.js +45 -96
  110. package/dist/release/runtimeRelease.js +9 -15
  111. package/dist/repository/gitWorkspace.js +371 -105
  112. package/dist/repository/projectMaintenanceLock.js +98 -82
  113. package/dist/repository/taskBaseFreshness.js +1 -1
  114. package/dist/repository/taskWorkspaceCoordinator.js +82 -144
  115. package/dist/repository/taskWorkspacePreparer.js +113 -47
  116. package/dist/repository/workspaceCleanupInspection.js +187 -0
  117. package/dist/resources/autoResourceGc.js +4 -77
  118. package/dist/resources/liveReferences.js +7 -54
  119. package/dist/resources/resourceDiscovery.js +5 -118
  120. package/dist/resources/resourceGc.js +233 -181
  121. package/dist/resources/resourceRegistrar.js +5 -4
  122. package/dist/resources/resourceRegistry.js +3 -37
  123. package/dist/resources/resourceRegistryStore.js +13 -36
  124. package/dist/resources/sqliteResourceRegistry.js +43 -26
  125. package/dist/review/deltaRecheck.js +1 -1
  126. package/dist/review/reviewAcceptance.js +1 -1
  127. package/dist/review/reviewDecision.js +1 -1
  128. package/dist/review/reviewRound.js +8 -7
  129. package/dist/review/taskFinalReviewContractResolution.js +1 -1
  130. package/dist/runtime/acpProtocol.js +4 -51
  131. package/dist/runtime/acpSession.js +10 -75
  132. package/dist/runtime/acpSessionConfiguration.js +1 -8
  133. package/dist/runtime/agentEndpoint.js +3 -2
  134. package/dist/runtime/agentError.js +5 -3
  135. package/dist/runtime/agentFailureContext.js +43 -0
  136. package/dist/runtime/agentHost.js +42 -25
  137. package/dist/runtime/builtinAgentErrorMappers.js +91 -0
  138. package/dist/runtime/codexAppServerRuntime.js +34 -3
  139. package/dist/runtime/codexInteractiveHost.js +2 -2
  140. package/dist/runtime/index.js +0 -1
  141. package/dist/runtime/managedCaller.js +21 -1
  142. package/dist/runtime/providerControl.js +5 -1
  143. package/dist/runtime/providerErrors.js +38 -0
  144. package/dist/runtime/providerRetry.js +198 -0
  145. package/dist/runtime/providerRuntimeIdentity.js +28 -2
  146. package/dist/runtime/runtimeObservation.js +0 -5
  147. package/dist/runtime/runtimeSessionCandidate.js +2 -3
  148. package/dist/runtime/sessionOwnerIdentity.js +1 -1
  149. package/dist/runtime/sessionTokenMetrics.js +15 -5
  150. package/dist/runtime/structuredProviderHost.js +10 -43
  151. package/dist/runtime/taskUsageMetrics.js +275 -0
  152. package/dist/runtime/tmuxAdapters.js +6 -8
  153. package/dist/scheduler/activeRoleRunDelivery.js +19 -7
  154. package/dist/scheduler/leaderWakeupProcessor.js +24 -6
  155. package/dist/scheduler/operatorEvent.js +12 -20
  156. package/dist/scheduler/operatorInputNotificationProcessor.js +1 -1
  157. package/dist/scheduler/ports.js +5 -8
  158. package/dist/scheduler/roleRunLiveness.js +4 -4
  159. package/dist/scheduler/roleRunStall.js +16 -23
  160. package/dist/scheduler/taskExecutionProjection.js +49 -36
  161. package/dist/scheduler/taskObservabilityProjection.js +6 -45
  162. package/dist/scheduler/taskWake.js +5 -10
  163. package/dist/scheduler/wakeReason.js +2 -0
  164. package/dist/scheduler/wakeupQueue.js +2 -4
  165. package/dist/setup/setupCommand.js +1 -1
  166. package/dist/storage/contextRecords.js +106 -0
  167. package/dist/storage/currentTaskStore.js +1 -1
  168. package/dist/storage/homeLayout.js +13 -17
  169. package/dist/storage/migrations/agentFailureContext.js +22 -0
  170. package/dist/storage/migrations/currentInputContract.js +86 -0
  171. package/dist/storage/migrations/currentRuntimeContract.js +228 -0
  172. package/dist/storage/migrations/historicalVerificationPlan.js +35 -0
  173. package/dist/storage/migrations/integrationContinuation.js +5 -4
  174. package/dist/storage/migrations/narrowAgentFailureContext.js +65 -0
  175. package/dist/storage/migrations/notificationOnlyWakes.js +74 -0
  176. package/dist/storage/migrations/verificationPlanV1.js +162 -0
  177. package/dist/storage/migrations/verificationPolicy.js +74 -0
  178. package/dist/storage/migrations/workItemHistory.js +46 -0
  179. package/dist/storage/persistenceWorker.js +12 -4
  180. package/dist/storage/recordValidation.js +87 -0
  181. package/dist/storage/sqliteSchema.js +173 -1
  182. package/dist/storage/sqliteStore.js +172 -161
  183. package/dist/storage/storageSchema.js +2 -14
  184. package/dist/storage/storageVersions.js +1 -1
  185. package/dist/storage/storeRpc.js +16 -7
  186. package/dist/storage/taskCatalog.js +123 -0
  187. package/dist/storage/taskStore.js +5 -7
  188. package/dist/storage/upgrade/upgradeOrchestrator.js +25 -37
  189. package/dist/task/archiveDiagnostics.js +1 -0
  190. package/dist/task/archivePreflight.js +124 -0
  191. package/dist/task/completionReadiness.js +5 -24
  192. package/dist/task/draftPlan.js +0 -53
  193. package/dist/task/nextAction.js +7 -13
  194. package/dist/task/publicationAdoption.js +56 -0
  195. package/dist/task/publicationReference.js +10 -0
  196. package/dist/task/remoteDelivery.js +31 -16
  197. package/dist/task/remoteDeliveryService.js +89 -0
  198. package/dist/task/taskActivation.js +2 -25
  199. package/dist/task/taskActivationService.js +0 -2
  200. package/dist/task/taskRecordReference.js +0 -1
  201. package/dist/task/taskSubmission.js +3 -14
  202. package/dist/telemetry/sqliteTelemetryBatch.js +29 -0
  203. package/dist/telemetry/sqliteTelemetryStore.js +49 -49
  204. package/dist/telemetry/telemetryWiring.js +4 -3
  205. package/dist/tmux/tmuxManager.js +29 -20
  206. package/dist/verification/gateArtifact.js +15 -24
  207. package/dist/verification/gateArtifactStore.js +14 -7
  208. package/dist/verification/verificationGateService.js +29 -88
  209. package/dist/verification/verificationPlan.js +27 -72
  210. package/dist/web/assets/client/app.js +92 -17
  211. package/dist/web/assets/client/components.js +55 -13
  212. package/dist/web/assets/client/i18n.js +72 -4
  213. package/dist/web/assets/client/taskSurface.js +6 -5
  214. package/dist/web/assets/client/view.js +35 -7
  215. package/dist/web/assets/shell.js +6 -0
  216. package/dist/web/assets/styles/layout.js +7 -0
  217. package/dist/web/webServer.js +12 -3
  218. package/dist/web/webSnapshot.js +14 -85
  219. package/dist/web/webTaskSurface.js +17 -46
  220. package/dist/workItem/workItem.js +1 -12
  221. package/dist/workspace/cleanupInspection.js +63 -0
  222. package/dist/workspace/workItemChangeSetManager.js +110 -50
  223. package/docs/agent-result-consumption.md +4 -0
  224. package/docs/agent-result-consumption.zh-CN.md +3 -0
  225. package/docs/agent-runtime-drivers.md +7 -0
  226. package/docs/agent-runtime-drivers.zh-CN.md +5 -0
  227. package/docs/architecture/README.md +2 -0
  228. package/docs/architecture/README.zh-CN.md +3 -1
  229. package/docs/architecture/capabilities-and-resources.md +42 -5
  230. package/docs/architecture/capabilities-and-resources.zh-CN.md +33 -3
  231. package/docs/managed-turn-and-session-runtime.md +121 -1
  232. package/docs/managed-turn-and-session-runtime.zh-CN.md +96 -2
  233. package/docs/observability/README.md +62 -0
  234. package/docs/observability/README.zh-CN.md +47 -0
  235. package/docs/plugin-sdk.md +4 -2
  236. package/docs/project-refresh.md +77 -0
  237. package/docs/project-refresh.zh-CN.md +59 -0
  238. package/docs/provider-retry.md +70 -0
  239. package/docs/provider-runtime.md +3 -1
  240. package/docs/provider-runtime.zh-CN.md +4 -2
  241. package/docs/release-workflow.md +190 -7
  242. package/docs/release-workflow.zh-CN.md +139 -5
  243. package/docs/roles-and-configuration.md +29 -0
  244. package/docs/roles-and-configuration.zh-CN.md +21 -0
  245. package/docs/sqlite-control-plane-design.md +16 -3
  246. package/docs/sqlite-control-plane-design.zh-CN.md +13 -2
  247. package/docs/task-dag-semantics.md +3 -3
  248. package/docs/task-dag-semantics.zh-CN.md +2 -2
  249. package/docs/task-delivery.md +223 -17
  250. package/docs/task-delivery.zh-CN.md +171 -14
  251. package/docs/task-discovery.md +101 -0
  252. package/docs/task-discovery.zh-CN.md +85 -0
  253. package/docs/testing/verification-levels.md +161 -9
  254. package/docs/testing/verification-levels.zh-CN.md +116 -9
  255. package/i18n/README.zh-CN.md +13 -7
  256. package/package.json +1 -1
  257. package/skills/yui-leader/SKILL.md +5 -0
  258. package/skills/yui-leader/references/execution.md +3 -2
  259. package/skills/yui-leader/references/integration.md +9 -5
  260. package/skills/yui-leader/references/planning.md +9 -3
  261. package/skills/yui-operator/SKILL.md +24 -8
  262. package/skills/yui-reviewer/SKILL.md +4 -0
  263. package/skills/yui-runtime/SKILL.md +17 -2
  264. package/skills/yui-runtime/references/publication.md +26 -4
  265. package/skills/yui-runtime/references/recovery.md +71 -0
  266. package/dist/agent/agentRegistry.js +0 -10
  267. package/dist/agentRun/runIdentity.js +0 -22
  268. package/dist/commands/deliveryGuardPreflight.js +0 -30
  269. package/dist/commands/taskIntegrationQueueCommands.js +0 -226
  270. package/dist/commands/taskOverviewCommand.js +0 -376
  271. package/dist/completion/completionWizard.js +0 -125
  272. package/dist/context/dispatchContext.js +0 -110
  273. package/dist/context/wakeNotification.js +0 -144
  274. package/dist/integration/integrationCheckEvidenceReuse.js +0 -53
  275. package/dist/integration/integrationQueueEntry.js +0 -191
  276. package/dist/integration/integrationQueueService.js +0 -810
  277. package/dist/release/fakeReleasePorts.js +0 -55
  278. package/dist/runtime/sessionOwnerRegistry.js +0 -137
  279. package/dist/task/deliveryGuard.js +0 -226
  280. /package/dist/{commands/taskActor.js → task/taskAuthority.js} +0 -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 stableRemoteRef = `refs/heads/${stableBranch}`;
181
- await git(["-C", initial.root, "fetch", "--no-tags", remote, stableRemoteRef]);
182
- const fetchedCommit = (await gitLine([
183
- "-C", initial.root,
184
- "rev-parse", "--verify", "--end-of-options", "FETCH_HEAD^{commit}"
185
- ])).toLowerCase();
186
- // The advertised SHA must match what was fetched. A network race, a ref
187
- // that moved mid-fetch, or any inconsistency fails closed: the stable
188
- // checkout is never advanced to an unverified commit.
189
- const advertisedCommit = await resolveRemoteBranchCommit(remote, stableBranch);
190
- if (fetchedCommit !== advertisedCommit) {
191
- throw new Error(`Project remote stable branch changed while it was fetched: ${stableBranch}.`);
192
- }
193
- await this.#assertRefreshCheckout(initial.root, stableBranch, initial.baseCommit);
194
- if (fetchedCommit === initial.baseCommit) {
195
- return {
196
- fromCommit: initial.baseCommit,
197
- toCommit: fetchedCommit,
198
- changed: false
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
- if (!await gitSucceeds([
202
- "-C", initial.root,
203
- "merge-base", "--is-ancestor", initial.baseCommit, fetchedCommit
204
- ])) {
205
- throw new Error(`Project checkout cannot be fast-forwarded from ${initial.baseCommit} to ${fetchedCommit}.`);
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: fetchedCommit,
215
- changed: true
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 ref = `refs/remotes/${remoteName}/${branch}`;
547
- if (!await gitSucceeds(["check-ref-format", ref])) {
548
- throw new Error("Git remote tracking ref is invalid.");
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: matches[0].url,
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
- "-C", root, "status", "--porcelain=v1", "--untracked-files=all"
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,9 +732,6 @@ 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({
@@ -786,34 +844,40 @@ export class NodeGitWorkspace {
786
844
  return "removed";
787
845
  }
788
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) {
789
854
  const identity = worktreeIdentity(input.taskSegment, "main");
790
855
  const expected = managedPath(resolve(input.container), input.directory);
791
856
  if (resolve(input.path) !== expected || input.branch !== identity.branch) {
792
- throw new Error("Task clone does not match its recorded managed identity.");
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 });
793
858
  }
794
859
  const kind = await pathKind(expected);
795
860
  if (kind === undefined)
796
861
  return "missing";
797
862
  if (kind === "symlink")
798
- throw new Error("Task clone must not be a symbolic link.");
863
+ cleanupFailure("workspace-identity-mismatch", "Task clone must not be a symbolic link.", "real directory", "symbolic link");
799
864
  await canonicalContainer(input.container, false);
800
865
  if (await realpath(expected) !== expected || await pathKind(join(expected, ".git")) !== "directory") {
801
- throw new Error("Task clone is not a standalone owned 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");
802
867
  }
803
868
  const repository = await this.inspect(expected);
804
869
  if (repository.root !== expected || repository.gitDirectory !== join(expected, ".git")) {
805
- throw new Error("Task clone Git ownership cannot be established.");
870
+ cleanupFailure("workspace-identity-mismatch", "Task clone Git ownership cannot be established.", "exact root and Git directory", "different Git ownership");
806
871
  }
807
872
  await assertExpectedBranch(expected, input.branch);
808
- if (!await this.isClean(expected))
873
+ if (!await this.inspectClean(expected))
809
874
  return "dirty";
810
875
  const worktrees = (await git(["-C", expected, "worktree", "list", "--porcelain", "-z"]))
811
876
  .split("\0").filter(line => line.startsWith("worktree "));
812
877
  if (worktrees.length !== 1 || worktrees[0] !== `worktree ${expected}`) {
813
- throw new Error("Task clone still owns Git worktree registrations; retained.");
878
+ cleanupFailure("git-worktree-registrations", "Task clone still owns Git worktree registrations; retained.", 1, worktrees.length);
814
879
  }
815
- await rm(expected, { recursive: true });
816
- return "removed";
880
+ return "clean";
817
881
  }
818
882
  async inspectRecordedWorktree(input) {
819
883
  const inspected = await inspectExactRecordedWorktree(input);
@@ -862,14 +926,19 @@ export class NodeGitWorkspace {
862
926
  const container = resolve(input.container);
863
927
  const path = managedPath(container, input.directory);
864
928
  const kind = await pathKind(path);
865
- if (kind === undefined)
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);
866
933
  return "missing";
934
+ }
867
935
  if (kind === "symlink")
868
- throw new Error("Managed worktree path must not be a symbolic link.");
936
+ cleanupFailure("workspace-identity-mismatch", "Managed worktree path must not be a symbolic link.", "real directory", "symbolic link");
869
937
  const canonicalContainerPath = await canonicalContainer(container, false);
870
938
  const project = await this.inspect(input.repositoryPath);
871
939
  await assertOwnedWorktree(project, canonicalContainerPath, path);
872
- const porcelain = await git(["-C", path, "status", "--porcelain=v1", "--untracked-files=all"]);
940
+ await assertExpectedBranch(path, branch);
941
+ const porcelain = await readWorktreeStatus(path);
873
942
  return porcelain.length > 0 ? "dirty" : "clean";
874
943
  }
875
944
  async removeIntegrationWorktree(input) {
@@ -901,9 +970,7 @@ export class NodeGitWorkspace {
901
970
  await git(["-C", repository.root, "worktree", "remove", "--force", "--", path]);
902
971
  }
903
972
  else {
904
- const porcelain = await git([
905
- "-C", path, "status", "--porcelain=v1", "--untracked-files=all"
906
- ]);
973
+ const porcelain = await readWorktreeStatus(path);
907
974
  if (porcelain.length > 0)
908
975
  return "dirty";
909
976
  if (input.expectedBaseCommit !== undefined
@@ -930,16 +997,22 @@ export class NodeGitWorkspace {
930
997
  * workspace's registration as a side effect of cleaning this exact owner.
931
998
  */
932
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) {
933
1007
  const records = (await git(["-C", repositoryRoot, "worktree", "list", "--porcelain", "-z"])).split("\0\0");
934
1008
  const fields = records.map(record => record.split("\0")).find(record => record.includes(`worktree ${path}`));
935
1009
  if (fields === undefined)
936
- return;
1010
+ return false;
937
1011
  if (!fields.includes(`branch refs/heads/${branch}`) || fields.some(field => field.startsWith("locked"))) {
938
- throw new Error(`Missing worktree has uncertain or locked Git ownership: ${path}; metadata retained.`);
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")) });
939
1014
  }
940
- if (await pathKind(path) !== undefined)
941
- throw new Error(`Worktree reappeared before metadata cleanup: ${path}.`);
942
- await git(["-C", repositoryRoot, "worktree", "remove", "--force", "--", path]);
1015
+ return true;
943
1016
  }
944
1017
  async function deleteBranchIfPresent(repositoryRoot, branch) {
945
1018
  const branchRef = `refs/heads/${branch}`;
@@ -999,7 +1072,7 @@ async function assertExpectedBranch(path, expected, allowRebase = false) {
999
1072
  }
1000
1073
  const branch = await gitLine(["-C", path, "symbolic-ref", "--short", "HEAD"]);
1001
1074
  if (branch !== expected) {
1002
- throw new Error(`Managed worktree is on an unexpected branch: ${branch}.`);
1075
+ cleanupFailure("workspace-identity-mismatch", "Managed worktree is on an unexpected branch.", expected, branch);
1003
1076
  }
1004
1077
  }
1005
1078
  async function assertOwnedWorktree(project, container, path) {
@@ -1012,7 +1085,7 @@ async function assertOwnedWorktree(project, container, path) {
1012
1085
  throw new Error("Managed worktree root does not match its deterministic path.");
1013
1086
  const common = await canonicalDirectory(await gitLine(["-C", path, "rev-parse", "--path-format=absolute", "--git-common-dir"]), "Managed Git common directory");
1014
1087
  if (common !== project.gitDirectory) {
1015
- throw new Error("Managed worktree belongs to another project.");
1088
+ cleanupFailure("workspace-identity-mismatch", "Managed worktree belongs to another project.", "Task main Git common directory", "different Git common directory");
1016
1089
  }
1017
1090
  }
1018
1091
  async function inspectExactRecordedWorktree(input) {
@@ -1147,13 +1220,72 @@ async function pathKind(path) {
1147
1220
  throw error;
1148
1221
  }
1149
1222
  }
1150
- async function git(args) {
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 = {}) {
1151
1279
  try {
1152
- const result = await executeFile("git", [...args], {
1280
+ const execution = executeFile("git", [...args], {
1153
1281
  encoding: "utf8",
1154
1282
  maxBuffer: 1024 * 1024,
1155
- timeout: 30_000
1283
+ timeout: 30_000,
1284
+ ...(options.environment === undefined ? {} : { env: { ...process.env, ...options.environment } })
1156
1285
  });
1286
+ if (options.input !== undefined)
1287
+ execution.child.stdin.end(options.input);
1288
+ const result = await execution;
1157
1289
  return result.stdout;
1158
1290
  }
1159
1291
  catch (error) {
@@ -1181,6 +1313,134 @@ async function gitLine(args) {
1181
1313
  }
1182
1314
  return lines[0];
1183
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
+ }
1184
1444
  function safeIdentity(value, label) {
1185
1445
  const identity = requireText(value, label);
1186
1446
  if ([".", "..", "__proto__", "prototype", "constructor"].includes(identity)
@@ -1208,10 +1468,13 @@ async function safeFetchBranch(value) {
1208
1468
  }
1209
1469
  return branch;
1210
1470
  }
1211
- async function resolveRemoteHeadBranch(remote) {
1471
+ async function resolveRemoteHeadBranch(remote, repositoryPath) {
1212
1472
  let output;
1213
1473
  try {
1214
- output = await git(["ls-remote", "--symref", remote, "HEAD"]);
1474
+ output = await git([
1475
+ ...(repositoryPath === undefined ? [] : ["-C", repositoryPath]),
1476
+ "ls-remote", "--symref", remote, "HEAD"
1477
+ ]);
1215
1478
  }
1216
1479
  catch (error) {
1217
1480
  const detail = error instanceof Error ? error.message : "Git command failed.";
@@ -1234,11 +1497,14 @@ async function resolveRemoteHeadBranch(remote) {
1234
1497
  throw invalidRemoteHead();
1235
1498
  }
1236
1499
  }
1237
- async function resolveRemoteBranchCommit(remote, branch) {
1500
+ async function resolveRemoteBranchCommit(remote, branch, repositoryPath) {
1238
1501
  const target = `refs/heads/${branch}`;
1239
1502
  let output;
1240
1503
  try {
1241
- output = await git(["ls-remote", "--refs", remote, target]);
1504
+ output = await git([
1505
+ ...(repositoryPath === undefined ? [] : ["-C", repositoryPath]),
1506
+ "ls-remote", "--refs", remote, target
1507
+ ]);
1242
1508
  }
1243
1509
  catch (error) {
1244
1510
  const detail = error instanceof Error ? error.message : "Git command failed.";