rvlib-shipkit 0.38.1 → 1.0.1

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 (255) hide show
  1. package/CHANGELOG.md +379 -16
  2. package/README.md +26 -14
  3. package/package.json +2 -2
  4. package/src/assets/gen/comfyts-gen.ts +27 -10
  5. package/src/assets/gen/derived.ts +1 -1
  6. package/src/assets/gen/generate-roll.ts +1 -1
  7. package/src/assets/kit-config.ts +6 -6
  8. package/src/assets/run-assets.ts +14 -10
  9. package/src/assets/server/studio-server.ts +6 -5
  10. package/src/check.ts +343 -0
  11. package/src/{rules/SK001-package-json-shape.ts → checks/SK001-package-json-shape.check.ts} +44 -46
  12. package/src/checks/SK002-contracts-fulfillness.check.ts +24 -0
  13. package/src/checks/SK003-claude-md-shape.check.ts +66 -0
  14. package/src/checks/SK004-gitignore-policy.check.ts +309 -0
  15. package/src/{rules/SK005-rvlib-versions.ts → checks/SK005-rvlib-versions.check.ts} +49 -32
  16. package/src/{rules/SK006-npm-publish-safety.ts → checks/SK006-npm-publish-safety.check.ts} +25 -24
  17. package/src/{rules/SK007-github-visibility.ts → checks/SK007-github-visibility.check.ts} +20 -14
  18. package/src/{rules/SK008-reviewer-agent.ts → checks/SK008-reviewer-agent.check.ts} +29 -14
  19. package/src/{rules/SK009-commit-ai-tells.ts → checks/SK009-commit-ai-tells.check.ts} +24 -21
  20. package/src/checks/SK010-undeclared-unit.check.ts +111 -0
  21. package/src/{rules/SK011-sibling-unit-ranges.ts → checks/SK011-sibling-unit-ranges.check.ts} +21 -20
  22. package/src/{rules/SK012-config-barrel-import.ts → checks/SK012-config-barrel-import.check.ts} +18 -23
  23. package/src/{rules/SK013-one-way-in-policy.ts → checks/SK013-one-way-in-policy.check.ts} +58 -50
  24. package/src/{rules/SK014-prose-ste.ts → checks/SK014-prose-ste.check.ts} +27 -13
  25. package/src/{rules/SK015-nested-repo.ts → checks/SK015-nested-repo.check.ts} +15 -12
  26. package/src/checks/SK016-legacy-check-shapes.check.ts +182 -0
  27. package/src/checks/SK017-vscode-hub-task.check.ts +197 -0
  28. package/src/checks/SK018-test-files-run.check.ts +28 -0
  29. package/src/checks/SK019-tests-run-in-units.check.ts +76 -0
  30. package/src/checks/SK020-self-spawning-check.check.ts +147 -0
  31. package/src/checks/contracts.check.ts +20 -0
  32. package/src/cli/cmd_Asks.ts +3 -2
  33. package/src/cli/cmd_Assets.ts +2 -2
  34. package/src/cli/cmd_Bump.ts +1 -1
  35. package/src/cli/cmd_Check.ts +6 -6
  36. package/src/cli/cmd_Checks.ts +95 -0
  37. package/src/cli/cmd_Ci.ts +48 -13
  38. package/src/cli/cmd_Commands.ts +2 -6
  39. package/src/cli/cmd_ConfigKeys.ts +69 -0
  40. package/src/cli/cmd_Contracts.ts +1 -1
  41. package/src/cli/cmd_Fleet.ts +16 -3
  42. package/src/cli/cmd_Hooks.ts +1 -1
  43. package/src/cli/cmd_Keys.ts +43 -18
  44. package/src/cli/cmd_Mv.ts +2 -2
  45. package/src/cli/cmd_Prose.ts +10 -1
  46. package/src/cli/cmd_Releases.ts +2 -2
  47. package/src/cli/cmd_Serve.ts +1 -1
  48. package/src/cli/cmd_Start.ts +6 -6
  49. package/src/cli/cmd_Status.ts +25 -0
  50. package/src/cli/cmd_Tui.ts +2 -2
  51. package/src/cli.ts +7 -4
  52. package/src/collections/actions.ts +4 -4
  53. package/src/collections/checks.ts +257 -0
  54. package/src/collections/collection-types.ts +254 -1
  55. package/src/collections/comfy.ts +23 -0
  56. package/src/collections/md-body.ts +14 -0
  57. package/src/collections/pings.ts +38 -12
  58. package/src/collections/posts.ts +19 -3
  59. package/src/collections/providers.ts +198 -91
  60. package/src/collections/reflections.ts +41 -0
  61. package/src/collections/state-rules.ts +73 -0
  62. package/src/collections/test-time.ts +288 -0
  63. package/src/collections/tests.ts +181 -0
  64. package/src/collections/wikis.ts +66 -0
  65. package/src/comfy/comfy-panel.ts +292 -0
  66. package/src/contracts/apply-contracts.ts +17 -12
  67. package/src/contracts/contract-types.ts +16 -4
  68. package/src/contracts/contracts-engine.ts +78 -42
  69. package/src/contracts/manifests/discord.contract.ts +6 -6
  70. package/src/contracts/manifests/identity.contract.ts +3 -3
  71. package/src/contracts/manifests/itch.contract.ts +12 -15
  72. package/src/contracts/manifests/social.contract.ts +5 -5
  73. package/src/contracts/manifests/steam.contract.ts +16 -16
  74. package/src/contracts/run-contracts.ts +4 -3
  75. package/src/core/attach.ts +96 -0
  76. package/src/core/canonical-scripts.ts +25 -0
  77. package/src/core/check-ctx.ts +204 -14
  78. package/src/core/check-file-names.ts +112 -0
  79. package/src/core/check-pass.ts +84 -0
  80. package/src/core/check-runner.ts +796 -0
  81. package/src/core/config-keys.ts +256 -0
  82. package/src/core/config-rewrite.ts +77 -0
  83. package/src/core/discover-checks.ts +166 -90
  84. package/src/core/discover-legacy-checks.ts +26 -0
  85. package/src/core/doc-block.ts +13 -0
  86. package/src/core/floor-resolve.ts +264 -0
  87. package/src/core/floor.ts +20 -137
  88. package/src/core/git-bin.ts +24 -0
  89. package/src/core/git-root.ts +108 -0
  90. package/src/core/instance.ts +195 -0
  91. package/src/core/jobs.ts +118 -0
  92. package/src/core/layout.ts +52 -0
  93. package/src/core/legacy-shapes.ts +207 -0
  94. package/src/core/lib-root.ts +5 -0
  95. package/src/core/locked-write.ts +110 -39
  96. package/src/core/machine-commit.ts +5 -4
  97. package/src/core/migrate-layout.ts +616 -0
  98. package/src/core/motherhome.ts +2 -8
  99. package/src/core/nesting.ts +4 -12
  100. package/src/core/one-way-in-policy.ts +73 -17
  101. package/src/core/outcome-words.ts +45 -0
  102. package/src/core/package-manager.ts +88 -0
  103. package/src/core/parallel.ts +21 -0
  104. package/src/core/process-tree.ts +44 -0
  105. package/src/core/raw-access.ts +93 -0
  106. package/src/core/read-set.ts +152 -0
  107. package/src/core/run-check.ts +155 -213
  108. package/src/core/run-ci.ts +152 -189
  109. package/src/core/run-fix.ts +99 -0
  110. package/src/core/run-fixers.ts +64 -33
  111. package/src/core/self-update.ts +11 -3
  112. package/src/core/slots.ts +83 -0
  113. package/src/core/spawn-timeout.ts +34 -3
  114. package/src/core/when-block.ts +401 -0
  115. package/src/core/when.ts +135 -0
  116. package/src/core/write-status.ts +5 -2
  117. package/src/fleet/scan-fleet.ts +16 -0
  118. package/src/hooks/leak-guard.ts +20 -9
  119. package/src/hooks/one-way-in-guard.ts +13 -5
  120. package/src/hooks/root-identity.ts +2 -1
  121. package/src/hooks/run-hooks.ts +38 -26
  122. package/src/init-repo.ts +39 -23
  123. package/src/ledger/check-stats.ts +155 -0
  124. package/src/ledger/check-table.ts +179 -0
  125. package/src/ledger/db.ts +255 -0
  126. package/src/ledger/gate-checks.ts +433 -0
  127. package/src/ledger/git-key.ts +116 -77
  128. package/src/ledger/git-scan.ts +96 -35
  129. package/src/ledger/ledger.ts +116 -42
  130. package/src/ledger/live-runs.ts +111 -0
  131. package/src/ledger/live-timeline.ts +97 -0
  132. package/src/ledger/repo-view.ts +73 -0
  133. package/src/ledger/run-log.ts +448 -0
  134. package/src/ledger/run-timeline.ts +173 -0
  135. package/src/ledger/store.ts +23 -12
  136. package/src/ledger/suite-log.ts +161 -0
  137. package/src/ledger/toolchain-identity.ts +7 -1
  138. package/src/ledger/world-cache.ts +111 -0
  139. package/src/mv/mv-md.ts +16 -6
  140. package/src/problems/extract.ts +182 -0
  141. package/src/problems/problem-board.ts +37 -0
  142. package/src/problems/problem-line.ts +77 -0
  143. package/src/prose/prose-view.ts +90 -0
  144. package/src/prose/run-prose.ts +60 -19
  145. package/src/prose/ste.ts +140 -24
  146. package/src/reflections/reflections-queue.ts +81 -0
  147. package/src/release/git-facts.ts +15 -9
  148. package/src/release/run-bump.ts +6 -5
  149. package/src/release/run-release.ts +25 -11
  150. package/src/release/run-releases.ts +2 -2
  151. package/src/release/unit-gate.ts +7 -54
  152. package/src/release/unit-resolve.ts +282 -0
  153. package/src/release/units.ts +33 -217
  154. package/src/repo-config.ts +157 -88
  155. package/src/rules/rule.ts +36 -96
  156. package/src/rules/untrack-generated.ts +5 -4
  157. package/src/scaffold/files/vscode-tasks.json +10 -31
  158. package/src/scaffold/jsonc-spans.ts +113 -0
  159. package/src/scaffold/project-scaffold.ts +16 -43
  160. package/src/scan.ts +98 -0
  161. package/src/serve/gate-trigger.ts +148 -0
  162. package/src/serve/health-schedule.ts +102 -0
  163. package/src/serve/hub-app/chart-view.ts +179 -0
  164. package/src/serve/hub-app/check-view.tsx +345 -0
  165. package/src/serve/hub-app/check-words.ts +164 -0
  166. package/src/serve/hub-app/checks-svc.ts +280 -0
  167. package/src/serve/hub-app/clock-svc.ts +33 -0
  168. package/src/serve/hub-app/col-card.tsx +23 -39
  169. package/src/serve/hub-app/cols-svc.ts +44 -20
  170. package/src/serve/hub-app/comfy-svc.ts +171 -0
  171. package/src/serve/hub-app/comfy-view.tsx +81 -0
  172. package/src/serve/hub-app/dur.ts +41 -0
  173. package/src/serve/hub-app/hash-route.ts +121 -0
  174. package/src/serve/hub-app/header-bar.tsx +117 -0
  175. package/src/serve/hub-app/health-svc.ts +23 -28
  176. package/src/serve/hub-app/help.tsx +29 -0
  177. package/src/serve/hub-app/hub-app.tsx +122 -119
  178. package/src/serve/hub-app/hub-st.ts +115 -42
  179. package/src/serve/hub-app/hub-types.ts +117 -2
  180. package/src/serve/hub-app/hub.css +534 -192
  181. package/src/serve/hub-app/index.html +2 -0
  182. package/src/serve/hub-app/live-svc.ts +174 -0
  183. package/src/serve/hub-app/md.ts +15 -0
  184. package/src/serve/hub-app/media-svc.ts +4 -1
  185. package/src/serve/hub-app/media-view.tsx +15 -0
  186. package/src/serve/hub-app/next-run.ts +68 -0
  187. package/src/serve/hub-app/project-gutter.css +65 -0
  188. package/src/serve/hub-app/project-gutter.tsx +128 -0
  189. package/src/serve/hub-app/projects-svc.ts +226 -0
  190. package/src/serve/hub-app/prose-hits-st.ts +124 -0
  191. package/src/serve/hub-app/prose-hits-view.tsx +176 -0
  192. package/src/serve/hub-app/prose-hits.css +21 -0
  193. package/src/serve/hub-app/run-strip.ts +47 -0
  194. package/src/serve/hub-app/runs-tabs.ts +24 -0
  195. package/src/serve/hub-app/test-view.tsx +107 -0
  196. package/src/serve/hub-app/tests-svc.ts +62 -0
  197. package/src/serve/hub-app/tests-view.tsx +238 -0
  198. package/src/serve/hub-app/time-ago.tsx +34 -0
  199. package/src/serve/hub-app/timeline-chart.tsx +274 -0
  200. package/src/serve/hub-app/timeline-geometry.ts +314 -0
  201. package/src/serve/hub-app/timeline-svc.ts +62 -0
  202. package/src/serve/hub-app/timeline-view.tsx +245 -0
  203. package/src/serve/hub-app/tree-build.ts +410 -0
  204. package/src/serve/hub-app/tree-row.tsx +42 -0
  205. package/src/serve/hub-app/tree-svc.ts +174 -0
  206. package/src/serve/hub-app/wikis-view.tsx +53 -0
  207. package/src/serve/other-repos.ts +79 -0
  208. package/src/serve/project-gutter-route.ts +350 -0
  209. package/src/serve/project-gutter.ts +449 -0
  210. package/src/serve/run-serve.ts +245 -16
  211. package/src/serve/same-origin.ts +14 -0
  212. package/src/serve/serve-hub.ts +213 -18
  213. package/src/shipkit.ts +19 -12
  214. package/src/smoke/run-smoke.ts +9 -4
  215. package/src/start-repo.ts +75 -36
  216. package/src/suite/judge-suites.ts +209 -15
  217. package/src/suite/kept-timings.ts +58 -0
  218. package/src/suite/runner-shapes.ts +88 -0
  219. package/src/suite/suite-cli.ts +147 -24
  220. package/src/suite/suite-cpu-preload.ts +92 -0
  221. package/src/suite/test-units.ts +243 -0
  222. package/src/ts/config.ts +4 -69
  223. package/src/ts/jsonc.ts +69 -0
  224. package/src/ts/lex.ts +143 -0
  225. package/src/ts/rename.ts +73 -10
  226. package/src/ts/source-graph.ts +28 -8
  227. package/src/ts/tsconfig-projects.ts +6 -97
  228. package/src/ts/tsconfig-resolve.ts +108 -0
  229. package/src/tui/build-steps-panel.ts +61 -0
  230. package/src/tui/checks-panel.ts +24 -16
  231. package/src/tui/run-tui.ts +2 -2
  232. package/src/tui/tui-panel.ts +5 -4
  233. package/src/wiki/graph-colors.ts +68 -0
  234. package/src/wiki/graph-mount.ts +7 -9
  235. package/src/wiki/graph-page.ts +42 -25
  236. package/src/wiki/graph-positions.ts +105 -0
  237. package/src/wiki/run-wiki.ts +1 -1
  238. package/src/wiki/scan-md.ts +1 -1
  239. package/src/wiki/serve-graph.ts +33 -3
  240. package/src/cli/cmd_Rules.ts +0 -51
  241. package/src/cli/cmd_Units.ts +0 -82
  242. package/src/collections/graphs.ts +0 -32
  243. package/src/contracts/contracts-once.ts +0 -43
  244. package/src/core/check-suite.ts +0 -302
  245. package/src/core/repair-contract-dirs.ts +0 -136
  246. package/src/core/task-runner.ts +0 -339
  247. package/src/ledger/gate-units.ts +0 -138
  248. package/src/ledger/unit-table.ts +0 -105
  249. package/src/rules/SK002-contracts-fulfillness.ts +0 -24
  250. package/src/rules/SK003-claude-md-shape.ts +0 -70
  251. package/src/rules/SK004-gitignore-policy.ts +0 -238
  252. package/src/rules/SK010-undeclared-release-unit.ts +0 -127
  253. package/src/rules/rule-registry.ts +0 -64
  254. package/src/rules/run-fix.ts +0 -72
  255. package/src/tui/tasks-panel.ts +0 -60
@@ -0,0 +1,616 @@
1
+ // layout-sweep: skip
2
+ // Carry a repo from the old root layout to `.shipkit/` (core/layout.ts), and
3
+ // repair a contract folder typed under a wrong name, in ONE pass and ONE commit.
4
+ //
5
+ // Nothing here is a decision, so no human is asked: `.rv-<x>/` became
6
+ // `.shipkit/<x>/`, `asks/`, `hooks/` and the `tmp/` scratch moved in, the
7
+ // generated files moved out of the root. A typo (`rv-reflections`, `.rv-reflection`) is the same
8
+ // move, because a folder under a name no scanner reads is silence.
9
+ //
10
+ // The invariant a reader breaks first: A MOVE NEVER CHANGES WHAT GIT TRACKS.
11
+ // A tracked file stays tracked under its new name (added with -f, because
12
+ // `.shipkit/*` is ignored and only some children are re-included), an
13
+ // untracked file moves on disk and stays untracked. The one exception is a
14
+ // typo folder, whose files git only tracked because the missing dot kept the
15
+ // ignore rule from matching: those leave the index when the target is ignored.
16
+ //
17
+ // A file carrying the SWEEP_SKIP marker is never rewritten: it names the old
18
+ // layout on purpose (this module, its tests, the legacy lines of SK004).
19
+ //
20
+ // Refusals, every one retried by the next run and printed by the caller:
21
+ // a target that already holds files (merging decides which file wins), a
22
+ // tracked path to move or rewrite that carries uncommitted edits (the commit
23
+ // would take them along), a merge or rebase in progress, a nested repo.
24
+ import { accessSync, constants, existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, readlinkSync, renameSync, rmSync, statSync, symlinkSync, writeFileSync } from 'fs'
25
+ import { homedir } from 'os'
26
+ import { dirname, join, posix } from 'path'
27
+ import type { RepoConfig } from '../repo-config.ts'
28
+ import { gitBin } from './git-bin.ts'
29
+ import { probeGitRoot } from './git-root.ts'
30
+ import { LAYOUT, MACHINE_PRIVATE, SHIPKIT_DIR } from './layout.ts'
31
+ import { machineCommit } from './machine-commit.ts'
32
+ import { nestedIn } from './nesting.ts'
33
+
34
+ /** the prefix every contract folder carried before `.shipkit/` */
35
+ export const LEGACY_CONTRACT_PREFIX = '.rv-'
36
+
37
+ const CONFIG_FILE = 'repo.config.ts'
38
+
39
+ /** what one pass did, in words: the folders it moved and the root files it
40
+ * dropped. Empty when it did neither, so no caller prints a sentence with a
41
+ * hole where the list should be */
42
+ export function layoutSummary(r: { moved: LayoutMove[]; removed: string[] }): string {
43
+ const list = (xs: string[]): string => (xs.length <= 1 ? xs.join('') : `${xs.slice(0, -1).join(', ')} and ${xs.at(-1)}`)
44
+ const folders = r.moved.filter((m) => m.kind !== 'cache').map((m) => `${m.from}/`)
45
+ const parts = [
46
+ ...(folders.length > 0 ? [`move ${list(folders)} under ${SHIPKIT_DIR}/`] : []),
47
+ ...(r.removed.length > 0 ? [`drop the root ${list(r.removed)}, which shipkit writes under ${SHIPKIT_DIR}/`] : []),
48
+ ]
49
+ return parts.join(', and ')
50
+ }
51
+
52
+ /** the contract names a typo is matched against */
53
+ const KNOWN = ['identity', 'assets', 'comfy', 'private', 'reflections', 'social', 'journal', 'domain', 'itch', 'steam', 'discord'] as const
54
+
55
+ /** root files shipkit used to generate on every run */
56
+ export const LEGACY_GENERATED = ['STATUS.md', 'STATUS.md.lock', 'STATUS.full.md', 'STATUS.full.md.lock'] as const
57
+
58
+ /** the root scratch folder, `.shipkit/tmp/` now */
59
+ export const LEGACY_TMP = 'tmp'
60
+
61
+ const LEGACY_CACHE = `${LEGACY_TMP}/.shipkit`
62
+
63
+ /** a lease a killed run left in the old scratch: never worth a refusal, the
64
+ * one at the target is the live one */
65
+ const STALE_LEASE = /^shipkit-[^/]+\.lock$/
66
+
67
+ /** what the old policy block kept out of git, rendered by SK004 while a repo
68
+ * still holds `.rv-*` folders so none of them is exposed before it moves */
69
+ export const LEGACY_VENUES = ['.rv-itch/', '.rv-steam/', '.rv-discord/']
70
+ export const LEGACY_IGNORED = ['.rv-private/', '.rv-reflections/', '.rv-social/', '.rv-journal/', '.rv-domain/', '.rv-assets/out/', ...LEGACY_VENUES]
71
+
72
+ /** the shape of a name, ignoring the leading dots, the separators, the case and
73
+ * a trailing plural. `rv-reflections`, `rv_reflection` and `RV-Reflections`
74
+ * are the same intent, `reflections` and `docs` are not */
75
+ const shapeOf = (s: string): string => s.toLowerCase().replace(/^\.+/, '').replace(/[-_.]/g, '').replace(/s$/, '')
76
+
77
+ const LEGACY_NAME = /^\.rv-([a-z0-9][a-z0-9-]*)$/
78
+
79
+ export type MoveKind = 'contract' | 'typo' | 'asks' | 'hooks' | 'cache' | 'tmp'
80
+ /** a move onto a folder that already exists, whose files collide nowhere.
81
+ * `drop`: stale leases left behind rather than moved over the live ones */
82
+ export type MergeMove = LayoutMove & { merge: true; drop?: string[] }
83
+ export type LayoutMove = { from: string; to: string; kind: MoveKind }
84
+ export type LayoutRefusal = { from: string; to: string; reason: string }
85
+ export type LayoutPlan = { moves: LayoutMove[]; refusals: LayoutRefusal[] }
86
+
87
+ export type PlanInput = {
88
+ /** directory names at the repo root */
89
+ rootDirs: string[]
90
+ /** names already under `.shipkit/` */
91
+ shipkitEntries?: string[]
92
+ /** repo.config sets `asks.dir`, so the queue lives where the repo said */
93
+ asksDirSet?: boolean
94
+ /** core.hooksPath points at `hooks/` and its pre-commit is shipkit's */
95
+ hooksOurs?: boolean
96
+ /** `tmp/.shipkit` exists */
97
+ legacyCache?: boolean
98
+ /** a clone or a worktree sits somewhere under `tmp/` */
99
+ tmpHoldsRepo?: boolean
100
+ }
101
+
102
+ /** a `.git` anywhere under a folder: a clone or a linked worktree, whose
103
+ * registration names its path, so moving it breaks it */
104
+ export function holdsRepo(dir: string): boolean {
105
+ try {
106
+ for (const e of readdirSync(dir, { withFileTypes: true })) {
107
+ if (e.name === '.git') return true
108
+ if (e.isDirectory() && e.name !== 'node_modules' && holdsRepo(join(dir, e.name))) return true
109
+ }
110
+ } catch { /* unreadable: the move itself reports it */ }
111
+ return false
112
+ }
113
+
114
+ /** PURE: what to move, given what sits in a repo root */
115
+ export function planLayoutMigration(i: PlanInput): LayoutPlan {
116
+ const taken = new Set((i.shipkitEntries ?? []).map((e) => `${SHIPKIT_DIR}/${e}`))
117
+ const moves: LayoutMove[] = []
118
+ const refusals: LayoutRefusal[] = []
119
+ const add = (from: string, to: string, kind: MoveKind): void => {
120
+ const twin = moves.find((m) => m.to === to)
121
+ if (taken.has(to)) {
122
+ refusals.push({ from, to, reason: `${twin != null ? `${twin.from}/ moves there too` : 'the target already exists'}, and merging them decides which file wins` })
123
+ return
124
+ }
125
+ taken.add(to)
126
+ moves.push({ from, to, kind })
127
+ }
128
+ // exact legacy names first, so a typo beside its correct twin is the one refused
129
+ const dirs = [...i.rootDirs].sort((a, b) => Number(!LEGACY_NAME.test(a)) - Number(!LEGACY_NAME.test(b)) || a.localeCompare(b))
130
+ for (const d of dirs) {
131
+ const m = LEGACY_NAME.exec(d)
132
+ // a legacy name one plural or separator off a known one lands on the known one
133
+ if (m != null) { add(d, `${SHIPKIT_DIR}/${KNOWN.find((k) => shapeOf(k) === shapeOf(m[1]!)) ?? m[1]}`, 'contract'); continue }
134
+ if (d === SHIPKIT_DIR) continue
135
+ const typo = KNOWN.find((k) => shapeOf(d) === shapeOf(`rv${k}`))
136
+ if (typo != null) add(d, `${SHIPKIT_DIR}/${typo}`, 'typo')
137
+ }
138
+ if (i.rootDirs.includes('asks') && i.asksDirSet !== true) add('asks', LAYOUT.asks, 'asks')
139
+ if (i.rootDirs.includes('hooks') && i.hooksOurs === true) add('hooks', LAYOUT.hooks, 'hooks')
140
+ // a cache is never worth a refusal: a warm one at the target wins. It
141
+ // leaves the scratch before the scratch itself moves
142
+ if (i.legacyCache === true) {
143
+ if (taken.has(LAYOUT.cache)) moves.push({ from: LEGACY_CACHE, to: LAYOUT.cache, kind: 'cache' })
144
+ else add(LEGACY_CACHE, LAYOUT.cache, 'cache')
145
+ }
146
+ // every repo's scratch, shipkit's own logs and the repo's plans alike
147
+ if (i.rootDirs.includes(LEGACY_TMP)) {
148
+ if (i.tmpHoldsRepo === true) refusals.push({ from: LEGACY_TMP, to: LAYOUT.tmp, reason: 'a git checkout sits under it, and a moved worktree loses its registration' })
149
+ else add(LEGACY_TMP, LAYOUT.tmp, 'tmp')
150
+ }
151
+ return { moves, refusals }
152
+ }
153
+
154
+ /** the text rewrite a contract move implies: `.rv-<x>` → its target
155
+ * (`.shipkit/<x>` unless `targets` says otherwise), never a machine-global
156
+ * `~/.rv-<x>`, never a longer name that merely starts the same.
157
+ *
158
+ * `at` is the file's own path before and after the move. A relative path
159
+ * (`../.rv-identity/brand.ts`) that resolved to a moved folder is recomputed
160
+ * from the file's new place, so two folders that moved together still find
161
+ * each other (`../identity/brand.ts`). */
162
+ export function sweepText(text: string, targets: Record<string, string> | 'all', at?: { from: string; to: string }): string {
163
+ const names = targets === 'all' ? null : Object.keys(targets)
164
+ if (names != null && names.length === 0) return text
165
+ const alt = names == null ? '[a-z0-9][a-z0-9-]*' : names.map((n) => n.slice(LEGACY_CONTRACT_PREFIX.length).replace(/[-]/g, '\\-')).join('|')
166
+ const re = new RegExp(`(?<!~/)(?<![\\w.-])((?:\\.\\.?/)*)\\.rv-(${alt})(?![\\w-])`, 'g')
167
+ return text.replace(re, (_m, prefix: string, x: string) => {
168
+ const legacy = `${LEGACY_CONTRACT_PREFIX}${x}`
169
+ const target = (targets === 'all' ? undefined : targets[legacy]) ?? `${SHIPKIT_DIR}/${x}`
170
+ if (prefix === '' || at == null || posix.normalize(posix.join(posix.dirname(at.from), prefix + legacy)) !== legacy) return prefix + target
171
+ return relativeFrom(at.to, target)
172
+ })
173
+ }
174
+
175
+ /** a relative specifier from one repo path to another, always starting with `./` or `../` */
176
+ const relativeFrom = (file: string, target: string): string => {
177
+ const rel = posix.relative(posix.dirname(file), target)
178
+ return rel.startsWith('../') ? rel : `./${rel}`
179
+ }
180
+
181
+ /** the same for a path built at run time from the file's own folder:
182
+ * `join(import.meta.dir, '..', 'public/x.png')` resolved to the repo root from
183
+ * `.rv-identity/`, and must climb one more level from `.shipkit/identity/`.
184
+ * Only the leading `'.'` and `'..'` arguments are rewritten */
185
+ function rebaseDirJoins(text: string, at: { from: string; to: string }, moves: LayoutMove[]): string {
186
+ return text.replace(/\b(join|resolve)\((\s*)(import\.meta\.dirname|import\.meta\.dir|__dirname)((?:\s*,\s*(['"])\.\.?\5)+)/g, (m, fn: string, sp: string, base: string, segs: string) => {
187
+ const q = /['"]/.exec(segs)![0]
188
+ const parts = [...segs.matchAll(/(['"])(\.\.?)\1/g)].map((x) => x[2]!)
189
+ const target = movedTo(moves, posix.normalize(posix.join(posix.dirname(at.from), ...parts)))
190
+ if (target.startsWith('../')) return m
191
+ const rel = posix.relative(posix.dirname(at.to), target)
192
+ const next = rel === '' ? [] : rel.split('/')
193
+ if (next.some((x) => x !== '..') || next.join('/') === parts.filter((x) => x !== '.').join('/')) return m
194
+ return `${fn}(${sp}${base}${next.map((x) => `, ${q}${x}${q}`).join('')}`
195
+ })
196
+ }
197
+
198
+ /** PURE: a file that moved keeps pointing at what it pointed at. Only what is
199
+ * a path by its SHAPE is touched: an import or require specifier, a markdown
200
+ * link, and `join(import.meta.dir, '..')`. Any other string is left alone,
201
+ * because `'../'` in `p.replace('../', '')` is not a path. Each path is
202
+ * resolved from where the file WAS, carried through the moves, and written
203
+ * from where it IS; one that still resolves the same way stays byte for byte */
204
+ export function rebaseRelative(text: string, at: { from: string; to: string }, moves: LayoutMove[]): string {
205
+ if (at.from === at.to) return text
206
+ const rebase = (spec: string): string => {
207
+ const target = movedTo(moves, posix.normalize(posix.join(posix.dirname(at.from), spec)))
208
+ if (target.startsWith('../')) return spec
209
+ return posix.normalize(posix.join(posix.dirname(at.to), spec)) === target ? spec : relativeFrom(at.to, target)
210
+ }
211
+ return rebaseDirJoins(text, at, moves)
212
+ .replace(/(\bfrom\s+|\bimport\s+|\bimport\s*\(\s*|\brequire\s*\(\s*)(['"])(\.\.?\/[^'"\s]+)\2/g, (_m, lead: string, q: string, spec: string) => `${lead}${q}${rebase(spec)}${q}`)
213
+ .replace(/(\]\()(\.\.?\/[^)\s#]+)/g, (_m, lead: string, spec: string) => `${lead}${rebase(spec)}`)
214
+ }
215
+
216
+ /** one repo.config.ts value that names a moved folder, as a path: the folder
217
+ * then a slash, behind an optional `!`, `/` or `./`. A word, a bare folder
218
+ * name and a longer name that merely starts the same stay as they are */
219
+ function rebasePathValue(v: string, moves: LayoutMove[]): string {
220
+ const m = /^(!?(?:\/|\.\/)?)(.*)$/s.exec(v)!
221
+ const rest = m[2]!
222
+ const hit = moves.find((x) => rest.startsWith(`${x.from}/`))
223
+ return hit == null ? v : `${m[1]}${hit.to}${rest.slice(hit.from.length)}`
224
+ }
225
+
226
+ /** PURE: repo.config.ts source with every quoted path that names a moved
227
+ * folder rewritten (`leakAllow` paths, `blockedPaths`, `gitignore` lines, a
228
+ * prose include, any other value): each of them is a repo path by contract */
229
+ export function sweepConfigSource(text: string, moves: LayoutMove[]): string {
230
+ if (moves.length === 0) return text
231
+ return text.replace(/(['"`])((?:\\.|(?!\1)[^\\\n])*)\1/g, (lit, q: string, body: string) => {
232
+ if (q === '`' && body.includes('${')) return lit
233
+ const next = rebasePathValue(body, moves)
234
+ return next === body ? lit : `${q}${next}${q}`
235
+ })
236
+ }
237
+
238
+ /** PURE: the loaded config with the same rewrite, so what this pass renders
239
+ * from it (the ignore block) reads the values the file now holds. Only plain
240
+ * objects and arrays are copied; functions and class instances are kept */
241
+ export function rebaseConfigPaths<T>(v: T, moves: LayoutMove[]): T {
242
+ if (typeof v === 'string') return rebasePathValue(v, moves) as T
243
+ if (Array.isArray(v)) return v.map((x) => rebaseConfigPaths(x, moves)) as T
244
+ if (v != null && typeof v === 'object' && Object.getPrototypeOf(v) === Object.prototype)
245
+ return Object.fromEntries(Object.entries(v).map(([k, x]) => [k, rebaseConfigPaths(x, moves)])) as T
246
+ return v
247
+ }
248
+
249
+ /** PURE: the .gitignore after the move. Old names turn into new ones, except
250
+ * the folders still on disk (`keep`), whose lines keep guarding them. A
251
+ * negation of one FILE inside a folder (`!.shipkit/social/README.md`) is dead
252
+ * under `.shipkit/*`, because git never looks inside an excluded folder, so
253
+ * the folder is re-included and re-ignored first, and the file comes back */
254
+ export function sweepGitignore(text: string, keep: string[] = []): string {
255
+ const out: string[] = []
256
+ const have = new Set(text.split('\n'))
257
+ for (const line of text.split('\n')) {
258
+ const kept = keep.some((k) => new RegExp(`(?<![\\w-])${k.replace(/[.]/g, '\\.')}(?![\\w-])`).test(line))
259
+ const next = kept ? line : sweepText(line, 'all')
260
+ const deep = /^!\.shipkit\/([^/]+)\/(.+)$/.exec(next)
261
+ if (!kept && next !== line && deep != null) {
262
+ for (const pre of [`!${SHIPKIT_DIR}/${deep[1]}/`, `${SHIPKIT_DIR}/${deep[1]}/*`])
263
+ if (!have.has(pre)) { out.push(pre); have.add(pre) }
264
+ }
265
+ out.push(next)
266
+ }
267
+ return out.join('\n')
268
+ }
269
+
270
+ /** the relative file paths under a folder */
271
+ const filesUnder = (dir: string, pre = ''): string[] => {
272
+ const out: string[] = []
273
+ for (const e of readdirSync(join(dir, pre), { withFileTypes: true })) {
274
+ const rel = pre === '' ? e.name : `${pre}/${e.name}`
275
+ if (e.isDirectory()) out.push(...filesUnder(dir, rel))
276
+ else out.push(rel)
277
+ }
278
+ return out
279
+ }
280
+
281
+ /** a target that already exists is the normal case in a checkout that pulled
282
+ * the move: the tracked files arrived, the untracked ones (drafts, private
283
+ * notes) stayed behind in the old folder. When no file name exists on both
284
+ * sides, merging decides nothing, so it moves. A real collision stays refused */
285
+ function mergeWhereNothingCollides(root: string, plan: LayoutPlan): LayoutPlan {
286
+ const moves: LayoutMove[] = [...plan.moves]
287
+ const refusals: LayoutRefusal[] = []
288
+ for (const r of plan.refusals) {
289
+ const from = join(root, r.from)
290
+ const to = join(root, r.to)
291
+ const twin = moves.some((m) => m.to === r.to)
292
+ const scratch = r.from === LEGACY_TMP
293
+ let clash = true
294
+ let drop: string[] = []
295
+ try {
296
+ const both = twin || !isDir(from) || !isDir(to) ? null : filesUnder(from).filter((f) => existsSync(join(to, f)))
297
+ drop = scratch && both != null ? both.filter((f) => STALE_LEASE.test(f)) : []
298
+ clash = both == null || both.length > drop.length
299
+ } catch { clash = true }
300
+ if (clash) refusals.push(r)
301
+ else moves.push({ from: r.from, to: r.to, kind: scratch ? 'tmp' : LEGACY_NAME.test(r.from) ? 'contract' : 'typo', merge: true, ...(drop.length > 0 ? { drop } : {}) } as MergeMove)
302
+ }
303
+ return { moves, refusals }
304
+ }
305
+
306
+ /** move every entry of one folder into another that holds none of the same names */
307
+ function mergeInto(from: string, to: string): void {
308
+ for (const e of readdirSync(from, { withFileTypes: true })) {
309
+ const src = join(from, e.name)
310
+ const dst = join(to, e.name)
311
+ if (e.isDirectory() && isDir(dst)) mergeInto(src, dst)
312
+ else renameSync(src, dst)
313
+ }
314
+ rmSync(from, { recursive: true, force: true })
315
+ }
316
+
317
+ /** where a repo-relative path sat before the moves */
318
+ const movedFrom = (moves: LayoutMove[], rel: string): string => {
319
+ const m = moves.find((x) => rel === x.to || rel.startsWith(`${x.to}/`))
320
+ return m == null ? rel : m.from + rel.slice(m.to.length)
321
+ }
322
+
323
+ const canWrite = (p: string): boolean => { try { accessSync(p, constants.W_OK); return true } catch { return false } }
324
+
325
+ /** where a repo-relative path sits once the moves are made */
326
+ const movedTo = (moves: LayoutMove[], rel: string): string => {
327
+ const m = moves.find((x) => rel === x.from || rel.startsWith(`${x.from}/`))
328
+ return m == null ? rel : m.to + rel.slice(m.from.length)
329
+ }
330
+
331
+ const isChangelog = (rel: string): boolean => /(^|\/)changelog\.md$/i.test(rel)
332
+
333
+ /** a file that names the old layout on purpose carries this, and the sweep leaves it alone */
334
+ export const SWEEP_SKIP = 'layout-sweep: skip'
335
+
336
+ export type LayoutResult = {
337
+ moved: LayoutMove[]
338
+ refusals: LayoutRefusal[]
339
+ /** tracked files whose text the sweep rewrote */
340
+ swept: string[]
341
+ /** legacy generated files removed from the root */
342
+ removed: string[]
343
+ /** why nothing happened, when nothing could */
344
+ deferred?: string
345
+ /** the commit, when one landed */
346
+ commit?: string
347
+ /** moved, and the commit did not land: the dirt is this run's */
348
+ uncommitted?: true
349
+ /** the paths the commit would have carried, for the one who lands it by hand */
350
+ pending?: string[]
351
+ }
352
+
353
+ const git = (root: string, args: string[]): { code: number; out: string } => {
354
+ const r = Bun.spawnSync([gitBin(), ...args], { cwd: root })
355
+ return { code: r.exitCode, out: `${r.stdout.toString()}${r.stderr.toString()}` }
356
+ }
357
+
358
+ const dirsIn = (root: string): string[] => {
359
+ try { return readdirSync(root, { withFileTypes: true }).filter((e) => e.isDirectory()).map((e) => e.name) } catch { return [] }
360
+ }
361
+
362
+ const isDir = (p: string): boolean => { try { return statSync(p).isDirectory() } catch { return false } }
363
+
364
+ /** does core.hooksPath point at the legacy `hooks/`, holding a shipkit pre-commit */
365
+ export function legacyHooksOurs(root: string): boolean {
366
+ const r = git(root, ['config', 'core.hooksPath'])
367
+ const v = r.out.trim().replace(/\/+$/, '')
368
+ if (v !== 'hooks' && v !== join(root, 'hooks')) return false
369
+ try { return readFileSync(join(root, 'hooks', 'pre-commit'), 'utf8').includes('shipkit') } catch { return false }
370
+ }
371
+
372
+ /** a checkout that received the move through a merge, not through this pass,
373
+ * still has core.hooksPath on the vanished `hooks/`, and git runs no hook
374
+ * from a folder that is not there: the commit gates go dark in silence */
375
+ function repointStaleHooks(root: string): void {
376
+ const v = git(root, ['config', 'core.hooksPath']).out.trim().replace(/\/+$/, '')
377
+ if (v !== 'hooks' && v !== join(root, 'hooks')) return
378
+ if (existsSync(join(root, 'hooks')) || !existsSync(join(root, LAYOUT.hooks, 'pre-commit'))) return
379
+ git(root, ['config', 'core.hooksPath', LAYOUT.hooks])
380
+ }
381
+
382
+ /** the machine-global private folder moves the same way, once per machine.
383
+ * The old path stays as a symlink to the new one, so a repo still on an
384
+ * older shipkit keeps reading its keyword list. The link goes once
385
+ * `shipkit fleet` shows no repo on a version that reads it */
386
+ export function moveMachinePrivate(home: string): void {
387
+ if (home === '') return
388
+ const old = join(home, '.rv-private')
389
+ const next = join(home, MACHINE_PRIVATE)
390
+ try {
391
+ if (!lstatSync(old).isDirectory() || existsSync(next)) return
392
+ mkdirSync(dirname(next), { recursive: true })
393
+ renameSync(old, next)
394
+ symlinkSync(posix.relative(home, next), old)
395
+ } catch { /* absent, or a link already: nothing to move */ }
396
+ }
397
+
398
+ /** what the planner reads off disk and config */
399
+ export function readPlanInput(root: string, config?: RepoConfig): PlanInput {
400
+ return {
401
+ rootDirs: dirsIn(root),
402
+ shipkitEntries: isDir(join(root, SHIPKIT_DIR)) ? readdirSync(join(root, SHIPKIT_DIR)) : [],
403
+ asksDirSet: config?.asks?.dir != null,
404
+ hooksOurs: existsSync(join(root, 'hooks')) && legacyHooksOurs(root),
405
+ legacyCache: isDir(join(root, LEGACY_CACHE)),
406
+ tmpHoldsRepo: isDir(join(root, LEGACY_TMP)) && holdsRepo(join(root, LEGACY_TMP)),
407
+ }
408
+ }
409
+
410
+ /** a repo still carrying the old layout: the names a check reports */
411
+ export function legacyLayoutLeft(root: string, config?: RepoConfig): string[] {
412
+ const plan = planLayoutMigration(readPlanInput(root, config))
413
+ return [...plan.moves.filter((m) => m.kind !== 'cache').map((m) => `${m.from}/`), ...plan.refusals.map((r) => `${r.from}/`)]
414
+ }
415
+
416
+ const lsFiles = (root: string, paths: string[]): string[] => {
417
+ if (paths.length === 0) return []
418
+ const r = git(root, ['ls-files', '-z', '--', ...paths])
419
+ return r.code === 0 ? r.out.split('\0').filter((l) => l !== '') : []
420
+ }
421
+
422
+ /** tracked text files naming a legacy folder, CHANGELOG files left as history */
423
+ function sweepTargets(root: string, legacyNames: string[]): string[] {
424
+ if (legacyNames.length === 0) return []
425
+ const r = git(root, ['grep', '-lIz', '-F', ...legacyNames.flatMap((n) => ['-e', n])])
426
+ if (r.code !== 0) return []
427
+ return r.out.split('\0').filter((l) => l !== '' && !isChangelog(l) && l !== '.gitignore')
428
+ }
429
+
430
+ /** tracked paths among these with staged or unstaged edits */
431
+ function dirtyAmong(root: string, paths: string[]): string[] {
432
+ if (paths.length === 0) return []
433
+ const r = git(root, ['status', '--porcelain', '-z', '--untracked-files=no', '--', ...paths])
434
+ return r.out.split('\0').filter((l) => l.length > 3).map((l) => l.slice(3))
435
+ }
436
+
437
+ function inProgress(root: string): string | null {
438
+ const g = probeGitRoot(root).root
439
+ if (g == null) return null
440
+ for (const f of ['MERGE_HEAD', 'REBASE_HEAD', 'CHERRY_PICK_HEAD', 'rebase-merge', 'rebase-apply'])
441
+ if (existsSync(join(g.gitDir, f))) return `a ${f.toLowerCase().replace(/_head$/, '').replace(/-.*/, '')} is in progress`
442
+ if (git(root, ['symbolic-ref', '--quiet', 'HEAD']).code !== 0) return 'HEAD is detached'
443
+ return null
444
+ }
445
+
446
+ export type MigrateOpts = {
447
+ config?: RepoConfig
448
+ /** re-render the SK004 block into this .gitignore text (SK004 owns the
449
+ * block). `legacy`: an old folder is still on disk, so its name stays
450
+ * ignored. `config`: the repo config with its paths already moved, which
451
+ * is what the block must be rendered from */
452
+ renderGitignore?: (text: string, legacy: boolean, config?: RepoConfig) => string
453
+ }
454
+
455
+ /** tracked symlinks, with where each points */
456
+ function trackedLinks(root: string): { rel: string; target: string }[] {
457
+ const r = git(root, ['ls-files', '-s', '-z'])
458
+ if (r.code !== 0) return []
459
+ const out: { rel: string; target: string }[] = []
460
+ for (const row of r.out.split('\0')) {
461
+ if (!row.startsWith('120000 ')) continue
462
+ const rel = row.slice(row.indexOf('\t') + 1)
463
+ try { out.push({ rel, target: readlinkSync(join(root, rel)) }) } catch { /* gone from disk: nothing to relink */ }
464
+ }
465
+ return out
466
+ }
467
+
468
+ /**
469
+ * Apply the plan. Returns what moved, what the sweep rewrote and whether the
470
+ * commit landed. Never throws: a permission error or a race leaves the repo
471
+ * where it was, and the next run tries again.
472
+ */
473
+ export function migrateLayout(root: string, o: MigrateOpts = {}): LayoutResult {
474
+ repointStaleHooks(root)
475
+ moveMachinePrivate(homedir())
476
+ const input = readPlanInput(root, o.config)
477
+ const plan = mergeWhereNothingCollides(root, planLayoutMigration(input))
478
+ const legacyStatus = LEGACY_GENERATED.filter((f) => existsSync(join(root, f)))
479
+ const out: LayoutResult = { moved: [], refusals: plan.refusals, swept: [], removed: [] }
480
+ if (plan.moves.length === 0 && legacyStatus.length === 0) return out
481
+
482
+ const inGit = probeGitRoot(root).root != null
483
+ if (inGit) {
484
+ if (nestedIn(root) != null) return { ...out, deferred: 'this repo is a folder of a larger git repo, whose index is not shipkit\'s to rewrite' }
485
+ const busy = inProgress(root)
486
+ if (busy != null) return { ...out, deferred: busy }
487
+ }
488
+
489
+ const contractNames = plan.moves.filter((m) => m.kind === 'contract').map((m) => m.from)
490
+ const targets = Object.fromEntries(plan.moves.filter((m) => m.kind === 'contract').map((m) => [m.from, m.to]))
491
+ const sources = plan.moves.filter((m) => m.kind !== 'cache').map((m) => m.from)
492
+ const tracked = inGit ? lsFiles(root, [...sources, ...legacyStatus]) : []
493
+ // a moved file is rebased whether or not it names an old folder, and
494
+ // repo.config.ts whenever one of its values names one
495
+ const configNames = inGit && lsFiles(root, [CONFIG_FILE]).length > 0 && sweepConfigSource(readFileSync(join(root, CONFIG_FILE), 'utf8'), plan.moves) !== readFileSync(join(root, CONFIG_FILE), 'utf8') ? [CONFIG_FILE] : []
496
+ // a legacy generated file is dropped, never rewritten: a rewrite would
497
+ // write it back after the drop, and the commit would keep it
498
+ const dropped = new Set<string>(legacyStatus)
499
+ const swept = inGit ? [...new Set([...sweepTargets(root, contractNames), ...tracked.filter((t) => /\.(m?[jt]sx?|json|md)$/.test(t)), ...configNames])].filter((f) => !dropped.has(f)) : []
500
+ const links = inGit ? trackedLinks(root) : []
501
+ // commit the ignore file when git tracks it, or when this pass creates it:
502
+ // it is what keeps the moved private folders out. An existing untracked
503
+ // one is the repo's choice, and naming it in the pathspec refuses the commit
504
+ const giExisted = existsSync(join(root, '.gitignore'))
505
+ const giTracked = inGit && lsFiles(root, ['.gitignore']).length > 0
506
+ if (inGit) {
507
+ const dirty = dirtyAmong(root, [...tracked, ...swept, ...links.map((l) => l.rel), '.gitignore'])
508
+ if (dirty.length > 0) return { ...out, deferred: `${dirty.slice(0, 3).join(', ')}${dirty.length > 3 ? ` +${dirty.length - 3}` : ''} carries uncommitted edits, and the move commit would take them along` }
509
+ }
510
+
511
+ // every rewrite is computed, and every write is checked, BEFORE anything
512
+ // moves: a move that stops part way leaves no old folder for the next run
513
+ // to find, so there is no resuming it, only not starting it
514
+ const moves = plan.moves.filter((m) => m.kind !== 'cache' || !existsSync(join(root, m.to)))
515
+ const writes: { now: string; text: string }[] = []
516
+ for (const rel of swept) {
517
+ const text = readFileSync(join(root, rel), 'utf8')
518
+ if (text.includes(SWEEP_SKIP)) continue
519
+ const now = movedTo(moves, rel)
520
+ const paths = rel === CONFIG_FILE ? sweepConfigSource(text, moves) : text
521
+ const next = sweepText(rebaseRelative(paths, { from: rel, to: now }, moves), targets, { from: rel, to: now })
522
+ if (next !== text) writes.push({ now, text: next })
523
+ }
524
+ const relinks: { now: string; target: string }[] = []
525
+ for (const l of links) {
526
+ const now = movedTo(moves, l.rel)
527
+ if (l.target.startsWith('/')) continue
528
+ const was = posix.normalize(posix.join(posix.dirname(l.rel), l.target))
529
+ const target = movedTo(moves, was)
530
+ if (target === was && now === l.rel) continue
531
+ const next = posix.relative(posix.dirname(now), target)
532
+ if (next !== l.target) relinks.push({ now, target: next })
533
+ }
534
+ const unwritable = [
535
+ ...writes.map((w) => join(root, movedFrom(moves, w.now))),
536
+ ...moves.flatMap((m) => [join(root, m.from), dirname(join(root, m.from))]),
537
+ join(root, '.gitignore'),
538
+ ].filter((p) => existsSync(p) && !canWrite(p))
539
+ if (unwritable.length > 0) return { ...out, deferred: `${unwritable.slice(0, 3).map((p) => p.slice(root.length + 1) || '.').join(', ')} is not writable, so the move would stop part way` }
540
+
541
+ const gi = join(root, '.gitignore')
542
+ const giOriginal = existsSync(gi) ? readFileSync(gi, 'utf8') : ''
543
+ const config = o.config == null ? undefined : rebaseConfigPaths(o.config, moves)
544
+ const render = (text: string, legacy: boolean): string => (o.renderGitignore != null ? o.renderGitignore(text, legacy, config) : text)
545
+ const stillOld = (): string[] => dirsIn(root).filter((d) => d.startsWith(LEGACY_CONTRACT_PREFIX))
546
+ try {
547
+ // 1. both layouts ignored while any folder is in flight
548
+ const giBoth = render(sweepGitignore(giOriginal, dirsIn(root).filter((d) => d.startsWith(LEGACY_CONTRACT_PREFIX))), true)
549
+ if (giBoth !== giOriginal) writeFileSync(gi, giBoth)
550
+
551
+ // 2. the folders
552
+ mkdirSync(join(root, SHIPKIT_DIR), { recursive: true })
553
+ for (const m of plan.moves) {
554
+ const from = join(root, m.from)
555
+ const to = join(root, m.to)
556
+ if (m.kind === 'cache' && existsSync(to)) { rmSync(from, { recursive: true, force: true }); continue }
557
+ mkdirSync(dirname(to), { recursive: true })
558
+ if ('merge' in m) {
559
+ for (const f of (m as MergeMove).drop ?? []) rmSync(join(from, f), { force: true })
560
+ mergeInto(from, to)
561
+ }
562
+ else renameSync(from, to)
563
+ out.moved.push(m)
564
+ }
565
+ for (const f of legacyStatus) { rmSync(join(root, f), { force: true }); out.removed.push(f) }
566
+
567
+ // 3. the text and the links that named the old folders
568
+ for (const w of writes) { writeFileSync(join(root, w.now), w.text); out.swept.push(w.now) }
569
+ for (const l of relinks) { rmSync(join(root, l.now), { force: true }); symlinkSync(l.target, join(root, l.now)); out.swept.push(l.now) }
570
+
571
+ // 4. the old names leave the ignore file only with their folders
572
+ const left = stillOld()
573
+ const giFinal = render(sweepGitignore(giOriginal, left), left.length > 0)
574
+ if (giFinal !== readFileSync(gi, 'utf8')) writeFileSync(gi, giFinal)
575
+ } catch (e) {
576
+ // whatever moved is under .shipkit/, whatever did not is still old:
577
+ // both stay ignored, and nothing pretends a later run finishes it
578
+ try { writeFileSync(gi, render(sweepGitignore(giOriginal, stillOld()), true)) } catch { /* the message below says what to check */ }
579
+ return { ...out, deferred: `the move stopped part way (${(e as Error).message.slice(0, 120)}). What moved is under ${SHIPKIT_DIR}/ and both layouts stay ignored: finish the rest by hand, then commit` }
580
+ }
581
+
582
+ const hooksMoved = out.moved.some((m) => m.kind === 'hooks')
583
+ if (hooksMoved && inGit) git(root, ['config', 'core.hooksPath', LAYOUT.hooks])
584
+ if (!inGit) return out
585
+
586
+ // 4. the index: every tracked file keeps its tracking under the new name
587
+ const renamed = (rel: string): string | null => {
588
+ for (const m of out.moved) if (rel === m.from || rel.startsWith(`${m.from}/`)) return m.kind === 'typo' ? null : m.to + rel.slice(m.from.length)
589
+ return null
590
+ }
591
+ const typoTracked = tracked.filter((rel) => out.moved.some((m) => m.kind === 'typo' && rel.startsWith(`${m.from}/`)))
592
+ const typoKept = typoTracked
593
+ .map((rel) => { const m = out.moved.find((x) => rel.startsWith(`${x.from}/`))!; return m.to + rel.slice(m.from.length) })
594
+ .filter((to) => git(root, ['check-ignore', '-q', '--no-index', to]).code !== 0)
595
+ const added = [...tracked.map(renamed).filter((p): p is string => p != null), ...typoKept]
596
+ if (tracked.length > 0) git(root, ['rm', '-r', '-q', '--cached', '--ignore-unmatch', '--', ...tracked])
597
+ if (added.length > 0) git(root, ['add', '-f', '--', ...added])
598
+ const giNew = !giExisted && existsSync(join(root, '.gitignore'))
599
+ if (giNew) git(root, ['add', '--', '.gitignore'])
600
+ // a swept file inside a moved folder is committed only when it stayed tracked
601
+ const inMoved = (p: string): boolean => out.moved.some((m) => p.startsWith(`${m.to}/`))
602
+ const sweptTracked = out.swept.filter((p) => !inMoved(p) || added.includes(p))
603
+ // the ignore file rides only when this pass changed it: a second move of a
604
+ // recreated tmp/ changes nothing git tracks, and an empty commit is no failure
605
+ const giChanged = giNew || (giTracked && readFileSync(join(root, '.gitignore'), 'utf8') !== giOriginal)
606
+ const touched = [...new Set([...tracked, ...added, ...sweptTracked, ...(giChanged ? ['.gitignore'] : [])])]
607
+ if (touched.length === 0) return out
608
+ // the title names what this commit carries; a pass that did neither has
609
+ // nothing tracked to land and returned above
610
+ const what = layoutSummary(out)
611
+ if (what === '') return out
612
+ const landed = machineCommit({ anchor: root, message: `chore(machine): ${what}`, paths: touched.map((p) => join(root, p)) })
613
+ if (!landed) return { ...out, uncommitted: true, pending: touched }
614
+ out.commit = git(root, ['rev-parse', '--short', 'HEAD']).out.trim()
615
+ return out
616
+ }
@@ -10,11 +10,12 @@
10
10
  // path no longer exists are pruned on every write. Churn control: a row
11
11
  // rewrites only when its DATA changes or its last-seen day is older than
12
12
  // 7 days. Never fails the calling command.
13
- import { existsSync, readFileSync, statSync } from 'fs'
13
+ import { existsSync, readFileSync } from 'fs'
14
14
  import { homedir } from 'os'
15
15
  import { join, resolve } from 'path'
16
16
  import type { RepoConfig } from '../repo-config.ts'
17
17
  import { atomicWrite, withFileLock } from './locked-write.ts'
18
+ import { isLinkedWorktree } from './git-root.ts'
18
19
  import { machineCommit } from './machine-commit.ts'
19
20
 
20
21
  const BEGIN = '<!-- >>> shipkit local-projects — machine-written, edit outside the markers -->'
@@ -69,13 +70,6 @@ function consumptionMode(root: string): string {
69
70
  } catch { return '?' }
70
71
  }
71
72
 
72
- /** a LINKED worktree, where `.git` is a file pointing at the main checkout
73
- * rather than a directory. Cheap on purpose: this runs on every check pass in
74
- * every repo, and spawning git to learn it would cost more than the write. */
75
- function isLinkedWorktree(root: string): boolean {
76
- try { return statSync(join(root, '.git')).isFile() } catch { return false }
77
- }
78
-
79
73
  export type MotherhomeOutcome = { wrote: boolean; note?: string }
80
74
 
81
75
  export function reportToMotherhome(root: string, config: RepoConfig, today: string = new Date().toISOString().slice(0, 10)): MotherhomeOutcome {