@bevel-software/platform-core-backend 0.28.1 → 0.30.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 (274) hide show
  1. package/agent-guide/access-control.md +15 -0
  2. package/agent-guide/skills.md +8 -0
  3. package/dist/__tests__/test-repositories.d.ts +3 -0
  4. package/dist/__tests__/test-repositories.d.ts.map +1 -0
  5. package/dist/__tests__/test-repositories.js +18 -0
  6. package/dist/__tests__/test-repositories.js.map +1 -0
  7. package/dist/core/catalog-revision.d.ts.map +1 -1
  8. package/dist/core/catalog-revision.js +4 -2
  9. package/dist/core/catalog-revision.js.map +1 -1
  10. package/dist/core/compose-embed.d.ts +35 -0
  11. package/dist/core/compose-embed.d.ts.map +1 -0
  12. package/dist/core/compose-embed.js +14 -0
  13. package/dist/core/compose-embed.js.map +1 -0
  14. package/dist/core/core-ports.d.ts +10 -0
  15. package/dist/core/core-ports.d.ts.map +1 -1
  16. package/dist/core/core-ports.js.map +1 -1
  17. package/dist/core/create-core-server.d.ts.map +1 -1
  18. package/dist/core/create-core-server.js +8 -2
  19. package/dist/core/create-core-server.js.map +1 -1
  20. package/dist/core/create-core-services.d.ts +4 -1
  21. package/dist/core/create-core-services.d.ts.map +1 -1
  22. package/dist/core/create-core-services.js +57 -18
  23. package/dist/core/create-core-services.js.map +1 -1
  24. package/dist/core/lifecycle.d.ts +19 -6
  25. package/dist/core/lifecycle.d.ts.map +1 -1
  26. package/dist/core/lifecycle.js +17 -6
  27. package/dist/core/lifecycle.js.map +1 -1
  28. package/dist/modules/access/access-control.interface.d.ts +20 -0
  29. package/dist/modules/access/access-control.interface.d.ts.map +1 -1
  30. package/dist/modules/access/access-control.service.d.ts +68 -15
  31. package/dist/modules/access/access-control.service.d.ts.map +1 -1
  32. package/dist/modules/access/access-control.service.js +282 -58
  33. package/dist/modules/access/access-control.service.js.map +1 -1
  34. package/dist/modules/access/access.routes.d.ts.map +1 -1
  35. package/dist/modules/access/access.routes.js +20 -6
  36. package/dist/modules/access/access.routes.js.map +1 -1
  37. package/dist/modules/agent-instructions/shared-file-rules.d.ts +1 -1
  38. package/dist/modules/agent-instructions/shared-file-rules.js +1 -1
  39. package/dist/modules/auth/index.d.ts +15 -0
  40. package/dist/modules/auth/index.d.ts.map +1 -0
  41. package/dist/modules/auth/index.js +20 -0
  42. package/dist/modules/auth/index.js.map +1 -0
  43. package/dist/modules/diff/diff.service.d.ts.map +1 -1
  44. package/dist/modules/diff/diff.service.js +7 -1
  45. package/dist/modules/diff/diff.service.js.map +1 -1
  46. package/dist/modules/embed/embed.interface.d.ts +22 -0
  47. package/dist/modules/embed/embed.interface.d.ts.map +1 -1
  48. package/dist/modules/embed/embed.service.d.ts +28 -14
  49. package/dist/modules/embed/embed.service.d.ts.map +1 -1
  50. package/dist/modules/embed/embed.service.js +38 -17
  51. package/dist/modules/embed/embed.service.js.map +1 -1
  52. package/dist/modules/embed/embed.tools.d.ts.map +1 -1
  53. package/dist/modules/embed/embed.tools.js +10 -2
  54. package/dist/modules/embed/embed.tools.js.map +1 -1
  55. package/dist/modules/kb-fs/git-dir.d.ts +21 -0
  56. package/dist/modules/kb-fs/git-dir.d.ts.map +1 -0
  57. package/dist/modules/kb-fs/git-dir.js +58 -0
  58. package/dist/modules/kb-fs/git-dir.js.map +1 -0
  59. package/dist/modules/kb-fs/index.d.ts +9 -0
  60. package/dist/modules/kb-fs/index.d.ts.map +1 -0
  61. package/dist/modules/kb-fs/index.js +3 -0
  62. package/dist/modules/kb-fs/index.js.map +1 -0
  63. package/dist/modules/kb-fs/ref-files.d.ts +63 -0
  64. package/dist/modules/kb-fs/ref-files.d.ts.map +1 -0
  65. package/dist/modules/kb-fs/ref-files.js +249 -0
  66. package/dist/modules/kb-fs/ref-files.js.map +1 -0
  67. package/dist/modules/plugins/join-request-jobs.service.d.ts.map +1 -1
  68. package/dist/modules/plugins/join-request-jobs.service.js +43 -8
  69. package/dist/modules/plugins/join-request-jobs.service.js.map +1 -1
  70. package/dist/modules/plugins/teams.routes.d.ts +4 -0
  71. package/dist/modules/plugins/teams.routes.d.ts.map +1 -1
  72. package/dist/modules/plugins/teams.routes.js +9 -1
  73. package/dist/modules/plugins/teams.routes.js.map +1 -1
  74. package/dist/modules/skills/skills.contract.d.ts.map +1 -1
  75. package/dist/modules/skills/skills.tools.d.ts +8 -1
  76. package/dist/modules/skills/skills.tools.d.ts.map +1 -1
  77. package/dist/modules/skills/skills.tools.js +115 -11
  78. package/dist/modules/skills/skills.tools.js.map +1 -1
  79. package/dist/modules/tool-auth/internal-token.service.d.ts +1 -1
  80. package/dist/modules/tool-auth/tool-auth.middleware.d.ts +1 -2
  81. package/dist/modules/tool-auth/tool-auth.middleware.d.ts.map +1 -1
  82. package/dist/modules/tool-auth/tool-auth.middleware.js +1 -2
  83. package/dist/modules/tool-auth/tool-auth.middleware.js.map +1 -1
  84. package/dist/modules/tool-helpers/default-branch-write.d.ts +69 -0
  85. package/dist/modules/tool-helpers/default-branch-write.d.ts.map +1 -0
  86. package/dist/modules/tool-helpers/default-branch-write.js +63 -0
  87. package/dist/modules/tool-helpers/default-branch-write.js.map +1 -0
  88. package/dist/modules/tool-helpers/index.d.ts +1 -0
  89. package/dist/modules/tool-helpers/index.d.ts.map +1 -1
  90. package/dist/modules/tool-helpers/index.js +1 -0
  91. package/dist/modules/tool-helpers/index.js.map +1 -1
  92. package/dist/modules/tool-helpers/tool-handler.d.ts +2 -2
  93. package/dist/modules/tool-helpers/tool.contract.d.ts +2 -2
  94. package/dist/modules/workflow/file-lock.service.d.ts +27 -5
  95. package/dist/modules/workflow/file-lock.service.d.ts.map +1 -1
  96. package/dist/modules/workflow/file-lock.service.js +70 -18
  97. package/dist/modules/workflow/file-lock.service.js.map +1 -1
  98. package/dist/modules/workflow/git/git.service.d.ts +42 -0
  99. package/dist/modules/workflow/git/git.service.d.ts.map +1 -1
  100. package/dist/modules/workflow/git/git.service.js +201 -24
  101. package/dist/modules/workflow/git/git.service.js.map +1 -1
  102. package/dist/modules/workflow/index.d.ts +8 -0
  103. package/dist/modules/workflow/index.d.ts.map +1 -0
  104. package/dist/modules/workflow/index.js +8 -0
  105. package/dist/modules/workflow/index.js.map +1 -0
  106. package/dist/modules/workflow/pending-commits.service.d.ts +2 -17
  107. package/dist/modules/workflow/pending-commits.service.d.ts.map +1 -1
  108. package/dist/modules/workflow/pending-commits.service.js +4 -24
  109. package/dist/modules/workflow/pending-commits.service.js.map +1 -1
  110. package/dist/modules/workflow/workflow-hooks.d.ts +1 -1
  111. package/dist/modules/workflow/workflow.routes.d.ts.map +1 -1
  112. package/dist/modules/workflow/workflow.routes.js +20 -1
  113. package/dist/modules/workflow/workflow.routes.js.map +1 -1
  114. package/dist/modules/workflow/workflow.service.d.ts.map +1 -1
  115. package/dist/modules/workflow/workflow.service.js +5 -3
  116. package/dist/modules/workflow/workflow.service.js.map +1 -1
  117. package/dist/modules/workspace/agent-access.gate.d.ts +2 -2
  118. package/dist/modules/workspace/agent-access.gate.js +2 -2
  119. package/dist/modules/workspace/bounded-matcher.d.ts +79 -0
  120. package/dist/modules/workspace/bounded-matcher.d.ts.map +1 -0
  121. package/dist/modules/workspace/bounded-matcher.js +261 -0
  122. package/dist/modules/workspace/bounded-matcher.js.map +1 -0
  123. package/dist/modules/workspace/fetch-instant.d.ts +9 -0
  124. package/dist/modules/workspace/fetch-instant.d.ts.map +1 -0
  125. package/dist/modules/workspace/fetch-instant.js +13 -0
  126. package/dist/modules/workspace/fetch-instant.js.map +1 -0
  127. package/dist/modules/workspace/index.d.ts +17 -0
  128. package/dist/modules/workspace/index.d.ts.map +1 -0
  129. package/dist/modules/workspace/index.js +16 -0
  130. package/dist/modules/workspace/index.js.map +1 -0
  131. package/dist/modules/workspace/kb-repository.contract.d.ts +124 -0
  132. package/dist/modules/workspace/kb-repository.contract.d.ts.map +1 -0
  133. package/dist/modules/workspace/kb-repository.contract.js +19 -0
  134. package/dist/modules/workspace/kb-repository.contract.js.map +1 -0
  135. package/dist/modules/workspace/kb-repository.d.ts +236 -0
  136. package/dist/modules/workspace/kb-repository.d.ts.map +1 -0
  137. package/dist/modules/workspace/kb-repository.js +811 -0
  138. package/dist/modules/workspace/kb-repository.js.map +1 -0
  139. package/dist/modules/workspace/pattern-matcher.contract.d.ts +50 -0
  140. package/dist/modules/workspace/pattern-matcher.contract.d.ts.map +1 -0
  141. package/dist/modules/workspace/pattern-matcher.contract.js +27 -0
  142. package/dist/modules/workspace/pattern-matcher.contract.js.map +1 -0
  143. package/dist/modules/workspace/routine-write-policy.d.ts +0 -7
  144. package/dist/modules/workspace/routine-write-policy.d.ts.map +1 -1
  145. package/dist/modules/workspace/routine-write-policy.js +0 -5
  146. package/dist/modules/workspace/routine-write-policy.js.map +1 -1
  147. package/dist/modules/workspace/set-aside-clone.d.ts +40 -0
  148. package/dist/modules/workspace/set-aside-clone.d.ts.map +1 -1
  149. package/dist/modules/workspace/set-aside-clone.js +139 -0
  150. package/dist/modules/workspace/set-aside-clone.js.map +1 -1
  151. package/dist/modules/workspace/startup/kb-startup-runner.d.ts +99 -0
  152. package/dist/modules/workspace/startup/kb-startup-runner.d.ts.map +1 -1
  153. package/dist/modules/workspace/startup/kb-startup-runner.js +407 -29
  154. package/dist/modules/workspace/startup/kb-startup-runner.js.map +1 -1
  155. package/dist/modules/workspace/workspace.service.d.ts +68 -32
  156. package/dist/modules/workspace/workspace.service.d.ts.map +1 -1
  157. package/dist/modules/workspace/workspace.service.js +240 -161
  158. package/dist/modules/workspace/workspace.service.js.map +1 -1
  159. package/dist/modules/workspace/workspace.tools.d.ts +13 -4
  160. package/dist/modules/workspace/workspace.tools.d.ts.map +1 -1
  161. package/dist/modules/workspace/workspace.tools.js +268 -356
  162. package/dist/modules/workspace/workspace.tools.js.map +1 -1
  163. package/dist/shared/workspace-id.d.ts +17 -0
  164. package/dist/shared/workspace-id.d.ts.map +1 -1
  165. package/dist/shared/workspace-id.js +24 -0
  166. package/dist/shared/workspace-id.js.map +1 -1
  167. package/dist/test-setup.js +4 -0
  168. package/dist/test-setup.js.map +1 -1
  169. package/mcp-app/page.html +142 -8
  170. package/package.json +3 -3
  171. package/src/__tests__/test-repositories.ts +22 -0
  172. package/src/__tests__/workspace-service.ts +69 -0
  173. package/src/core/__tests__/compose-embed.test.ts +65 -0
  174. package/src/core/__tests__/lifecycle.test.ts +45 -26
  175. package/src/core/catalog-revision.ts +4 -2
  176. package/src/core/compose-embed.ts +50 -0
  177. package/src/core/core-ports.ts +10 -0
  178. package/src/core/create-core-server.ts +8 -2
  179. package/src/core/create-core-services.ts +63 -24
  180. package/src/core/lifecycle.ts +34 -12
  181. package/src/modules/access/__tests__/access-control.atref-cache.test.ts +443 -25
  182. package/src/modules/access/__tests__/access-control.group-read.test.ts +73 -0
  183. package/src/modules/access/__tests__/access.routes.group-grant.test.ts +66 -0
  184. package/src/modules/access/__tests__/access.routes.unknown-email.test.ts +2 -0
  185. package/src/modules/access/access-control.interface.ts +22 -0
  186. package/src/modules/access/access-control.service.ts +302 -58
  187. package/src/modules/access/access.routes.ts +22 -6
  188. package/src/modules/agent-instructions/shared-file-rules.ts +1 -1
  189. package/src/modules/auth/index.ts +29 -0
  190. package/src/modules/diff/__tests__/diff.service.seed-atomicity.test.ts +2 -1
  191. package/src/modules/diff/__tests__/diff.service.test.ts +2 -1
  192. package/src/modules/diff/__tests__/diff.service.worktree.test.ts +76 -0
  193. package/src/modules/diff/diff.service.ts +6 -4
  194. package/src/modules/embed/__tests__/embed.service.test.ts +76 -3
  195. package/src/modules/embed/__tests__/embed.tools.test.ts +29 -0
  196. package/src/modules/embed/embed.interface.ts +24 -0
  197. package/src/modules/embed/embed.service.ts +44 -19
  198. package/src/modules/embed/embed.tools.ts +10 -2
  199. package/src/modules/kb-fs/__tests__/git-dir.test.ts +77 -0
  200. package/src/modules/kb-fs/__tests__/ref-files.test.ts +338 -0
  201. package/src/modules/kb-fs/git-dir.ts +53 -0
  202. package/src/modules/kb-fs/index.ts +8 -0
  203. package/src/modules/kb-fs/ref-files.ts +279 -0
  204. package/src/modules/plugins/__tests__/join-request-jobs.service.test.ts +73 -6
  205. package/src/modules/plugins/__tests__/teams.routes.test.ts +55 -0
  206. package/src/modules/plugins/join-request-jobs.service.ts +45 -8
  207. package/src/modules/plugins/teams.routes.ts +14 -1
  208. package/src/modules/settings/__tests__/setup.routes.layout-phase.test.ts +2 -2
  209. package/src/modules/settings/__tests__/setup.routes.managed-phase.test.ts +2 -2
  210. package/src/modules/skills/__tests__/branch-skills.tools.test.ts +1 -1
  211. package/src/modules/skills/skills.contract.ts +4 -0
  212. package/src/modules/skills/skills.tools.ts +120 -10
  213. package/src/modules/tool-auth/internal-token.service.ts +1 -1
  214. package/src/modules/tool-auth/tool-auth.middleware.ts +1 -2
  215. package/src/modules/tool-helpers/__tests__/branch-declaration.test.ts +2 -2
  216. package/src/modules/tool-helpers/__tests__/can-write.tools.test.ts +581 -0
  217. package/src/modules/tool-helpers/default-branch-write.ts +90 -0
  218. package/src/modules/tool-helpers/index.ts +5 -0
  219. package/src/modules/tool-helpers/tool-handler.ts +2 -2
  220. package/src/modules/tool-helpers/tool.contract.ts +2 -2
  221. package/src/modules/tool-registry/__tests__/tool-description-length.test.ts +42 -3
  222. package/src/modules/workflow/__tests__/fake-file-lock-db.ts +13 -2
  223. package/src/modules/workflow/__tests__/workflow.routes.lock-path.test.ts +168 -1
  224. package/src/modules/workflow/__tests__/workflow.service.delete-branch.test.ts +102 -6
  225. package/src/modules/workflow/file-lock.service.ts +79 -18
  226. package/src/modules/workflow/git/__tests__/git.service.changedPathsMemo.test.ts +175 -0
  227. package/src/modules/workflow/git/__tests__/git.service.readFileAtRef.test.ts +42 -0
  228. package/src/modules/workflow/git/__tests__/git.service.worktrees.test.ts +284 -0
  229. package/src/modules/workflow/git/git.service.ts +207 -22
  230. package/src/modules/workflow/index.ts +7 -0
  231. package/src/modules/workflow/pending-commits.service.ts +4 -23
  232. package/src/modules/workflow/workflow-hooks.ts +1 -1
  233. package/src/modules/workflow/workflow.routes.ts +24 -1
  234. package/src/modules/workflow/workflow.service.ts +3 -2
  235. package/src/modules/workspace/__tests__/agent-downloads.test.ts +2 -2
  236. package/src/modules/workspace/__tests__/bounded-matcher.test.ts +213 -0
  237. package/src/modules/workspace/__tests__/git-internals.security.test.ts +22 -7
  238. package/src/modules/workspace/__tests__/kb-repository.test.ts +481 -0
  239. package/src/modules/workspace/__tests__/rest-tool-arguments.test.ts +14 -0
  240. package/src/modules/workspace/__tests__/routine-write-policy.test.ts +0 -11
  241. package/src/modules/workspace/__tests__/workspace-paths-inside-the-repo.test.ts +2 -2
  242. package/src/modules/workspace/__tests__/workspace.routes.folder-zip-access.test.ts +2 -2
  243. package/src/modules/workspace/__tests__/workspace.routes.folders.test.ts +4 -3
  244. package/src/modules/workspace/__tests__/workspace.service.any-workspace-credentials.test.ts +2 -2
  245. package/src/modules/workspace/__tests__/workspace.service.ensure-remotes-fetched.test.ts +2 -2
  246. package/src/modules/workspace/__tests__/workspace.service.forget-clone-races.test.ts +12 -5
  247. package/src/modules/workspace/__tests__/workspace.service.list-cloned.test.ts +6 -6
  248. package/src/modules/workspace/__tests__/workspace.service.move-never-overwrites.test.ts +2 -1
  249. package/src/modules/workspace/__tests__/workspace.service.read-filter.test.ts +2 -1
  250. package/src/modules/workspace/__tests__/workspace.service.replaced-repository.test.ts +2 -1
  251. package/src/modules/workspace/__tests__/workspace.service.test.ts +24 -23
  252. package/src/modules/workspace/__tests__/workspace.service.unknown-branch.test.ts +2 -1
  253. package/src/modules/workspace/__tests__/workspace.service.worktrees.test.ts +620 -0
  254. package/src/modules/workspace/__tests__/workspace.tools.agents-file.test.ts +15 -1
  255. package/src/modules/workspace/__tests__/workspace.tools.branch-errors.test.ts +71 -5
  256. package/src/modules/workspace/__tests__/workspace.tools.test.ts +174 -309
  257. package/src/modules/workspace/agent-access.gate.ts +2 -2
  258. package/src/modules/workspace/bounded-matcher.ts +328 -0
  259. package/src/modules/workspace/fetch-instant.ts +13 -0
  260. package/src/modules/workspace/index.ts +26 -0
  261. package/src/modules/workspace/kb-repository.contract.ts +124 -0
  262. package/src/modules/workspace/kb-repository.ts +844 -0
  263. package/src/modules/workspace/pattern-matcher.contract.ts +69 -0
  264. package/src/modules/workspace/routine-write-policy.ts +0 -15
  265. package/src/modules/workspace/set-aside-clone.ts +140 -0
  266. package/src/modules/workspace/startup/__tests__/kb-startup-runner.degraded.test.ts +3 -2
  267. package/src/modules/workspace/startup/__tests__/kb-startup-runner.test.ts +12 -10
  268. package/src/modules/workspace/startup/__tests__/kb-startup-runner.worktrees.test.ts +613 -0
  269. package/src/modules/workspace/startup/kb-startup-runner.ts +422 -29
  270. package/src/modules/workspace/startup/steps/__tests__/steps.test.ts +4 -3
  271. package/src/modules/workspace/workspace.service.ts +247 -169
  272. package/src/modules/workspace/workspace.tools.ts +299 -385
  273. package/src/shared/workspace-id.ts +24 -0
  274. package/src/test-setup.ts +5 -0
@@ -5,13 +5,16 @@ import { logger } from '../../../shared/logging.js';
5
5
  // callback of its own, and a module logger of the same name would be
6
6
  // shadowed exactly where it is meant to be the default.
7
7
  const startupLog = logger('kb-startup');
8
- import { workspaceIdForBranch } from '../../../shared/workspace-id.js';
8
+ import { branchForWorkspaceId, workspaceIdForBranch } from '../../../shared/workspace-id.js';
9
9
  import { git, lsRemoteHeads, stampIdentity, withTempDir } from './kb-git.js';
10
10
  import { GitRunError } from '../../../shared/git.contract.js';
11
11
  import { ClassifiedFailure, classifyGitFailure, failureOf, gitFailure, } from '../../../shared/git-failure.js';
12
12
  import { redactSecret, urlQuerySecrets } from '../../../shared/redact-secret.js';
13
13
  import { normalizeRepositoryAddress, sameRepository } from '../../kb-fs/remote-url.js';
14
- import { setAsideClone, setAsideRootFor, setAsideStamp } from '../set-aside-clone.js';
14
+ import { setAsideClone, setAsideRepository, setAsideRootFor, setAsideStamp } from '../set-aside-clone.js';
15
+ import { workingCopyShape } from '../kb-repository.js';
16
+ import { fetchInstant } from '../fetch-instant.js';
17
+ import { assertValidBranchName } from '../../kb-fs/branch-name.js';
15
18
  const UNFINISHED_CLONE_QUIET_MS = 60_000;
16
19
  /** The floor `kb-git.ts` gives a startup git command, a clone included. */
17
20
  const LONGEST_CLONE_MS = 600_000;
@@ -21,9 +24,15 @@ const LONGEST_CLONE_MS = 600_000;
21
24
  * on every one of these is being worked on by something that will not stop.
22
25
  */
23
26
  const PASSES_OVER_ONE_WORKING_COPY = 4;
24
- /** Whether `git clone` failed because its destination already held something, having written nothing. */
27
+ /**
28
+ * How recent a fetch of the repository the phase takes as current. Every
29
+ * branch it visits is a worktree of the one repository, so one fetch serves
30
+ * them all; a boot that visits 142 branches fetches once, not 142 times.
31
+ */
32
+ const STARTUP_FETCH_WINDOW_MS = 30_000;
33
+ /** Whether `git clone` or `git worktree add` failed because its destination already held something, having written nothing. */
25
34
  function refusedAnOccupiedFolder(err) {
26
- return err instanceof Error && /already exists and is not an empty directory/.test(err.message);
35
+ return err instanceof Error && /already exists and is not an empty directory|' already exists$/m.test(err.message);
27
36
  }
28
37
  /**
29
38
  * When anything under `dir` was last written, as a time in milliseconds. Now,
@@ -104,8 +113,23 @@ export function bootMaySurvive(err) {
104
113
  }
105
114
  export class KbStartupRunner {
106
115
  opts;
116
+ repository;
117
+ /**
118
+ * Branches deleted on the remote after the phase listed them: the
119
+ * repository's fetch (`--prune`) took their `origin/<branch>` away. Their
120
+ * worktrees are left as they are, and the phase commits and pushes nothing
121
+ * to them — a push would bring a deleted branch back.
122
+ */
123
+ goneFromRemote = new Set();
124
+ /**
125
+ * When this run listed the remote's branches. A fetch that started before
126
+ * it may lack a deletion the listing already saw, so only a fetch since
127
+ * counts as current for the branches the run opens (`fetchedSince`).
128
+ */
129
+ listedAt = 0;
107
130
  constructor(opts) {
108
131
  this.opts = opts;
132
+ this.repository = opts.repository;
109
133
  }
110
134
  /** The run in progress, so two invokers share one rather than racing clones. */
111
135
  inFlight = null;
@@ -283,11 +307,19 @@ export class KbStartupRunner {
283
307
  'process listings. Remove them from the URL and configure the token via the setup ' +
284
308
  'screen or GITHUB_TOKEN instead.');
285
309
  }
310
+ // What a previous run found deleted may have been pushed again since:
311
+ // each run asks afresh.
312
+ this.goneFromRemote.clear();
313
+ this.listedAt = fetchInstant();
286
314
  const heads = await this.ensureRemote();
287
315
  // AFTER the remote answered: the configured address is known to be a
288
316
  // repository we can reach, so a clone of a different one is stale, not
289
317
  // a casualty of a typo in the address.
290
318
  await this.reconcileClonesWithConfiguredRepository();
319
+ // Full clones from a release before worktrees become worktrees of the
320
+ // one repository — after the address check, so a clone of another
321
+ // repository is set aside rather than imported.
322
+ await this.convertLegacyClones();
291
323
  const ctx = this.buildContext(heads, handles);
292
324
  for (const step of this.opts.steps) {
293
325
  const started = Date.now();
@@ -338,6 +370,11 @@ export class KbStartupRunner {
338
370
  for (const h of handles.values()) {
339
371
  await this.finalize(h);
340
372
  }
373
+ // The repository's garbage collection, once per boot, with every
374
+ // branch's work landed and nothing served yet: automatic collection is
375
+ // off on the repository, where a commit in one worktree would collect
376
+ // beside every other worktree's work.
377
+ await this.repository.maintain();
341
378
  }
342
379
  catch (err) {
343
380
  // Every exit carries a scrubbed message: the port scrubs only what the
@@ -517,21 +554,35 @@ export class KbStartupRunner {
517
554
  */
518
555
  async reconcileClonesWithConfiguredRepository() {
519
556
  const configured = this.opts.kbRepoUrl();
557
+ // One folder per run, so what was set aside together stays together.
558
+ const stamp = setAsideStamp();
559
+ const setAsideRoot = setAsideRootFor(this.opts.workspacesRoot, this.opts.setAsideRoot);
560
+ // The repository first: its address is every worktree's. Even with no
561
+ // workspaces root — the repository lives outside it, and a worktree added
562
+ // later would fetch from whatever address it holds.
563
+ //
564
+ // The root is read BEFORE anything moves, and only an absent root counts
565
+ // as "no workspaces": one that cannot be read (a permission or I/O error)
566
+ // stops the phase, rather than letting the repository move without the
567
+ // worktrees it could not see.
520
568
  let entries;
521
569
  try {
522
570
  entries = await fs.readdir(this.opts.workspacesRoot, { withFileTypes: true });
523
571
  }
524
- catch {
525
- return; // no workspaces yet
572
+ catch (err) {
573
+ const code = err.code;
574
+ if (code !== 'ENOENT' && code !== 'ENOTDIR')
575
+ throw err;
576
+ entries = []; // no workspaces yet
526
577
  }
527
- // One folder per run, so what was set aside together stays together.
528
- const stamp = setAsideStamp();
529
- const setAsideRoot = setAsideRootFor(this.opts.workspacesRoot, this.opts.setAsideRoot);
578
+ await this.reconcileRepositoryWithConfigured(configured, path.join(setAsideRoot, stamp));
530
579
  for (const entry of entries) {
531
580
  if (!entry.isDirectory())
532
581
  continue;
533
582
  const repoDir = path.join(this.opts.workspacesRoot, entry.name, this.opts.kbDirName);
534
- const hasGit = await fs.access(path.join(repoDir, '.git')).then(() => true, () => false);
583
+ // A full clone only: a worktree reads the repository's address, and went
584
+ // (or stayed) with it above.
585
+ const hasGit = await fs.stat(path.join(repoDir, '.git')).then((s) => s.isDirectory(), () => false);
535
586
  if (!hasGit)
536
587
  continue;
537
588
  let origin;
@@ -565,6 +616,264 @@ export class KbStartupRunner {
565
616
  await this.announceDiscarded(entry.name);
566
617
  }
567
618
  }
619
+ /**
620
+ * The repository's half of {@link reconcileClonesWithConfiguredRepository}.
621
+ * The address is the repository's, shared by every worktree, so it is
622
+ * decided once: the same repository at a new address is re-pointed and
623
+ * kept; another repository is set aside TOGETHER WITH EVERY WORKTREE OF IT,
624
+ * into `dest` — a worktree's commits live in the repository, so neither is
625
+ * of use without the other. Nothing is deleted.
626
+ */
627
+ async reconcileRepositoryWithConfigured(configured, dest) {
628
+ // Read and probed as itself (reads, and a probe fetch into a throwaway
629
+ // ref, which git locks); the one write, the re-pointing, is made by
630
+ // `setOriginUrl` as the repository's administrator. The phase runs before
631
+ // anything is served, so nothing opens a branch between the two.
632
+ const verdict = await (async () => {
633
+ const origin = await this.repository.originUrl();
634
+ if (origin === null || sameRepository(origin, configured))
635
+ return 'keep';
636
+ const was = this.redact(normalizeRepositoryAddress(origin));
637
+ // Any commit of ours will do: a common ancestor of one is a common
638
+ // ancestor of the repository's history.
639
+ const ours = (await git(this.opts.gitRunner, this.repository.dir, [
640
+ 'for-each-ref', '--count=1', '--format=%(objectname)', 'refs/heads', 'refs/remotes/origin',
641
+ ]).catch(() => '')).trim();
642
+ if (ours !== '' && (await this.sharesHistoryWithConfigured(this.repository.dir, configured, ours))) {
643
+ await this.repository.setOriginUrl(configured);
644
+ startupLog.warn(`the knowledge-base repository was fetched from ${was}, whose history the configured repository ` +
645
+ 'holds: the same repository at a new address. Pointed at the configured address and kept, with ' +
646
+ 'every worktree and their unpushed work.');
647
+ return 'repointed';
648
+ }
649
+ return { was };
650
+ })();
651
+ if (typeof verdict === 'string')
652
+ return;
653
+ const { was } = verdict;
654
+ // Every worktree's work leaves first, and may stop the phase: see
655
+ // `beforeCloneSetAside`.
656
+ const worktreeIds = [];
657
+ for (const entry of await fs.readdir(this.opts.workspacesRoot, { withFileTypes: true }).catch(() => [])) {
658
+ if (!entry.isDirectory())
659
+ continue;
660
+ const dir = path.join(this.opts.workspacesRoot, entry.name, this.opts.kbDirName);
661
+ if (await fs.stat(path.join(dir, '.git')).then((s) => s.isFile(), () => false))
662
+ worktreeIds.push(entry.name);
663
+ }
664
+ for (const id of worktreeIds)
665
+ await this.opts.beforeCloneSetAside?.(id);
666
+ const moved = await setAsideRepository(this.repository, this.opts.workspacesRoot, this.opts.kbDirName, dest);
667
+ startupLog.warn(`the knowledge-base repository is of another repository (${was}). It and its ${moved.length} worktree(s) were ` +
668
+ `set aside together at ${dest}; nothing was deleted, and a fresh repository is made from the configured ` +
669
+ 'one. Work that was never pushed is in that folder: `git log` in a worktree there shows it.');
670
+ for (const id of moved)
671
+ await this.announceDiscarded(id);
672
+ }
673
+ /**
674
+ * Convert every full clone under the workspaces root — what a release before
675
+ * worktrees left there — into a worktree of the one repository, ONE AT A
676
+ * TIME, without losing a commit:
677
+ *
678
+ * 1. the clone's branch tip is imported into the repository as the branch's
679
+ * local ref (`git fetch <clone> +refs/heads/<b>:refs/heads/<b>`), so
680
+ * commits the remote lacks are in the repository before anything moves,
681
+ * and with it every stash entry and other local ref the clone holds,
682
+ * under `refs/kb-converted/<workspace id>/`;
683
+ * 2. the clone is moved aside, beside where it was (one rename);
684
+ * 3. the worktree is added at the imported tip, and checked to be there;
685
+ * 4. the files git ignores in the clone, in no commit, are copied into it;
686
+ * 5. the clone is renamed `.<kb>.converted`: the one step that says the
687
+ * copy is complete, taken before the worktree is used, so a clone left
688
+ * behind after it is never copied from again (it would overwrite edits
689
+ * made since); a rename that fails undoes the conversion;
690
+ * 6. only then is the clone removed. A removal that fails leaves the
691
+ * `.converted` folder, which the next boot removes and nothing reads.
692
+ *
693
+ * A clone that cannot be converted — unreadable objects, uncommitted changes
694
+ * that are not anybody's to discard, a branch it is not on — is put back
695
+ * where it was and keeps working as a clone; the branch is logged, and the
696
+ * next boot tries again. One clone never stops the phase.
697
+ *
698
+ * A conversion a crash cut short is finished or undone first: the moved
699
+ * clone's ignored files are copied and it is removed when its worktree is
700
+ * in place (its tip was imported before it moved, and the worktree was not
701
+ * used yet: the rename of step 5 comes first), and put back when it is not.
702
+ */
703
+ async convertLegacyClones() {
704
+ let entries;
705
+ try {
706
+ entries = await fs.readdir(this.opts.workspacesRoot, { withFileTypes: true });
707
+ }
708
+ catch {
709
+ return;
710
+ }
711
+ let converted = 0;
712
+ const kept = [];
713
+ for (const entry of entries) {
714
+ if (!entry.isDirectory())
715
+ continue;
716
+ const workspaceDir = path.join(this.opts.workspacesRoot, entry.name);
717
+ const repoDir = path.join(workspaceDir, this.opts.kbDirName);
718
+ const aside = path.join(workspaceDir, `.${this.opts.kbDirName}.converting`);
719
+ const convertedClone = path.join(workspaceDir, `.${this.opts.kbDirName}.converted`);
720
+ await this.removeConvertedClone(branchForWorkspaceId(entry.name), convertedClone);
721
+ try {
722
+ await this.finishInterruptedConversion(repoDir, aside, convertedClone);
723
+ }
724
+ catch (err) {
725
+ // Both folders stay as they are; the next start looks again.
726
+ kept.push(branchForWorkspaceId(entry.name));
727
+ startupLog.warn(`an interrupted conversion of the "${branchForWorkspaceId(entry.name)}" working copy could not be finished (` +
728
+ `${this.redact(err instanceof Error ? err.message : String(err))}). The moved clone is kept as it is, and the next start tries again.`);
729
+ continue;
730
+ }
731
+ const branch = branchForWorkspaceId(entry.name);
732
+ let shape;
733
+ try {
734
+ shape = await workingCopyShape(repoDir);
735
+ }
736
+ catch (err) {
737
+ // A `.git` that could not be read has shown nothing: left as it is,
738
+ // named, and looked at again by the next start.
739
+ kept.push(branch);
740
+ startupLog.warn(`the "${branch}" working copy could not be inspected for conversion (` +
741
+ `${this.redact(err instanceof Error ? err.message : String(err))}). It is left as it is, and the next start tries again.`);
742
+ continue;
743
+ }
744
+ if (shape !== 'clone')
745
+ continue;
746
+ try {
747
+ if (workspaceIdForBranch(branch) !== entry.name)
748
+ continue;
749
+ assertValidBranchName(branch);
750
+ }
751
+ catch {
752
+ continue;
753
+ }
754
+ // A clone with no commit on its branch is an unfinished one, which the
755
+ // branch's own pass sets aside: there is no tip to import.
756
+ const tip = await git(this.opts.gitRunner, repoDir, [
757
+ 'for-each-ref', '--format=%(objectname)', `refs/heads/${branch}`,
758
+ ]).catch(() => 'unreadable');
759
+ if (tip.trim() === '')
760
+ continue;
761
+ try {
762
+ await this.convertOneClone(branch, repoDir, aside, convertedClone);
763
+ converted++;
764
+ }
765
+ catch (err) {
766
+ kept.push(branch);
767
+ startupLog.warn(`the "${branch}" working copy is still a full clone: it could not be converted to a worktree (` +
768
+ `${this.redact(err instanceof Error ? err.message : String(err))}). It keeps working as a clone, ` +
769
+ 'nothing in it was changed, and the next start tries again.');
770
+ }
771
+ }
772
+ if (converted > 0 || kept.length > 0) {
773
+ startupLog.info(`converted ${converted} full clone(s) to worktrees of the knowledge-base repository` +
774
+ (kept.length > 0 ? `; ${kept.length} left as they are, to be tried again at the next start (${kept.join(', ')})` : '') +
775
+ '.');
776
+ }
777
+ }
778
+ async convertOneClone(branch, repoDir, aside, converted) {
779
+ const onBranch = (await git(this.opts.gitRunner, repoDir, ['symbolic-ref', '--quiet', '--short', 'HEAD'])).trim();
780
+ if (onBranch !== branch)
781
+ throw new Error(`it is on "${onBranch}", not on its own branch`);
782
+ // Uncommitted work is in the working tree, not in a commit the import can
783
+ // carry; the clone keeps it, and is converted once it is committed.
784
+ const dirty = (await git(this.opts.gitRunner, repoDir, ['status', '--porcelain=v1'])).trim();
785
+ if (dirty !== '')
786
+ throw new Error('it has uncommitted changes');
787
+ const tip = await this.repository.importBranch(repoDir, branch);
788
+ await this.importLocalRefs(repoDir, branch);
789
+ await fs.rename(repoDir, aside);
790
+ try {
791
+ await this.repository.addWorktree(branch, repoDir, { allowLocalOnly: true, keepLocalTip: true });
792
+ const head = (await git(this.opts.gitRunner, repoDir, ['rev-parse', 'HEAD'])).trim();
793
+ if (head !== tip)
794
+ throw new Error(`the worktree came up at ${head}, not at the clone's tip ${tip}`);
795
+ await this.copyIgnoredFiles(aside, repoDir);
796
+ await fs.rename(aside, converted);
797
+ }
798
+ catch (err) {
799
+ await this.undoConversion(repoDir, aside);
800
+ throw err;
801
+ }
802
+ await this.removeConvertedClone(branch, converted);
803
+ }
804
+ /**
805
+ * The clone's stash and its other local refs, into the repository before
806
+ * the clone goes (see `KbRepository.importLocalRefs`), and said where.
807
+ */
808
+ async importLocalRefs(cloneDir, branch) {
809
+ const kept = await this.repository.importLocalRefs(cloneDir, branch, workspaceIdForBranch(branch));
810
+ if (kept.length === 0)
811
+ return;
812
+ startupLog.info(`the "${branch}" clone's stash and other local refs are kept in the knowledge-base repository (${kept.length}): ` +
813
+ `${kept.join(', ')}. \`git log <ref>\` in its worktree shows each.`);
814
+ }
815
+ /** Undone: the worktree goes, the clone comes back exactly as it was. */
816
+ async undoConversion(repoDir, aside) {
817
+ await fs.rm(repoDir, { recursive: true, force: true }).catch(() => undefined);
818
+ await this.repository.prune().catch(() => undefined);
819
+ await fs.rename(aside, repoDir);
820
+ }
821
+ /**
822
+ * Remove a converted clone: its commits and ignored files are in the
823
+ * worktree already. One that cannot be removed is said and left; the next
824
+ * boot tries again, and nothing ever copies from it.
825
+ */
826
+ async removeConvertedClone(branch, converted) {
827
+ await fs.rm(converted, { recursive: true, force: true }).catch((err) => {
828
+ startupLog.warn(`the converted clone of "${branch}" could not be removed (` +
829
+ `${this.redact(err instanceof Error ? err.message : String(err))}). Nothing reads it; the next start tries again.`);
830
+ });
831
+ }
832
+ /**
833
+ * Copy the files git ignores in the clone at `from` into the worktree at
834
+ * `to`: they are in no commit the import carried, yet they are the
835
+ * clone's. Copied, not moved, so an undo puts the clone back whole; and
836
+ * over whatever is there, so a copy a crash cut short (a partial file) is
837
+ * made whole again before the clone goes.
838
+ */
839
+ async copyIgnoredFiles(from, to) {
840
+ const ignored = (await git(this.opts.gitRunner, from, ['ls-files', '-z', '--others', '--ignored', '--exclude-standard', '--directory']))
841
+ .split('\0')
842
+ .map((rel) => rel.replace(/\/$/, ''))
843
+ .filter(Boolean);
844
+ for (const rel of ignored) {
845
+ await fs.mkdir(path.dirname(path.join(to, rel)), { recursive: true });
846
+ await fs.cp(path.join(from, rel), path.join(to, rel), { recursive: true, force: true, verbatimSymlinks: true });
847
+ }
848
+ }
849
+ /** See {@link convertLegacyClones}: a conversion a crash cut short. */
850
+ async finishInterruptedConversion(repoDir, aside, converted) {
851
+ if (!(await fs.access(aside).then(() => true, () => false)))
852
+ return;
853
+ // Only a checkout shown to be no worktree is removed: a link that could
854
+ // not be read has shown nothing, and the error leaves both folders alone.
855
+ if ((await workingCopyShape(repoDir)) === 'worktree') {
856
+ // Not used since it was added: the clone is still `.converting`, so
857
+ // the copy is finished (over a partial one) as the conversion would
858
+ // have, and undone when it cannot be.
859
+ try {
860
+ // Imported before the clone moved; again, in case that was by a
861
+ // release that imported its branch only.
862
+ await this.importLocalRefs(aside, branchForWorkspaceId(path.basename(path.dirname(repoDir))));
863
+ await this.copyIgnoredFiles(aside, repoDir);
864
+ await fs.rename(aside, converted);
865
+ }
866
+ catch (err) {
867
+ await this.undoConversion(repoDir, aside);
868
+ throw err;
869
+ }
870
+ await this.removeConvertedClone(branchForWorkspaceId(path.basename(path.dirname(repoDir))), converted);
871
+ return;
872
+ }
873
+ await fs.rm(repoDir, { recursive: true, force: true });
874
+ await this.repository.prune().catch(() => undefined);
875
+ await fs.rename(aside, repoDir);
876
+ }
568
877
  /**
569
878
  * Whether the configured repository holds history this clone has too —
570
879
  * asked of git, not guessed from the two addresses. The configured
@@ -576,7 +885,7 @@ export class KbStartupRunner {
576
885
  * a wrong true would push a stranger's commits into the configured
577
886
  * repository.
578
887
  */
579
- async sharesHistoryWithConfigured(repoDir, configured) {
888
+ async sharesHistoryWithConfigured(repoDir, configured, ours = 'HEAD') {
580
889
  const probe = 'refs/hexis-probe';
581
890
  let theirs = [];
582
891
  try {
@@ -590,7 +899,7 @@ export class KbStartupRunner {
590
899
  // One question for all of them: a common ancestor of HEAD and ANY of
591
900
  // their branches is a common ancestor of HEAD and their merge. Capped,
592
901
  // so a remote with thousands of branches cannot outgrow a command line.
593
- await git(this.opts.gitRunner, repoDir, ['merge-base', 'HEAD', ...theirs.slice(0, 200)]);
902
+ await git(this.opts.gitRunner, repoDir, ['merge-base', ours, ...theirs.slice(0, 200)]);
594
903
  return true;
595
904
  }
596
905
  catch {
@@ -644,9 +953,13 @@ export class KbStartupRunner {
644
953
  const workspaceDir = path.join(this.opts.workspacesRoot, workspaceIdForBranch(branch));
645
954
  const repoDir = path.join(workspaceDir, this.opts.kbDirName);
646
955
  for (let pass = 1;; pass++) {
647
- const hasGit = await fs.access(path.join(repoDir, '.git')).then(() => true, () => false);
648
- if (hasGit && (await this.hasCommit(repoDir)))
649
- return this.updateClone(branch, repoDir);
956
+ // A worktree whose link to the repository is broken has a `.git` and is
957
+ // no working copy: it is set aside as an unfinished clone is, below.
958
+ const shape = await workingCopyShape(repoDir);
959
+ const hasGit = shape !== 'none';
960
+ if ((shape === 'worktree' || shape === 'clone') && (await this.hasCommit(repoDir))) {
961
+ return this.updateClone(branch, repoDir, shape === 'worktree');
962
+ }
650
963
  if (pass > PASSES_OVER_ONE_WORKING_COPY) {
651
964
  throw new Error('it kept changing while this start was preparing it; something else is working on it');
652
965
  }
@@ -709,8 +1022,10 @@ export class KbStartupRunner {
709
1022
  await this.announceDiscarded(id);
710
1023
  }
711
1024
  async isUnfinishedClone(repoDir) {
712
- const hasGit = await fs.access(path.join(repoDir, '.git')).then(() => true, () => false);
713
- return hasGit && !(await this.hasCommit(repoDir));
1025
+ const shape = await workingCopyShape(repoDir);
1026
+ if (shape === 'broken')
1027
+ return true;
1028
+ return shape !== 'none' && !(await this.hasCommit(repoDir));
714
1029
  }
715
1030
  /**
716
1031
  * Wait until nothing under the unfinished clone's `.git` has been written
@@ -729,7 +1044,11 @@ export class KbStartupRunner {
729
1044
  for (;;) {
730
1045
  if (!(await this.isUnfinishedClone(repoDir)))
731
1046
  return false;
732
- const lastWrite = await newestChangeUnder(path.join(repoDir, '.git'));
1047
+ // A clone writes under its `.git` folder; a worktree being added
1048
+ // writes its checkout, its `.git` being a file.
1049
+ const dotGit = path.join(repoDir, '.git');
1050
+ const watched = (await fs.stat(dotGit).then((s) => s.isDirectory(), () => false)) ? dotGit : repoDir;
1051
+ const lastWrite = await newestChangeUnder(watched);
733
1052
  const quietFor = Date.now() - lastWrite;
734
1053
  if (quietFor >= quietMs)
735
1054
  return true;
@@ -763,16 +1082,21 @@ export class KbStartupRunner {
763
1082
  }
764
1083
  async cloneFresh(branch, workspaceDir, repoDir) {
765
1084
  await fs.mkdir(workspaceDir, { recursive: true });
766
- // Only what cannot be a clone is cleared out of the way: a folder that
767
- // gained a `.git` since the caller looked is somebody's clone, and git
1085
+ // Only what cannot be a working copy is cleared out of the way: a folder
1086
+ // that gained a `.git` since the caller looked is somebody's, and git
768
1087
  // refuses it below rather than this removing it.
769
1088
  if (!(await fs.access(path.join(repoDir, '.git')).then(() => true, () => false))) {
770
1089
  await fs.rm(repoDir, { recursive: true, force: true });
771
1090
  }
772
- await git(this.opts.gitRunner, workspaceDir, ['clone', '-b', branch, this.opts.kbRepoUrl(), repoDir]);
773
- // Said as soon as the clone is there, before its configuration: if a
774
- // command below fails, the clone stays on disk, and the retry finds it
775
- // and never comes back through here.
1091
+ // A worktree of the one repository, which is made (and fetched) by the
1092
+ // first branch that needs it: no history is copied per branch. The
1093
+ // repository carries the identity, the long-paths setting and the
1094
+ // credential helper for every worktree.
1095
+ // The phase opens every branch: the repository's one fetch in the window
1096
+ // brought them all, so each add reads it instead of asking the remote
1097
+ // for its branch again. A fetch that failed leaves the add to ask.
1098
+ await this.repository.fetch(this.fetchWindow());
1099
+ await this.repository.addWorktree(branch, repoDir, { fetchedSince: this.fetchedSince() });
776
1100
  try {
777
1101
  this.opts.onCloneCreated?.(workspaceIdForBranch(branch));
778
1102
  }
@@ -781,12 +1105,42 @@ export class KbStartupRunner {
781
1105
  detail: this.redact(err instanceof Error ? err.message : String(err)),
782
1106
  });
783
1107
  }
784
- await git(this.opts.gitRunner, repoDir, ['config', 'core.longpaths', 'true']);
785
- await stampIdentity(this.opts.gitRunner, repoDir);
786
1108
  return repoDir;
787
1109
  }
788
- async updateClone(branch, repoDir) {
789
- await git(this.opts.gitRunner, repoDir, ['fetch', 'origin', branch]);
1110
+ /**
1111
+ * The earliest a fetch of the repository may have started and still count
1112
+ * for this run: never before its listing, never older than the window. An
1113
+ * instant, so the repository compares it when it gets the reservation and
1114
+ * time spent waiting for it does not age the fetch out.
1115
+ */
1116
+ fetchedSince() {
1117
+ return Math.max(this.listedAt, Date.now() - STARTUP_FETCH_WINDOW_MS);
1118
+ }
1119
+ fetchWindow() {
1120
+ return { maxAgeMs: STARTUP_FETCH_WINDOW_MS, fetchedSince: this.fetchedSince() };
1121
+ }
1122
+ async updateClone(branch, repoDir, worktree = false) {
1123
+ if (worktree) {
1124
+ // ONE fetch for every worktree the phase visits, within the window: the
1125
+ // repository's. The phase is fail-closed, so a failed fetch stops it
1126
+ // with git's words, as the clone's own fetch did.
1127
+ if (!(await this.repository.fetch(this.fetchWindow()))) {
1128
+ const raw = `git fetch origin failed: ${String(this.repository.lastFetchFailure() ?? 'unknown failure')}`;
1129
+ throw new ClassifiedFailure(this.redact(raw), classifyGitFailure(raw));
1130
+ }
1131
+ // The branch was listed before the fetch and deleted on the remote
1132
+ // since: nothing to move the worktree to. Its commits stay where they
1133
+ // are, reachable from its branch, as any other branch's do.
1134
+ if (!(await this.hasRemoteBranch(repoDir, branch))) {
1135
+ this.goneFromRemote.add(branch);
1136
+ startupLog.warn(`branch "${branch}" was deleted on the remote during this start: its working copy is left as it is`);
1137
+ return repoDir;
1138
+ }
1139
+ this.goneFromRemote.delete(branch);
1140
+ }
1141
+ else {
1142
+ await git(this.opts.gitRunner, repoDir, ['fetch', 'origin', branch]);
1143
+ }
790
1144
  const local = (await git(this.opts.gitRunner, repoDir, ['rev-parse', 'HEAD'])).trim();
791
1145
  const remote = (await git(this.opts.gitRunner, repoDir, ['rev-parse', `origin/${branch}`])).trim();
792
1146
  if (local !== remote) {
@@ -798,6 +1152,23 @@ export class KbStartupRunner {
798
1152
  }
799
1153
  return repoDir;
800
1154
  }
1155
+ /**
1156
+ * Whether the repository's remote-tracking ref for `branch` exists. False
1157
+ * only when git answered that it does not (`--verify --quiet`, exit 1);
1158
+ * any other failure is thrown.
1159
+ */
1160
+ async hasRemoteBranch(repoDir, branch) {
1161
+ try {
1162
+ await git(this.opts.gitRunner, repoDir, ['rev-parse', '--quiet', '--verify', `refs/remotes/origin/${branch}^{commit}`]);
1163
+ return true;
1164
+ }
1165
+ catch (err) {
1166
+ const ran = err instanceof Error ? err.cause : undefined;
1167
+ if (ran instanceof GitRunError && !ran.timedOut && ran.exitCode === 1)
1168
+ return false;
1169
+ throw err;
1170
+ }
1171
+ }
801
1172
  /**
802
1173
  * Whether the repository at `repoDir` has a commit checked out. False for a
803
1174
  * clone that was cut short.
@@ -825,8 +1196,15 @@ export class KbStartupRunner {
825
1196
  async finalize(h) {
826
1197
  if (!h.dirty)
827
1198
  return;
1199
+ if (this.goneFromRemote.has(h.name)) {
1200
+ startupLog.warn(`${h.name}: deleted on the remote during this start; the phase's changes are not committed or pushed`);
1201
+ return;
1202
+ }
828
1203
  const repoDir = await h.repoDir();
829
- await stampIdentity(this.opts.gitRunner, repoDir);
1204
+ // A worktree's identity is the repository's, stamped when it was made; a
1205
+ // config write from here would contend with every other worktree's.
1206
+ if ((await workingCopyShape(repoDir)) !== 'worktree')
1207
+ await stampIdentity(this.opts.gitRunner, repoDir);
830
1208
  // Drop any PRE-EXISTING index state first (a crashed tool may have left
831
1209
  // edits staged): `git commit` publishes the whole index, and the phase
832
1210
  // must commit exactly its own staged set. The edits stay in the working