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