@workerstack/repo 0.0.0-stage → 0.1.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 (438) hide show
  1. package/CHANGELOG.md +45 -0
  2. package/LICENSE +21 -0
  3. package/README.md +0 -3
  4. package/agents/global-settings/antigravity.json +3 -0
  5. package/agents/global-settings/claude.json +6 -0
  6. package/agents/global-settings/codex.toml +2 -0
  7. package/agents/global-settings/cursor.json +3 -0
  8. package/agents/global-settings/grok.toml +36 -0
  9. package/agents/global-settings/manifest.json +36 -0
  10. package/agents/grok/hooks/orchestrate-parent.json +60 -0
  11. package/agents/grok/hooks/rtk.json +16 -0
  12. package/agents/grok/hooks/rulesync.json +3 -0
  13. package/agents/grok/roles/scout.toml +5 -0
  14. package/agents/hooks.jsonc +67 -0
  15. package/agents/mcp-policy.jsonc +16 -0
  16. package/agents/rules/overview.md +21 -0
  17. package/agents/settings/claude.json +5 -0
  18. package/agents/skills/ask/SKILL.md +13 -0
  19. package/agents/skills/auto/SKILL.md +13 -0
  20. package/agents/skills/away/SKILL.md +19 -0
  21. package/agents/skills/blast-radius/SKILL.md +54 -0
  22. package/agents/skills/bro/SKILL.md +9 -0
  23. package/agents/skills/browser/SKILL.md +16 -0
  24. package/agents/skills/clasp/SKILL.md +11 -0
  25. package/agents/skills/commit/SKILL.md +35 -0
  26. package/agents/skills/commit-and-push/SKILL.md +9 -0
  27. package/agents/skills/commit-push/SKILL.md +59 -0
  28. package/agents/skills/commit-push-deploy/SKILL.md +74 -0
  29. package/agents/skills/commit-push-release/SKILL.md +75 -0
  30. package/agents/skills/correct/SKILL.md +39 -0
  31. package/agents/skills/do/SKILL.md +21 -0
  32. package/agents/skills/explain/SKILL.md +13 -0
  33. package/agents/skills/google-workspace/SKILL.md +12 -0
  34. package/agents/skills/handoff/SKILL.md +81 -0
  35. package/agents/skills/implement-plan/SKILL.md +29 -0
  36. package/agents/skills/implement-roadmap/SKILL.md +27 -0
  37. package/agents/skills/jev/SKILL.md +17 -0
  38. package/agents/skills/listen/SKILL.md +15 -0
  39. package/agents/skills/pickup/SKILL.md +58 -0
  40. package/agents/skills/principle-attack-the-premise/SKILL.md +24 -0
  41. package/agents/skills/principle-boundary-discipline/SKILL.md +38 -0
  42. package/agents/skills/principle-build-the-lever/SKILL.md +28 -0
  43. package/agents/skills/principle-encode-lessons-in-structure/SKILL.md +15 -0
  44. package/agents/skills/principle-exhaust-the-design-space/SKILL.md +12 -0
  45. package/agents/skills/principle-experience-first/SKILL.md +22 -0
  46. package/agents/skills/principle-explain-the-number/SKILL.md +21 -0
  47. package/agents/skills/principle-fix-root-causes/SKILL.md +24 -0
  48. package/agents/skills/principle-foundational-thinking/SKILL.md +25 -0
  49. package/agents/skills/principle-guard-the-context-window/SKILL.md +16 -0
  50. package/agents/skills/principle-laziness-protocol/SKILL.md +13 -0
  51. package/agents/skills/principle-make-operations-idempotent/SKILL.md +25 -0
  52. package/agents/skills/principle-migrate-callers-then-delete-legacy-apis/SKILL.md +18 -0
  53. package/agents/skills/principle-minimize-reader-load/SKILL.md +22 -0
  54. package/agents/skills/principle-model-the-domain/SKILL.md +26 -0
  55. package/agents/skills/principle-never-block-on-the-human/SKILL.md +17 -0
  56. package/agents/skills/principle-outcome-oriented-execution/SKILL.md +25 -0
  57. package/agents/skills/principle-prove-it-works/SKILL.md +25 -0
  58. package/agents/skills/principle-redesign-from-first-principles/SKILL.md +18 -0
  59. package/agents/skills/principle-separate-before-serializing-shared-state/SKILL.md +20 -0
  60. package/agents/skills/principle-sequence-verifiable-units/SKILL.md +22 -0
  61. package/agents/skills/principle-subtract-before-you-add/SKILL.md +21 -0
  62. package/agents/skills/principle-test-behavior-not-implementation/SKILL.md +24 -0
  63. package/agents/skills/principle-type-system-discipline/SKILL.md +17 -0
  64. package/agents/skills/review-plan/SKILL.md +28 -0
  65. package/agents/skills/review-roadmap/SKILL.md +28 -0
  66. package/agents/skills/show-me/SKILL.md +21 -0
  67. package/agents/skills/speak/SKILL.md +15 -0
  68. package/agents/skills/su/SKILL.md +11 -0
  69. package/agents/skills/teach/SKILL.md +25 -0
  70. package/agents/skills/tmp/SKILL.md +54 -0
  71. package/agents/skills/typescript-best-practices/SKILL.md +32 -0
  72. package/agents/skills/typescript-best-practices/references/patterns.md +328 -0
  73. package/agents/skills/unslop/SKILL.md +62 -0
  74. package/agents/skills/write-plan/SKILL.md +33 -0
  75. package/agents/skills/write-roadmap/SKILL.md +66 -0
  76. package/agents/subagents/implementer-deep.md +57 -0
  77. package/agents/subagents/implementer-fast.md +57 -0
  78. package/agents/subagents/orchestrator.md +43 -0
  79. package/agents/subagents/researcher.md +47 -0
  80. package/agents/subagents/scout.md +44 -0
  81. package/bin/repo.ts +4 -0
  82. package/cli/actionlint-default.test.ts +38 -0
  83. package/cli/actionlint-download.test.ts +162 -0
  84. package/cli/actionlint-real.test.ts +33 -0
  85. package/cli/actionlint.constants.ts +43 -0
  86. package/cli/actionlint.test.ts +108 -0
  87. package/cli/actionlint.ts +178 -0
  88. package/cli/bin.test.ts +54 -0
  89. package/cli/bin.ts +40 -0
  90. package/cli/check-changelog.test.ts +40 -0
  91. package/cli/check-changelog.ts +12 -0
  92. package/cli/check-comments.test.ts +157 -0
  93. package/cli/check-comments.ts +123 -0
  94. package/cli/check-env-modules.test.ts +85 -0
  95. package/cli/check-env-modules.ts +56 -0
  96. package/cli/check-magic.test.ts +139 -0
  97. package/cli/check-magic.ts +120 -0
  98. package/cli/check-manifests.constants.ts +4 -0
  99. package/cli/check-manifests.test.ts +210 -0
  100. package/cli/check-manifests.ts +71 -0
  101. package/cli/check-nightly.test.ts +103 -0
  102. package/cli/check-nightly.ts +37 -0
  103. package/cli/check-ratchets-missing.test.ts +46 -0
  104. package/cli/check-ratchets-mutation.test.ts +49 -0
  105. package/cli/check-ratchets.constants.ts +20 -0
  106. package/cli/check-ratchets.test.ts +190 -0
  107. package/cli/check-ratchets.ts +193 -0
  108. package/cli/check-rejections.test.ts +220 -0
  109. package/cli/check-rejections.ts +153 -0
  110. package/cli/check-reports.ts +37 -0
  111. package/cli/check-tsconfig.test.ts +507 -0
  112. package/cli/check-tsconfig.ts +205 -0
  113. package/cli/commands.test.ts +114 -0
  114. package/cli/commands.ts +218 -0
  115. package/cli/commit-changelog.ts +27 -0
  116. package/cli/commit-msg-changelog.test.ts +93 -0
  117. package/cli/commit-msg-range.test.ts +126 -0
  118. package/cli/commit-msg-red-proof.test.ts +147 -0
  119. package/cli/commit-msg-repo.test.ts +153 -0
  120. package/cli/commit-msg.test.ts +101 -0
  121. package/cli/commit-msg.ts +194 -0
  122. package/cli/commit-range.constants.ts +17 -0
  123. package/cli/commit-range.ts +57 -0
  124. package/cli/config-knip.test.ts +70 -0
  125. package/cli/config-nightly.test.ts +84 -0
  126. package/cli/config-tdd.test.ts +46 -0
  127. package/cli/config.test.ts +576 -0
  128. package/cli/config.ts +261 -0
  129. package/cli/conventional.ts +9 -0
  130. package/cli/coverage-config.ts +62 -0
  131. package/cli/coverage.constants.ts +6 -0
  132. package/cli/coverage.test.ts +199 -0
  133. package/cli/coverage.ts +127 -0
  134. package/cli/env.constants.ts +10 -0
  135. package/cli/env.test.ts +56 -0
  136. package/cli/env.ts +72 -0
  137. package/cli/fixtures/base-config.ts +13 -0
  138. package/cli/fixtures/gate-world.ts +48 -0
  139. package/cli/fixtures/knip-run.ts +38 -0
  140. package/cli/fixtures/mutate-fakes.ts +137 -0
  141. package/cli/fixtures/planted-repo.ts +50 -0
  142. package/cli/fixtures/publish-world.ts +71 -0
  143. package/cli/fixtures/red-proof-repo.ts +72 -0
  144. package/cli/fixtures/release-context.ts +15 -0
  145. package/cli/fixtures/release-world.ts +111 -0
  146. package/cli/fixtures/stub-bin.ts +40 -0
  147. package/cli/guard-diagnostics.test.ts +157 -0
  148. package/cli/guard-diagnostics.ts +195 -0
  149. package/cli/init.test.ts +487 -0
  150. package/cli/init.ts +275 -0
  151. package/cli/knip-check-ignores.test.ts +106 -0
  152. package/cli/knip-check-real.test.ts +65 -0
  153. package/cli/knip-check.constants.ts +46 -0
  154. package/cli/knip-check.test.ts +114 -0
  155. package/cli/knip-check.ts +169 -0
  156. package/cli/knip-schema.ts +67 -0
  157. package/cli/known-failures.constants.ts +22 -0
  158. package/cli/known-failures.test.ts +138 -0
  159. package/cli/known-failures.ts +131 -0
  160. package/cli/lint/as-needs-reason.ts +41 -0
  161. package/cli/lint/boundaries.ts +73 -0
  162. package/cli/lint/class-file.ts +39 -0
  163. package/cli/lint/config-file.ts +32 -0
  164. package/cli/lint/entry-file.ts +54 -0
  165. package/cli/lint/env-in-config.ts +66 -0
  166. package/cli/lint/filename-matches-export.ts +63 -0
  167. package/cli/lint/index-file.ts +22 -0
  168. package/cli/lint/index.ts +47 -0
  169. package/cli/lint/is-reason.ts +6 -0
  170. package/cli/lint/manifest.ts +46 -0
  171. package/cli/lint/names.ts +59 -0
  172. package/cli/lint/no-commented-out-code.ts +105 -0
  173. package/cli/lint/no-mock-of-subject.ts +64 -0
  174. package/cli/lint/no-module-side-effects.ts +94 -0
  175. package/cli/lint/no-module-state.ts +58 -0
  176. package/cli/lint/no-side-effect-import.ts +17 -0
  177. package/cli/lint/no-v8-ignore.ts +18 -0
  178. package/cli/lint/script-rules.ts +9 -0
  179. package/cli/lint/skip-on-platform.constants.ts +8 -0
  180. package/cli/lint/skip-on-platform.ts +118 -0
  181. package/cli/lint/todo-needs-id.ts +63 -0
  182. package/cli/lint/types-file.ts +28 -0
  183. package/cli/lint-schema.ts +32 -0
  184. package/cli/magic/fixtures/magic-repo.ts +64 -0
  185. package/cli/magic/magic-hit.ts +2 -0
  186. package/cli/magic/magic-nodes.ts +44 -0
  187. package/cli/magic/magic-numbers.test.ts +99 -0
  188. package/cli/magic/magic-numbers.ts +117 -0
  189. package/cli/magic/magic-strings.test.ts +38 -0
  190. package/cli/magic/magic-strings.ts +81 -0
  191. package/cli/main.test.ts +853 -0
  192. package/cli/main.ts +292 -0
  193. package/cli/mutate-args.ts +79 -0
  194. package/cli/mutate-check.test.ts +147 -0
  195. package/cli/mutate-deps.ts +42 -0
  196. package/cli/mutate-edges.test.ts +150 -0
  197. package/cli/mutate-files.test.ts +100 -0
  198. package/cli/mutate-files.ts +31 -0
  199. package/cli/mutate-floors.test.ts +91 -0
  200. package/cli/mutate-floors.ts +104 -0
  201. package/cli/mutate-real-deps.ts +57 -0
  202. package/cli/mutate-real.test.ts +96 -0
  203. package/cli/mutate-report.ts +96 -0
  204. package/cli/mutate-run.ts +128 -0
  205. package/cli/mutate-shards.ts +64 -0
  206. package/cli/mutate.constants.ts +67 -0
  207. package/cli/mutate.test.ts +182 -0
  208. package/cli/mutate.ts +64 -0
  209. package/cli/nested-vitest-flags.ts +8 -0
  210. package/cli/nightly-config.constants.ts +14 -0
  211. package/cli/nightly-config.ts +62 -0
  212. package/cli/prompt.test.ts +202 -0
  213. package/cli/prompt.ts +1 -0
  214. package/cli/red-proof-edges.test.ts +78 -0
  215. package/cli/red-proof-failures.test.ts +82 -0
  216. package/cli/red-proof-tree.test.ts +157 -0
  217. package/cli/red-proof-verdict.test.ts +44 -0
  218. package/cli/red-proof.constants.ts +14 -0
  219. package/cli/red-proof.ts +202 -0
  220. package/cli/release/changelog-parse.ts +83 -0
  221. package/cli/release/changelog.test.ts +141 -0
  222. package/cli/release/changelog.ts +106 -0
  223. package/cli/release/gate-refusals.test.ts +85 -0
  224. package/cli/release/gate.test.ts +63 -0
  225. package/cli/release/gate.ts +70 -0
  226. package/cli/release/git.ts +66 -0
  227. package/cli/release/github.test.ts +128 -0
  228. package/cli/release/github.ts +46 -0
  229. package/cli/release/list-dir.test.ts +11 -0
  230. package/cli/release/list-dir.ts +6 -0
  231. package/cli/release/main.test.ts +48 -0
  232. package/cli/release/main.ts +51 -0
  233. package/cli/release/manifest.test.ts +24 -0
  234. package/cli/release/manifest.ts +34 -0
  235. package/cli/release/pack.test.ts +107 -0
  236. package/cli/release/pack.ts +76 -0
  237. package/cli/release/plan.ts +127 -0
  238. package/cli/release/publish-run.test.ts +98 -0
  239. package/cli/release/publish.test.ts +127 -0
  240. package/cli/release/publish.ts +125 -0
  241. package/cli/release/refuse.ts +5 -0
  242. package/cli/release/release-messages.constants.ts +96 -0
  243. package/cli/release/release-refusals.test.ts +149 -0
  244. package/cli/release/release.constants.ts +128 -0
  245. package/cli/release/release.test.ts +199 -0
  246. package/cli/release/release.ts +145 -0
  247. package/cli/release/version.test.ts +118 -0
  248. package/cli/release/version.ts +69 -0
  249. package/cli/release/wait.test.ts +101 -0
  250. package/cli/release/wait.ts +95 -0
  251. package/cli/script-diagnostics.test.ts +76 -0
  252. package/cli/script-diagnostics.ts +62 -0
  253. package/cli/scripts/board.ts +519 -0
  254. package/cli/scripts/browser.ts +994 -0
  255. package/cli/scripts/change-set.ts +105 -0
  256. package/cli/scripts/check-globals.ts +199 -0
  257. package/cli/scripts/ci-skips.constants.ts +17 -0
  258. package/cli/scripts/ci-skips.ts +118 -0
  259. package/cli/scripts/ci-wait-run.ts +28 -0
  260. package/cli/scripts/ci-wait.constants.ts +44 -0
  261. package/cli/scripts/ci-wait.ts +175 -0
  262. package/cli/scripts/ci.constants.ts +15 -0
  263. package/cli/scripts/ci.ts +208 -0
  264. package/cli/scripts/clean.ts +363 -0
  265. package/cli/scripts/comments-step.ts +15 -0
  266. package/cli/scripts/doctor.ts +828 -0
  267. package/cli/scripts/emit-agent-shell-tools.ts +91 -0
  268. package/cli/scripts/emit-mcp-policy.ts +402 -0
  269. package/cli/scripts/folder-map.ts +353 -0
  270. package/cli/scripts/gate-fat-tools.ts +1095 -0
  271. package/cli/scripts/gitignore-guard.ts +338 -0
  272. package/cli/scripts/google-workspace.ts +779 -0
  273. package/cli/scripts/hook-command.ts +72 -0
  274. package/cli/scripts/hook-input.ts +178 -0
  275. package/cli/scripts/hook-io.constants.ts +5 -0
  276. package/cli/scripts/hook-io.ts +46 -0
  277. package/cli/scripts/hooks-union.ts +469 -0
  278. package/cli/scripts/interactive.ts +30 -0
  279. package/cli/scripts/jev.ts +420 -0
  280. package/cli/scripts/job.ts +934 -0
  281. package/cli/scripts/jsonc.ts +9 -0
  282. package/cli/scripts/lessons.ts +564 -0
  283. package/cli/scripts/lint-baseline.ts +233 -0
  284. package/cli/scripts/lint-main.ts +24 -0
  285. package/cli/scripts/lint-skips.ts +139 -0
  286. package/cli/scripts/lint.constants.ts +6 -0
  287. package/cli/scripts/lint.ts +142 -0
  288. package/cli/scripts/listen.ts +962 -0
  289. package/cli/scripts/managed-text.ts +47 -0
  290. package/cli/scripts/orchestrate-parent.ts +1155 -0
  291. package/cli/scripts/parse-need.ts +81 -0
  292. package/cli/scripts/persist-handoff.ts +228 -0
  293. package/cli/scripts/persist-implementation.ts +276 -0
  294. package/cli/scripts/persist-plan.ts +888 -0
  295. package/cli/scripts/persist-roadmap.ts +478 -0
  296. package/cli/scripts/persist-tmp.ts +177 -0
  297. package/cli/scripts/persist.ts +54 -0
  298. package/cli/scripts/phase-files.ts +121 -0
  299. package/cli/scripts/prompt.ts +38 -0
  300. package/cli/scripts/purge-global-mcps.ts +313 -0
  301. package/cli/scripts/rejections-step.ts +37 -0
  302. package/cli/scripts/rtk-grok.ts +102 -0
  303. package/cli/scripts/scrub-spawn-prompt.ts +103 -0
  304. package/cli/scripts/scrub-text-lib.ts +58 -0
  305. package/cli/scripts/session-map.ts +69 -0
  306. package/cli/scripts/setup.ts +862 -0
  307. package/cli/scripts/skill-conflicts.ts +82 -0
  308. package/cli/scripts/speak.ts +370 -0
  309. package/cli/scripts/strategy.ts +168 -0
  310. package/cli/scripts/sync-global-settings.ts +316 -0
  311. package/cli/scripts/turn-steps.ts +249 -0
  312. package/cli/scripts/turn.ts +157 -0
  313. package/cli/scripts/typecheck-step.ts +43 -0
  314. package/cli/scripts/vscode-tasks.ts +175 -0
  315. package/cli/scripts/workspace-root.ts +160 -0
  316. package/cli/scripts/worktree.ts +806 -0
  317. package/cli/spawn.test.ts +34 -0
  318. package/cli/spawn.ts +27 -0
  319. package/cli/staged-change.constants.ts +11 -0
  320. package/cli/staged-change.ts +79 -0
  321. package/cli/stryker-preload.test.ts +50 -0
  322. package/cli/stryker-preload.ts +9 -0
  323. package/cli/sync/agents/frontmatter.test.ts +197 -0
  324. package/cli/sync/agents/frontmatter.ts +140 -0
  325. package/cli/sync/agents/hooks.test.ts +517 -0
  326. package/cli/sync/agents/hooks.ts +401 -0
  327. package/cli/sync/agents/rules.test.ts +229 -0
  328. package/cli/sync/agents/rules.ts +84 -0
  329. package/cli/sync/agents/sources.test.ts +375 -0
  330. package/cli/sync/agents/sources.ts +244 -0
  331. package/cli/sync/agents/subagents.test.ts +149 -0
  332. package/cli/sync/agents/subagents.ts +158 -0
  333. package/cli/sync/agents-text.test.ts +31 -0
  334. package/cli/sync/agents-text.ts +36 -0
  335. package/cli/sync/fixtures/copied.README.md +35 -0
  336. package/cli/sync/fixtures/copied.tgz +0 -0
  337. package/cli/sync/fixtures/maps/golden/INDEX.md +22 -0
  338. package/cli/sync/fixtures/maps-ws.tgz +0 -0
  339. package/cli/sync/git-files.test.ts +340 -0
  340. package/cli/sync/git-files.ts +197 -0
  341. package/cli/sync/git-hooks.test.ts +94 -0
  342. package/cli/sync/golden.test.ts +15 -0
  343. package/cli/sync/hooks-mode.test.ts +99 -0
  344. package/cli/sync/hosts.ts +28 -0
  345. package/cli/sync/knip-config.constants.ts +4 -0
  346. package/cli/sync/knip-config.test.ts +147 -0
  347. package/cli/sync/knip-config.ts +108 -0
  348. package/cli/sync/manifest.test.ts +78 -0
  349. package/cli/sync/manifest.ts +60 -0
  350. package/cli/sync/maps.test.ts +304 -0
  351. package/cli/sync/maps.ts +297 -0
  352. package/cli/sync/migrate.test.ts +2000 -0
  353. package/cli/sync/migrate.ts +1104 -0
  354. package/cli/sync/nightly-jobs.test.ts +63 -0
  355. package/cli/sync/nightly.constants.ts +48 -0
  356. package/cli/sync/nightly.test.ts +100 -0
  357. package/cli/sync/nightly.ts +75 -0
  358. package/cli/sync/outputs.test.ts +351 -0
  359. package/cli/sync/outputs.ts +168 -0
  360. package/cli/sync/oxlint-config.test.ts +178 -0
  361. package/cli/sync/oxlint-config.ts +168 -0
  362. package/cli/sync/release-jobs.test.ts +29 -0
  363. package/cli/sync/release-reports.test.ts +31 -0
  364. package/cli/sync/release-workflow.constants.ts +61 -0
  365. package/cli/sync/release-workflow.test.ts +192 -0
  366. package/cli/sync/release-workflow.ts +86 -0
  367. package/cli/sync/release.constants.ts +93 -0
  368. package/cli/sync/release.test.ts +187 -0
  369. package/cli/sync/release.ts +112 -0
  370. package/cli/sync/repo-dir.test.ts +371 -0
  371. package/cli/sync/repo-dir.ts +205 -0
  372. package/cli/sync/standards.constants.ts +8 -0
  373. package/cli/sync/standards.test.ts +206 -0
  374. package/cli/sync/standards.ts +179 -0
  375. package/cli/sync/stock-history.json +2796 -0
  376. package/cli/sync/stock-history.test.ts +66 -0
  377. package/cli/sync/stock-history.ts +84 -0
  378. package/cli/sync/stock.test.ts +47 -0
  379. package/cli/sync/stock.ts +24 -0
  380. package/cli/sync/trees.test.ts +621 -0
  381. package/cli/sync/trees.ts +280 -0
  382. package/cli/sync/validate.test.ts +299 -0
  383. package/cli/sync/validate.ts +402 -0
  384. package/cli/sync/workspace.test.ts +180 -0
  385. package/cli/sync/workspace.ts +138 -0
  386. package/cli/sync/write.test.ts +341 -0
  387. package/cli/sync/write.ts +231 -0
  388. package/cli/sync.test.ts +1495 -0
  389. package/cli/sync.ts +360 -0
  390. package/cli/tdd-scope.test.ts +36 -0
  391. package/cli/tdd-scope.ts +41 -0
  392. package/cli/test-files.ts +16 -0
  393. package/cli/ts-program.ts +69 -0
  394. package/cli/tsconfig.constants.ts +8 -0
  395. package/cli/workerstack-config.types.ts +21 -0
  396. package/dist/cli/env.d.ts +10 -0
  397. package/dist/cli/env.js +64 -0
  398. package/dist/presets/temp-root-env.d.ts +10 -0
  399. package/dist/presets/temp-root-env.js +20 -0
  400. package/dist/presets/temp-root.constants.d.ts +6 -0
  401. package/dist/presets/temp-root.constants.js +6 -0
  402. package/dist/presets/temp-root.d.ts +2 -0
  403. package/dist/presets/temp-root.js +60 -0
  404. package/dist/presets/vitest.constants.d.ts +16 -0
  405. package/dist/presets/vitest.constants.js +16 -0
  406. package/dist/presets/vitest.d.ts +11 -0
  407. package/dist/presets/vitest.js +67 -0
  408. package/dist/presets/workers-plugin.d.ts +10 -0
  409. package/dist/presets/workers-plugin.js +54 -0
  410. package/package.json +141 -4
  411. package/references/INDEX.md +100 -0
  412. package/references/browser.md +66 -0
  413. package/references/cascade.md +15 -0
  414. package/references/clasp.md +21 -0
  415. package/references/cloudflare.md +36 -0
  416. package/references/google-workspace.md +79 -0
  417. package/references/hooks.md +103 -0
  418. package/references/implementation-format.md +119 -0
  419. package/references/jev.md +56 -0
  420. package/references/lessons.md +73 -0
  421. package/references/listen.md +80 -0
  422. package/references/map.md +107 -0
  423. package/references/output.md +47 -0
  424. package/references/plan-format.md +264 -0
  425. package/references/review-loop.md +48 -0
  426. package/references/roadmap-format.md +104 -0
  427. package/references/rtk.md +27 -0
  428. package/references/scope.md +76 -0
  429. package/references/setup.md +42 -0
  430. package/references/speak.md +59 -0
  431. package/tsconfig/base.json +28 -0
  432. package/tsconfig/bun.d.ts +1 -0
  433. package/tsconfig/bun.json +8 -0
  434. package/tsconfig/reset.d.ts +1 -0
  435. package/tsconfig/strict.json +12 -0
  436. package/tsconfig/web.json +7 -0
  437. package/tsconfig/worker.d.ts +1 -0
  438. package/tsconfig/worker.json +8 -0
@@ -0,0 +1,1495 @@
1
+ import { BASE_CONFIG } from "./fixtures/base-config.ts";
2
+ import { execFileSync, spawnSync } from "node:child_process";
3
+ import fs from "node:fs";
4
+ import os from "node:os";
5
+ import path from "node:path";
6
+ import { afterEach, describe, expect, it, vi } from "vitest";
7
+ import { CONFIG_FILE } from "./config.ts";
8
+ import { aliasOf, linkText, removeAlias } from "../dev/test-paths.ts";
9
+ import { gitFiles, type PostPass } from "./sync/git-files.ts";
10
+ import { MANIFEST_REL, renderManifest } from "./sync/manifest.ts";
11
+ import { standards } from "./sync/standards.ts";
12
+ import { fileURLToPath } from "node:url";
13
+ import { bunfig, index, type Generator, type Output, type SyncContext } from "./sync/outputs.ts";
14
+ import { planSync, runSync, type SyncDeps } from "./sync.ts";
15
+ import type { FormatEntry } from "./sync/validate.ts";
16
+ import { writeOutput } from "./sync/write.ts";
17
+
18
+ const NO_AGENTS = { hosts: [], skills: [] };
19
+
20
+ const BUNFIG = '# Generated by repo sync. Do not edit.\n[install]\nlinker = "hoisted"\nauto = "disable"\n';
21
+ const GITIGNORE =
22
+ "# >>> @workerstack/repo (generated by repo sync)\n.tmp/\n.worktrees/\n.repo/tmp/\ncoverage/\nnode_modules/\n# <<< @workerstack/repo\n";
23
+ const GITATTRIBUTES =
24
+ "# >>> @workerstack/repo (generated by repo sync)\n* text=auto eol=lf\nbunfig.toml linguist-generated\n# <<< @workerstack/repo\n";
25
+ const SETTINGS = '{\n "files.readonlyInclude": {\n "bunfig.toml": true\n }\n}\n';
26
+ const INDEX =
27
+ "# References\n" +
28
+ "\n" +
29
+ "<!-- repo:packages -->\n" +
30
+ "- [@acme/api](../packages/api/references/INDEX.md): API worker\n" +
31
+ "<!-- /repo:packages -->\n" +
32
+ "\n" +
33
+ "<!-- repo:stack -->\n" +
34
+ "- [@workerstack/repo](../node_modules/@workerstack/repo/references/INDEX.md)\n" +
35
+ "<!-- /repo:stack -->\n";
36
+ const OUTPUTS = [
37
+ "bunfig.toml",
38
+ "references/INDEX.md",
39
+ "packages/api/references/INDEX.md",
40
+ ".gitignore",
41
+ ".gitattributes",
42
+ ".vscode/settings.json",
43
+ ];
44
+ const OLD = new Date("2020-01-01T00:00:00Z");
45
+
46
+ const temps: string[] = [];
47
+
48
+ afterEach(() => {
49
+ vi.restoreAllMocks();
50
+ for (const dir of temps.splice(0)) fs.rmSync(dir, { recursive: true, force: true });
51
+ });
52
+
53
+ function tempDir(): string {
54
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "workerstack-sync-"));
55
+ temps.push(dir);
56
+ return dir;
57
+ }
58
+
59
+ function write(root: string, rel: string, text: string): void {
60
+ const file = path.join(root, rel);
61
+ fs.mkdirSync(path.dirname(file), { recursive: true });
62
+ fs.writeFileSync(file, text);
63
+ }
64
+
65
+ function read(root: string, rel: string): string {
66
+ return fs.readFileSync(path.join(root, rel), "utf8");
67
+ }
68
+
69
+ /** Every file under `root`, root-relative, with its bytes. */
70
+ function snapshot(root: string): Record<string, string> {
71
+ const files: Record<string, string> = {};
72
+ for (const rel of fs.readdirSync(root, { recursive: true, encoding: "utf8" }).sort()) {
73
+ const file = path.join(root, rel);
74
+ if (fs.statSync(file).isFile()) files[rel.replaceAll("\\", "/")] = fs.readFileSync(file, "latin1");
75
+ }
76
+ return files;
77
+ }
78
+
79
+ /** A temp workspace repo with the docs pack, one package and a `@workerstack/repo` dependency. */
80
+ function fixture(config = 'export default { packs: ["agents", "docs"], agents: { hosts: ["claude"] } }\n'): string {
81
+ const root = tempDir();
82
+ write(root, CONFIG_FILE, config);
83
+ write(
84
+ root,
85
+ "package.json",
86
+ JSON.stringify({ name: "fixture", workspaces: ["packages/*"], devDependencies: { "@workerstack/repo": "^1.0.0" } }),
87
+ );
88
+ write(root, "packages/api/package.json", JSON.stringify({ name: "@acme/api" }));
89
+ write(root, "packages/api/references/INDEX.md", "# API worker\n");
90
+ return root;
91
+ }
92
+
93
+ /** Runs `repo sync` in `cwd`, capturing what it prints. */
94
+ async function sync(cwd: string, ...args: string[]) {
95
+ return syncWith(cwd, { generators: [bunfig, index], post: [gitFiles] }, ...args);
96
+ }
97
+
98
+ /** No post-passes, so a test with its own generators sees only their outputs. */
99
+ const noPost: readonly PostPass[] = [];
100
+
101
+ async function syncWith(cwd: string, deps: SyncDeps, ...args: string[]) {
102
+ const out: string[] = [];
103
+ const err: string[] = [];
104
+ const code = await runSync(
105
+ args,
106
+ { cwd, stdout: (s) => out.push(s), stderr: (s) => err.push(s) },
107
+ { post: noPost, ...deps },
108
+ );
109
+ return { code, stdout: out.join(""), stderr: err.join("") };
110
+ }
111
+
112
+ function mtimes(root: string): number[] {
113
+ return OUTPUTS.map((rel) => fs.statSync(path.join(root, rel)).mtimeMs);
114
+ }
115
+
116
+ function age(root: string): void {
117
+ for (const rel of OUTPUTS) fs.utimesSync(path.join(root, rel), OLD, OLD);
118
+ }
119
+
120
+ describe("runSync", () => {
121
+ it("writes every output under the root on the first run, then reports up to date without touching them", async () => {
122
+ const root = fixture();
123
+
124
+ expect(await sync(root)).toEqual({
125
+ code: 0,
126
+ stdout:
127
+ "repo sync: wrote bunfig.toml, references/INDEX.md, packages/api/references/INDEX.md, .gitignore, .gitattributes, .vscode/settings.json\n",
128
+ stderr: "",
129
+ });
130
+ expect(read(root, "bunfig.toml")).toBe(BUNFIG);
131
+ expect(read(root, ".gitignore")).toBe(GITIGNORE);
132
+ expect(read(root, ".gitattributes")).toBe(GITATTRIBUTES);
133
+ expect(read(root, ".vscode/settings.json")).toBe(SETTINGS);
134
+ expect(read(root, "references/INDEX.md")).toBe(INDEX);
135
+
136
+ age(root);
137
+ const before = mtimes(root);
138
+ expect(await sync(root)).toEqual({ code: 0, stdout: "repo sync: up to date\n", stderr: "" });
139
+ expect(mtimes(root)).toEqual(before);
140
+ });
141
+
142
+ it("refuses a config without the agents pack in this repo, whose trees are the stock source, and touches nothing", async () => {
143
+ const root = fixture('export default { packs: ["docs"] }\n');
144
+ write(root, "package.json", JSON.stringify({ name: "@workerstack/repo" }));
145
+ write(root, ".claude/skills/ask/SKILL.md", "stock\n");
146
+ expect(await sync(root)).toEqual({
147
+ code: 1,
148
+ stdout: "",
149
+ stderr:
150
+ 'repo sync: workerstack.config.ts: this repo ships the agent trees as its stock source; keep "agents" in packs\nrepo sync: nothing written\n',
151
+ });
152
+ expect(read(root, ".claude/skills/ask/SKILL.md")).toBe("stock\n");
153
+ expect(fs.existsSync(path.join(root, "bunfig.toml"))).toBe(false);
154
+ });
155
+
156
+ it("finds the root from a nested cwd and writes there", async () => {
157
+ const root = fixture();
158
+ expect((await sync(path.join(root, "packages", "api"))).code).toBe(0);
159
+ expect(read(root, "bunfig.toml")).toBe(BUNFIG);
160
+ expect(fs.existsSync(path.join(root, "packages", "api", "bunfig.toml"))).toBe(false);
161
+ });
162
+
163
+ it("rewrites only the files that drifted and leaves no temp files", async () => {
164
+ const root = fixture();
165
+ await sync(root);
166
+ write(root, "bunfig.toml", `${BUNFIG}x\n`);
167
+ age(root);
168
+ const [, ...kept] = mtimes(root);
169
+
170
+ expect(await sync(root)).toEqual({ code: 0, stdout: "repo sync: wrote bunfig.toml\n", stderr: "" });
171
+ expect(read(root, "bunfig.toml")).toBe(BUNFIG);
172
+ expect(mtimes(root).slice(1)).toEqual(kept);
173
+ expect(fs.readdirSync(root).filter((name) => name.includes(".tmp"))).toEqual([]);
174
+ });
175
+
176
+ it("keeps the hand-written text around the managed parts", async () => {
177
+ const root = fixture();
178
+ write(root, ".gitignore", "node_modules/\n.tmp/\n");
179
+ write(root, "references/INDEX.md", "# Docs\n\nHand-written.\n");
180
+ await sync(root);
181
+ expect(read(root, ".gitignore")).toBe(`node_modules/\n.tmp/\n\n${GITIGNORE}`);
182
+ expect(read(root, "references/INDEX.md")).toBe(`# Docs\n\nHand-written.\n${INDEX.slice("# References\n".length)}`);
183
+ });
184
+
185
+ it("treats a file whose bytes differ only in line endings as drifted", async () => {
186
+ const root = fixture();
187
+ await sync(root);
188
+ write(root, "bunfig.toml", BUNFIG.replaceAll("\n", "\r\n"));
189
+ expect(await sync(root, "--check")).toEqual({
190
+ code: 1,
191
+ stdout: "",
192
+ stderr: "repo sync: bunfig.toml is out of date; run `bun repo sync`\n",
193
+ });
194
+ });
195
+
196
+ it("--check reports up to date and exits 0 when nothing drifted", async () => {
197
+ const root = fixture();
198
+ await sync(root);
199
+ expect(await sync(root, "--check")).toEqual({ code: 0, stdout: "repo sync: up to date\n", stderr: "" });
200
+ });
201
+
202
+ it("--check names every stale file, exits 1 and writes nothing", async () => {
203
+ const root = fixture();
204
+ write(root, "bunfig.toml", `${BUNFIG}x\n`);
205
+ const before = snapshot(root);
206
+
207
+ expect(await sync(root, "--check")).toEqual({
208
+ code: 1,
209
+ stdout: "",
210
+ stderr:
211
+ "repo sync: bunfig.toml is out of date; run `bun repo sync`\n" +
212
+ "repo sync: references/INDEX.md is out of date; run `bun repo sync`\n" +
213
+ "repo sync: packages/api/references/INDEX.md is out of date; run `bun repo sync`\n" +
214
+ "repo sync: .gitignore is out of date; run `bun repo sync`\n" +
215
+ "repo sync: .gitattributes is out of date; run `bun repo sync`\n" +
216
+ "repo sync: .vscode/settings.json is out of date; run `bun repo sync`\n",
217
+ });
218
+ expect(snapshot(root)).toEqual(before);
219
+ });
220
+
221
+ it("a config error exits 1 and leaves every file byte-identical", async () => {
222
+ const root = fixture("export default { pakcs: [] }\n");
223
+ write(root, "bunfig.toml", `${BUNFIG}x\n`);
224
+ write(root, ".gitignore", "node_modules/\n");
225
+ const before = snapshot(root);
226
+
227
+ expect(await sync(root)).toEqual({
228
+ code: 1,
229
+ stdout: "",
230
+ stderr: 'repo sync: workerstack.config.ts: unknown key "pakcs"\nrepo sync: nothing written\n',
231
+ });
232
+ expect(snapshot(root)).toEqual(before);
233
+ });
234
+
235
+ it("a generator error exits 1 and writes none of the valid outputs", async () => {
236
+ const root = fixture();
237
+ write(root, "bunfig.toml", `${BUNFIG}x\n`);
238
+ write(root, "references/INDEX.md", "# References\n\n<!-- repo:stack -->\n");
239
+ const before = snapshot(root);
240
+
241
+ expect(await sync(root)).toEqual({
242
+ code: 1,
243
+ stdout: "",
244
+ stderr:
245
+ 'repo sync: references/INDEX.md: "<!-- repo:stack -->" has no closing "<!-- /repo:stack -->"\nrepo sync: nothing written\n',
246
+ });
247
+ expect(snapshot(root)).toEqual(before);
248
+ });
249
+
250
+ it("a workspace error exits 1 and writes nothing", async () => {
251
+ const root = fixture();
252
+ write(root, "packages/api/package.json", "{");
253
+ const before = snapshot(root);
254
+
255
+ const result = await sync(root);
256
+ expect(result.code).toBe(1);
257
+ expect(result.stdout).toBe("");
258
+ expect(result.stderr).toMatch(
259
+ /^repo sync: packages\/api\/package\.json: not valid JSON: .+\nrepo sync: nothing written\n$/,
260
+ );
261
+ expect(snapshot(root)).toEqual(before);
262
+ });
263
+
264
+ it("reports a file it cannot read instead of treating it as missing", async () => {
265
+ const root = fixture();
266
+ write(root, "references/INDEX.md/stray.txt", "a directory where the index belongs\n");
267
+ const before = snapshot(root);
268
+
269
+ const result = await sync(root);
270
+ expect(result.code).toBe(1);
271
+ expect(result.stdout).toBe("");
272
+ expect(result.stderr).toMatch(
273
+ /^repo sync: references\/INDEX\.md: cannot read: EISDIR\b.*\nrepo sync: nothing written\n$/,
274
+ );
275
+ expect(snapshot(root)).toEqual(before);
276
+ });
277
+
278
+ it("reports a failed write, removes its temp file and exits 1", async () => {
279
+ const root = fixture();
280
+ const rename = vi.spyOn(fs, "renameSync").mockImplementationOnce(() => {
281
+ throw Object.assign(new Error("EACCES: permission denied, rename"), { code: "EACCES" });
282
+ });
283
+
284
+ expect(await sync(root)).toEqual({
285
+ code: 1,
286
+ stdout: "",
287
+ stderr:
288
+ "repo sync: bunfig.toml: cannot write: EACCES: permission denied, rename; restored 0 outputs, nothing changed\n",
289
+ });
290
+ expect(rename).toHaveBeenCalledOnce();
291
+ expect(fs.readdirSync(root).sort()).toEqual([CONFIG_FILE, "package.json", "packages"].sort());
292
+ });
293
+
294
+ it("--postinstall prints an error and exits 0", async () => {
295
+ const root = fixture("export default { pakcs: [] }\n");
296
+ expect(await sync(root, "--postinstall")).toEqual({
297
+ code: 0,
298
+ stdout: "",
299
+ stderr: 'repo sync: workerstack.config.ts: unknown key "pakcs"\nrepo sync: nothing written\n',
300
+ });
301
+ expect(fs.existsSync(path.join(root, "bunfig.toml"))).toBe(false);
302
+ });
303
+
304
+ it("--postinstall writes like a plain run when there is no error", async () => {
305
+ const root = fixture();
306
+ expect(await sync(root, "--postinstall")).toEqual({
307
+ code: 0,
308
+ stdout:
309
+ "repo sync: wrote bunfig.toml, references/INDEX.md, packages/api/references/INDEX.md, .gitignore, .gitattributes, .vscode/settings.json\n",
310
+ stderr: "",
311
+ });
312
+ });
313
+
314
+ it("says to run repo init when no config exists in cwd or any parent", async () => {
315
+ const cwd = tempDir();
316
+ expect(await sync(cwd)).toEqual({
317
+ code: 1,
318
+ stdout: "",
319
+ stderr: `repo sync: no workerstack.config.ts in ${cwd} or any parent; run \`bun repo init\`\n`,
320
+ });
321
+ expect(fs.readdirSync(cwd)).toEqual([]);
322
+ expect((await sync(cwd, "--postinstall")).code).toBe(0);
323
+ });
324
+
325
+ it("produces identical bytes across runs and roots", async () => {
326
+ const first = fixture();
327
+ const second = fixture();
328
+ await sync(first);
329
+ const once = snapshot(first);
330
+ await sync(first);
331
+ await sync(second);
332
+ expect(snapshot(first)).toEqual(once);
333
+ expect(snapshot(second)).toEqual(once);
334
+ });
335
+
336
+ it.each([
337
+ [["--force"], "repo sync: unknown flag --force; use --check or --postinstall\n"],
338
+ [["extra"], "repo sync: unknown flag extra; use --check or --postinstall\n"],
339
+ [["--check", "--postinstall"], "repo sync: use --check or --postinstall, not both\n"],
340
+ ])("%j is a usage error that exits 1 and writes nothing", async (args, stderr) => {
341
+ const root = fixture();
342
+ const before = snapshot(root);
343
+ expect(await sync(root, ...args)).toEqual({ code: 1, stdout: "", stderr });
344
+ expect(snapshot(root)).toEqual(before);
345
+ });
346
+ });
347
+
348
+ describe("planSync", () => {
349
+ it("runs every generator in order and concatenates outputs and errors", () => {
350
+ const files: Record<string, string> = {
351
+ "bunfig.toml": "[test]\n",
352
+ ".gitignore": "# >>> @workerstack/repo\n",
353
+ };
354
+ const plan = planSync({
355
+ root: "/repo",
356
+ config: { packs: ["docs"], agents: NO_AGENTS, ...BASE_CONFIG },
357
+ workspace: { root: "/repo", manifest: {}, packages: [] },
358
+ read: (rel) => files[rel] ?? null,
359
+ });
360
+ // The git files pass runs after the generators, over what they produced, even when one of them failed.
361
+ expect(plan.outputs.map((output) => output.path)).toEqual([
362
+ "references/INDEX.md",
363
+ ".gitattributes",
364
+ ".vscode/settings.json",
365
+ ]);
366
+ expect(plan.errors).toEqual([
367
+ "bunfig.toml is hand-written; delete it so repo sync can own it",
368
+ '.gitignore: "# >>> @workerstack/repo" has no closing "# <<< @workerstack/repo"',
369
+ ]);
370
+ });
371
+
372
+ it("runs the given post-passes in order, each over the outputs planned before it, and reports one that throws", () => {
373
+ const ctx: SyncContext = {
374
+ root: "/repo",
375
+ config: { packs: ["docs"], agents: NO_AGENTS, ...BASE_CONFIG },
376
+ workspace: { root: "/repo", manifest: {}, packages: [] },
377
+ read: () => null,
378
+ };
379
+ const ok: Generator = () => ({ outputs: [file("a.txt")], errors: [] });
380
+ const seen: string[][] = [];
381
+ const adding =
382
+ (rel: string): PostPass =>
383
+ (_ctx, outputs) => {
384
+ seen.push(outputs.map((output) => output.path));
385
+ return { outputs: [file(rel)], errors: [] };
386
+ };
387
+ expect(planSync(ctx, [ok], [adding("b.txt"), adding("c.txt")]).outputs.map((output) => output.path)).toEqual([
388
+ "a.txt",
389
+ "b.txt",
390
+ "c.txt",
391
+ ]);
392
+ expect(seen).toEqual([["a.txt"], ["a.txt", "b.txt"]]);
393
+ const boom = () => {
394
+ throw new Error("post failed");
395
+ };
396
+ expect(planSync(ctx, [ok], [boom])).toEqual({ outputs: [file("a.txt")], errors: ["post failed"] });
397
+ });
398
+
399
+ it("reports a generator that throws as an error and keeps running the others", () => {
400
+ const boom: Generator = () => {
401
+ throw new Error("EACCES: cannot read x");
402
+ };
403
+ const ok: Generator = () => ({ outputs: [file("a.txt")], errors: [] });
404
+ const plan = planSync(
405
+ {
406
+ root: "/repo",
407
+ config: { packs: ["docs"], agents: NO_AGENTS, ...BASE_CONFIG },
408
+ workspace: { root: "/repo", manifest: {}, packages: [] },
409
+ read: () => null,
410
+ },
411
+ [boom, ok],
412
+ noPost,
413
+ );
414
+ expect(plan).toEqual({ outputs: [file("a.txt")], errors: ["EACCES: cannot read x"] });
415
+ });
416
+
417
+ it("refuses two outputs with one path, naming the generators that planned them", () => {
418
+ const first: Generator = () => ({ outputs: [file("a.txt"), file("b.txt")], errors: [] });
419
+ const second: Generator = () => ({ outputs: [file("a.txt", "other\n")], errors: [] });
420
+ const plan = planSync(
421
+ {
422
+ root: "/repo",
423
+ config: { packs: ["docs"], agents: NO_AGENTS, ...BASE_CONFIG },
424
+ workspace: { root: "/repo", manifest: {}, packages: [] },
425
+ read: () => null,
426
+ },
427
+ [first, second],
428
+ noPost,
429
+ );
430
+ expect(plan.errors).toEqual(["a.txt: planned by first and second; rename one"]);
431
+ });
432
+
433
+ it("plans every output for a clean repo", () => {
434
+ const plan = planSync({
435
+ root: "/repo",
436
+ config: { packs: ["docs"], agents: NO_AGENTS, ...BASE_CONFIG },
437
+ workspace: { root: "/repo", manifest: {}, packages: [] },
438
+ read: () => null,
439
+ });
440
+ expect(plan.errors).toEqual([]);
441
+ expect(plan.outputs.map((output) => output.path)).toEqual(OUTPUTS.filter((rel) => !rel.startsWith("packages/")));
442
+ });
443
+ });
444
+
445
+ const file = (path: string, content = `${path}\n`): Output => ({
446
+ kind: "file",
447
+ path,
448
+ content,
449
+ tracked: false,
450
+ managed: "whole",
451
+ });
452
+ const link = (path: string, target: string): Output => ({
453
+ kind: "link",
454
+ path,
455
+ target,
456
+ tracked: false,
457
+ managed: "whole",
458
+ });
459
+ /** A generator that returns exactly `outputs`, replacing the real ones. */
460
+ const generating = (...outputs: Output[]): Generator[] => [() => ({ outputs, errors: [] })];
461
+ /** The same, with every path accepted as text, so these tests need not name registered formats. */
462
+ const only = (...outputs: Output[]): { generators: Generator[]; formats: FormatEntry[] } => ({
463
+ generators: generating(...outputs),
464
+ formats: [{ match: () => true, format: "text" }],
465
+ });
466
+
467
+ describe("a config rewritten between two runs in one process", () => {
468
+ it("is read again, so the second run converges on the new config", async () => {
469
+ const root = fixture('export default { packs: ["agents"], agents: { hosts: ["claude"] } }\n');
470
+ const hosts: Generator = (ctx) => ({ outputs: [file("hosts.txt", ctx.config.agents.hosts.join(","))], errors: [] });
471
+ const deps = { generators: [hosts], formats: [{ match: () => true, format: "text" as const }] };
472
+ expect((await syncWith(root, deps)).code).toBe(0);
473
+ expect(read(root, "hosts.txt")).toBe("claude");
474
+
475
+ write(root, CONFIG_FILE, 'export default { packs: ["agents"], agents: { hosts: ["codex"] } }\n');
476
+ expect(await syncWith(root, deps, "--check")).toMatchObject({
477
+ code: 1,
478
+ stderr: expect.stringContaining("hosts.txt is out of date"),
479
+ });
480
+ expect((await syncWith(root, deps)).code).toBe(0);
481
+ expect(read(root, "hosts.txt")).toBe("codex");
482
+ expect(await syncWith(root, deps, "--check")).toEqual({ code: 0, stdout: "repo sync: up to date\n", stderr: "" });
483
+ });
484
+ });
485
+
486
+ const manifestPaths = (root: string): string[] => (JSON.parse(read(root, MANIFEST_REL)) as { paths: string[] }).paths;
487
+ const exists = (root: string, rel: string): boolean => {
488
+ try {
489
+ fs.lstatSync(path.join(root, rel));
490
+ return true;
491
+ } catch {
492
+ return false;
493
+ }
494
+ };
495
+
496
+ describe("runSync link outputs", () => {
497
+ it("creates a link, keeps it while current and replaces it when it points elsewhere", async () => {
498
+ const root = fixture();
499
+ fs.mkdirSync(path.join(root, "store/a"), { recursive: true });
500
+ fs.mkdirSync(path.join(root, "store/b"), { recursive: true });
501
+ const symlink = vi.spyOn(fs, "symlinkSync");
502
+
503
+ const deps = only(link("tree/skill", "../store/a"));
504
+ expect(await syncWith(root, deps)).toEqual({ code: 0, stdout: "repo sync: wrote tree/skill\n", stderr: "" });
505
+ expect(linkText(path.join(root, "tree/skill"))).toBe("../store/a");
506
+ expect(symlink).toHaveBeenCalledTimes(1);
507
+
508
+ expect(await syncWith(root, deps)).toEqual({ code: 0, stdout: "repo sync: up to date\n", stderr: "" });
509
+ expect(symlink).toHaveBeenCalledTimes(1);
510
+
511
+ expect((await syncWith(root, only(link("tree/skill", "../store/b")))).code).toBe(0);
512
+ expect(linkText(path.join(root, "tree/skill"))).toBe("../store/b");
513
+ expect(symlink).toHaveBeenCalledTimes(2);
514
+ });
515
+
516
+ it("keeps a link whose text is exactly the planned target", async () => {
517
+ const root = fixture();
518
+ fs.mkdirSync(path.join(root, "store"));
519
+ fs.mkdirSync(path.join(root, "tree"));
520
+ // A junction needs no privilege on win32 and is a plain symlink elsewhere, so the posix check runs on every OS.
521
+ fs.symlinkSync(path.join(root, "store"), path.join(root, "tree/skill"), "junction");
522
+ const text = fs.readlinkSync(path.join(root, "tree/skill"));
523
+ await syncWith(root, { ...only(link("tree/skill", text)), platform: "linux" });
524
+ const symlink = vi.spyOn(fs, "symlinkSync");
525
+ expect(await syncWith(root, { ...only(link("tree/skill", text)), platform: "linux" })).toEqual({
526
+ code: 0,
527
+ stdout: "repo sync: up to date\n",
528
+ stderr: "",
529
+ });
530
+ expect(symlink).not.toHaveBeenCalled();
531
+ });
532
+
533
+ it("replaces a real file that stands where a link belongs", async () => {
534
+ const root = fixture();
535
+ fs.mkdirSync(path.join(root, "store"));
536
+ write(root, "tree/skill", "old\n");
537
+ expect((await syncWith(root, only(link("tree/skill", "../store")))).code).toBe(0);
538
+ expect(fs.lstatSync(path.join(root, "tree/skill")).isSymbolicLink()).toBe(true);
539
+ });
540
+
541
+ it("makes a junction with an absolute target on win32", async () => {
542
+ const root = fixture();
543
+ fs.mkdirSync(path.join(root, "store"));
544
+ const symlink = vi.spyOn(fs, "symlinkSync");
545
+ await syncWith(root, { ...only(link("tree/skill", "../store")), platform: "win32" });
546
+ expect(symlink).toHaveBeenCalledWith(path.join(root, "store"), path.join(root, "tree/skill"), "junction");
547
+ });
548
+
549
+ // A win32 junction holds an absolute target, so there a link is matched by the path it names (the next test).
550
+ it.skipIf(process.platform === "win32")(
551
+ "rewrites a link whose text differs, even when it resolves to the same directory",
552
+ async () => {
553
+ const root = fixture();
554
+ const store = path.join(root, "store");
555
+ fs.mkdirSync(store);
556
+ const alias = aliasOf(store);
557
+ try {
558
+ expect((await syncWith(root, only(link("tree/skill", alias)))).code).toBe(0);
559
+ expect(await syncWith(root, only(link("tree/skill", store)))).toEqual({
560
+ code: 0,
561
+ stdout: "repo sync: wrote tree/skill\n",
562
+ stderr: "",
563
+ });
564
+ expect(fs.readlinkSync(path.join(root, "tree/skill"))).toBe(store);
565
+ } finally {
566
+ removeAlias(alias);
567
+ }
568
+ },
569
+ );
570
+
571
+ it("on win32 treats a target whose parent dirs are spelled another way as the same directory", async () => {
572
+ const root = fixture();
573
+ const store = path.join(root, "store");
574
+ fs.mkdirSync(store);
575
+ const alias = aliasOf(root);
576
+ try {
577
+ expect(
578
+ (await syncWith(root, { ...only(link("tree/skill", path.join(alias, "store"))), platform: "win32" })).code,
579
+ ).toBe(0);
580
+ const symlink = vi.spyOn(fs, "symlinkSync");
581
+ expect(await syncWith(root, { ...only(link("tree/skill", store)), platform: "win32" })).toEqual({
582
+ code: 0,
583
+ stdout: "repo sync: up to date\n",
584
+ stderr: "",
585
+ });
586
+ expect(symlink).not.toHaveBeenCalled();
587
+ } finally {
588
+ removeAlias(alias);
589
+ }
590
+ });
591
+
592
+ // A posix symlink on win32 needs a privilege the junction does not, so win32 runs only its own platform.
593
+ const platforms = process.platform === "win32" ? (["win32"] as const) : (["linux", "win32"] as const);
594
+ it.each(platforms)(
595
+ "on %s rewrites a link that reaches the target only through another generated link, so pruning that link leaves none dangling",
596
+ async (platform) => {
597
+ const root = fixture();
598
+ fs.mkdirSync(path.join(root, "store/x"), { recursive: true });
599
+ const through = [link(".agents/skills/x", "../../store/x"), link(".claude/skills/x", "../../.agents/skills/x")];
600
+ expect((await syncWith(root, { generators: [() => ({ outputs: through, errors: [] })], platform })).code).toBe(0);
601
+
602
+ const direct = await syncWith(root, { ...only(link(".claude/skills/x", "../../store/x")), platform });
603
+ expect(direct.code).toBe(0);
604
+ expect(direct.stdout).toBe("repo sync: wrote .claude/skills/x; removed .agents/skills/x\n");
605
+ expect(linkText(path.join(root, ".claude/skills/x"))).toBe("../../store/x");
606
+ expect(fs.existsSync(path.join(root, ".claude/skills/x"))).toBe(true);
607
+ expect(exists(root, ".agents")).toBe(false);
608
+ },
609
+ );
610
+
611
+ it("reports a failed link and exits 1", async () => {
612
+ const root = fixture();
613
+ fs.mkdirSync(path.join(root, "store"));
614
+ vi.spyOn(fs, "symlinkSync").mockImplementationOnce(() => {
615
+ throw new Error("EPERM: operation not permitted, symlink");
616
+ });
617
+ // Injected, so the posix link call runs on win32 too; the mock keeps it from needing the symlink privilege.
618
+ expect(await syncWith(root, { ...only(link("tree/skill", "../store")), platform: "linux" })).toEqual({
619
+ code: 1,
620
+ stdout: "",
621
+ stderr:
622
+ "repo sync: tree/skill: cannot write: EPERM: operation not permitted, symlink; restored 0 outputs, nothing changed\n",
623
+ });
624
+ });
625
+ });
626
+
627
+ describe("runSync file writes", () => {
628
+ it("reports a failed write even when its temp file cannot be removed", async () => {
629
+ const root = fixture();
630
+ vi.spyOn(fs, "renameSync").mockImplementationOnce(() => {
631
+ throw new Error("EPERM: operation not permitted, rename");
632
+ });
633
+ vi.spyOn(fs, "rmSync").mockImplementationOnce(() => {
634
+ throw new Error("EBUSY: resource busy");
635
+ });
636
+ expect(await syncWith(root, only(file("a.txt")))).toEqual({
637
+ code: 1,
638
+ stdout: "",
639
+ stderr:
640
+ "repo sync: a.txt: cannot write: EPERM: operation not permitted, rename; restored 0 outputs, nothing changed\n",
641
+ });
642
+ });
643
+ });
644
+
645
+ describe("runSync manifest and prune", () => {
646
+ it("removes a path the last run wrote and no output names now, with its empty parent dirs", async () => {
647
+ const root = fixture();
648
+ fs.mkdirSync(path.join(root, "store"));
649
+ await syncWith(
650
+ root,
651
+ only(file("keep.txt"), file("gone.txt"), file("deep/er/file.txt"), link("links/skill", "../store")),
652
+ );
653
+ expect(manifestPaths(root)).toEqual(["deep/er/file.txt", "gone.txt", "keep.txt", "links/skill"]);
654
+ write(root, "deep/other.txt", "mine\n");
655
+ write(root, "unlisted.txt", "mine\n");
656
+ write(root, "unlisted/dir/a.txt", "mine\n");
657
+
658
+ expect(await syncWith(root, only(file("keep.txt")))).toEqual({
659
+ code: 0,
660
+ stdout: "repo sync: removed deep/er/file.txt, gone.txt, links/skill\n",
661
+ stderr: "",
662
+ });
663
+ expect(exists(root, "gone.txt")).toBe(false);
664
+ expect(exists(root, "links/skill")).toBe(false);
665
+ expect(exists(root, "links")).toBe(false);
666
+ expect(exists(root, "deep/er")).toBe(false);
667
+ expect(read(root, "deep/other.txt")).toBe("mine\n");
668
+ expect(read(root, "unlisted.txt")).toBe("mine\n");
669
+ expect(read(root, "unlisted/dir/a.txt")).toBe("mine\n");
670
+ expect(fs.existsSync(path.join(root, "store"))).toBe(true);
671
+ expect(manifestPaths(root)).toEqual(["keep.txt"]);
672
+ });
673
+
674
+ it("reports a path it cannot remove, exits 1 and keeps the old manifest", async () => {
675
+ const root = fixture();
676
+ await syncWith(root, only(file("a.txt"), file("b.txt")));
677
+ vi.spyOn(fs, "rmSync").mockImplementationOnce(() => {
678
+ throw new Error("EBUSY: resource busy");
679
+ });
680
+ expect(await syncWith(root, only(file("a.txt")))).toEqual({
681
+ code: 1,
682
+ stdout: "",
683
+ stderr: "repo sync: b.txt: cannot remove: EBUSY: resource busy; restored 0 outputs, nothing changed\n",
684
+ });
685
+ expect(manifestPaths(root)).toEqual(["a.txt", "b.txt"]);
686
+ });
687
+
688
+ it("reports a manifest it cannot write", async () => {
689
+ const root = fixture();
690
+ write(root, ".repo", "not a directory\n");
691
+ expect(await syncWith(root, only())).toMatchObject({ code: 1, stdout: "" });
692
+ });
693
+
694
+ it("never removes a path that is not in the manifest", async () => {
695
+ const root = fixture();
696
+ write(root, "stale.txt", "mine\n");
697
+ await syncWith(root, only(file("a.txt")));
698
+ expect(read(root, "stale.txt")).toBe("mine\n");
699
+ });
700
+
701
+ it("skips a manifest path that is already gone and a real directory standing in its place", async () => {
702
+ const root = fixture();
703
+ write(root, MANIFEST_REL, JSON.stringify({ version: 1, paths: ["missing.txt", "adir"] }));
704
+ write(root, "adir/inner.txt", "mine\n");
705
+ expect(await syncWith(root, only(file("a.txt")))).toEqual({
706
+ code: 0,
707
+ stdout: "repo sync: wrote a.txt\n",
708
+ stderr: "",
709
+ });
710
+ expect(read(root, "adir/inner.txt")).toBe("mine\n");
711
+ });
712
+
713
+ it("reads a missing, invalid or foreign manifest as empty", async () => {
714
+ for (const text of ["{", "[]", "null", '{"version":2,"paths":[]}', '{"version":1,"paths":[1]}', '{"version":1}']) {
715
+ const root = fixture();
716
+ write(root, MANIFEST_REL, text);
717
+ write(root, "old.txt", "mine\n");
718
+ expect((await syncWith(root, only(file("a.txt")))).code).toBe(0);
719
+ expect(read(root, "old.txt")).toBe("mine\n");
720
+ expect(manifestPaths(root)).toEqual(["a.txt"]);
721
+ }
722
+ });
723
+
724
+ it("writes the manifest after every output and prune, and not at all when a write fails", async () => {
725
+ const root = fixture();
726
+ const order: string[] = [];
727
+ const deps = {
728
+ ...only(file("a.txt"), file("b.txt")),
729
+ writeOutput: (r: string, output: Output, platform: NodeJS.Platform) => {
730
+ order.push(`${output.path}:${exists(root, MANIFEST_REL)}`);
731
+ return writeOutput(r, output, platform);
732
+ },
733
+ };
734
+ await syncWith(root, deps);
735
+ expect(order).toEqual(["a.txt:false", "b.txt:false"]);
736
+ expect(exists(root, MANIFEST_REL)).toBe(true);
737
+
738
+ const fresh = fixture();
739
+ const failing = { ...only(file("a.txt")), writeOutput: () => "disk full" };
740
+ expect((await syncWith(fresh, failing)).code).toBe(1);
741
+ expect(exists(fresh, MANIFEST_REL)).toBe(false);
742
+ });
743
+
744
+ it("restores every earlier output when a later write fails, and converges on the next run", async () => {
745
+ const root = fixture();
746
+ await syncWith(root, only(file("old.txt")));
747
+ write(root, "a.txt", "mine, drifted\n");
748
+ const before = snapshot(root);
749
+ const next = only(file("a.txt"), file("b.txt"), file("c/d.txt"));
750
+ let calls = 0;
751
+ const crashing = {
752
+ ...next,
753
+ writeOutput: (r: string, output: Output, platform: NodeJS.Platform) =>
754
+ ++calls === 3 ? "EACCES: permission denied" : writeOutput(r, output, platform),
755
+ };
756
+ expect(await syncWith(root, crashing)).toEqual({
757
+ code: 1,
758
+ stdout: "",
759
+ stderr: "repo sync: c/d.txt: cannot write: EACCES: permission denied; restored 2 outputs, nothing changed\n",
760
+ });
761
+ expect(snapshot(root)).toEqual(before);
762
+ expect(exists(root, "c")).toBe(false);
763
+ expect(manifestPaths(root)).toEqual(["old.txt"]);
764
+
765
+ expect(await syncWith(root, next)).toEqual({
766
+ code: 0,
767
+ stdout: "repo sync: wrote a.txt, b.txt, c/d.txt; removed old.txt\n",
768
+ stderr: "",
769
+ });
770
+ expect(exists(root, "old.txt")).toBe(false);
771
+ expect(manifestPaths(root)).toEqual(["a.txt", "b.txt", "c/d.txt"]);
772
+ expect(await syncWith(root, next)).toEqual({ code: 0, stdout: "repo sync: up to date\n", stderr: "" });
773
+ });
774
+
775
+ it("matches a manifest's backslash and absolute spellings to outputs and never prunes a foreign path", async () => {
776
+ const root = fixture();
777
+ const winRoot = root.replaceAll("/", "\\");
778
+ // A drive path is a plain file name inside the repo on posix and a real path outside it on win32.
779
+ const foreign =
780
+ process.platform === "win32" ? path.join(`${root}-foreign`, "a", "foreign.txt") : "C:\\other\\a\\foreign.txt";
781
+ const foreignAbs = process.platform === "win32" ? foreign : path.join(root, foreign);
782
+ temps.push(`${root}-foreign`);
783
+ write(root, "a/keep.txt", "keep\n");
784
+ write(root, "a/old.txt", "old\n");
785
+ write(path.dirname(foreignAbs), path.basename(foreignAbs), "mine\n");
786
+ write(path.dirname(root), `${path.basename(root)}-outside.txt`, "mine\n");
787
+ temps.push(`${root}-outside.txt`);
788
+ write(
789
+ root,
790
+ MANIFEST_REL,
791
+ JSON.stringify({
792
+ version: 1,
793
+ paths: [`${winRoot}\\a\\keep.txt`, ".\\a\\old.txt", foreign, `..\\${path.basename(root)}-outside.txt`, ""],
794
+ }),
795
+ );
796
+
797
+ expect((await syncWith(root, only(file("a/keep.txt", "keep\n")))).code).toBe(0);
798
+ expect(read(root, "a/keep.txt")).toBe("keep\n");
799
+ expect(exists(root, "a/old.txt")).toBe(false);
800
+ expect(fs.readFileSync(foreignAbs, "utf8")).toBe("mine\n");
801
+ expect(fs.readFileSync(`${root}-outside.txt`, "utf8")).toBe("mine\n");
802
+ expect(manifestPaths(root)).toEqual(["a/keep.txt"]);
803
+ });
804
+ });
805
+
806
+ describe("runSync output paths", () => {
807
+ it("rejects an output outside the repo before writing anything", async () => {
808
+ const root = fixture();
809
+ expect(await syncWith(root, only(file("a.txt"), file("../escape.txt")))).toEqual({
810
+ code: 1,
811
+ stdout: "",
812
+ stderr: "repo sync: ../escape.txt: not a path inside the repo\nrepo sync: nothing written\n",
813
+ });
814
+ expect(exists(root, "a.txt")).toBe(false);
815
+ });
816
+ });
817
+
818
+ describe("runSync --check with the manifest", () => {
819
+ it("names a missing output, a changed one and a prunable one, and writes nothing", async () => {
820
+ const root = fixture();
821
+ await syncWith(root, only(file("changed.txt"), file("gone.txt"), file("missing.txt")));
822
+ write(root, "changed.txt", "edited\n");
823
+ fs.rmSync(path.join(root, "missing.txt"));
824
+ const before = snapshot(root);
825
+
826
+ expect(await syncWith(root, only(file("changed.txt"), file("missing.txt"), file("fresh.txt")), "--check")).toEqual({
827
+ code: 1,
828
+ stdout: "",
829
+ stderr:
830
+ "repo sync: changed.txt is out of date; run `bun repo sync`\n" +
831
+ "repo sync: missing.txt is out of date; run `bun repo sync`\n" +
832
+ "repo sync: fresh.txt is out of date; run `bun repo sync`\n" +
833
+ "repo sync: gone.txt is stale and would be removed; run `bun repo sync`\n",
834
+ });
835
+ expect(snapshot(root)).toEqual(before);
836
+ });
837
+
838
+ it("fails on a link that is missing or wrong and passes once it is current", async () => {
839
+ const root = fixture();
840
+ fs.mkdirSync(path.join(root, "store"));
841
+ const deps = only(link("l", "store"));
842
+ expect((await syncWith(root, deps, "--check")).code).toBe(1);
843
+ await syncWith(root, deps);
844
+ expect(await syncWith(root, deps, "--check")).toEqual({ code: 0, stdout: "repo sync: up to date\n", stderr: "" });
845
+ });
846
+ });
847
+
848
+ describe("runSync summary", () => {
849
+ it("groups outputs that share a top dir and keeps single files by name", async () => {
850
+ const root = fixture();
851
+ const outputs = [file("a.md"), file(".claude/x"), file(".claude/y/z"), file("docs/one.md"), file(".claude/w")];
852
+ expect((await syncWith(root, only(...outputs))).stdout).toBe("repo sync: wrote a.md, .claude/ (3), docs/one.md\n");
853
+ expect((await syncWith(root, only())).stdout).toBe("repo sync: removed .claude/ (3), a.md, docs/one.md\n");
854
+ });
855
+ });
856
+
857
+ /** A claude settings overlay whose PreToolUse is a string, the example in the plan. */
858
+ const BAD_OVERLAY = JSON.stringify({ hooks: { PreToolUse: "x" } });
859
+ const SCHEMA_LINE =
860
+ "repo sync: .claude/settings.json: hooks.PreToolUse: Invalid input: expected array, received string\n";
861
+ const settings = (text: string): Generator[] =>
862
+ generating({ kind: "file", path: ".claude/settings.json", content: text, tracked: false, managed: "whole" });
863
+
864
+ describe("runSync output validation", () => {
865
+ it("names a schema miss with its dotted path, writes nothing and exits 1", async () => {
866
+ const root = fixture();
867
+ const before = snapshot(root);
868
+ const result = await syncWith(root, {
869
+ generators: [...generating(file("bunfig.toml", BUNFIG)), ...settings(BAD_OVERLAY)],
870
+ });
871
+ expect(result).toEqual({ code: 1, stdout: "", stderr: `${SCHEMA_LINE}repo sync: nothing written\n` });
872
+ expect(snapshot(root)).toEqual(before);
873
+ });
874
+
875
+ it("--check reports the same line and exits 1 without a nothing-written line", async () => {
876
+ const root = fixture();
877
+ const result = await syncWith(root, { generators: settings(BAD_OVERLAY) }, "--check");
878
+ expect(result).toEqual({ code: 1, stdout: "", stderr: SCHEMA_LINE });
879
+ });
880
+
881
+ it("--postinstall prints the lines and exits 0", async () => {
882
+ const root = fixture();
883
+ const result = await syncWith(root, { generators: settings(BAD_OVERLAY) }, "--postinstall");
884
+ expect(result).toEqual({ code: 0, stdout: "", stderr: `${SCHEMA_LINE}repo sync: nothing written\n` });
885
+ });
886
+
887
+ it("refuses an output path with no registered format", async () => {
888
+ const root = fixture();
889
+ const before = snapshot(root);
890
+ const result = await syncWith(root, { generators: generating(file("docs/x.yaml", "a: 1\n")) });
891
+ expect(result).toEqual({
892
+ code: 1,
893
+ stdout: "",
894
+ stderr:
895
+ "repo sync: docs/x.yaml: no output format registered; add it to cli/sync/validate.ts\nrepo sync: nothing written\n",
896
+ });
897
+ expect(snapshot(root)).toEqual(before);
898
+ });
899
+
900
+ it("accepts a valid overlay result", async () => {
901
+ const root = fixture();
902
+ const good = JSON.stringify({ hooks: { PreToolUse: [] } });
903
+ expect((await syncWith(root, { generators: settings(good) })).code).toBe(0);
904
+ expect(read(root, ".claude/settings.json")).toBe(good);
905
+ });
906
+
907
+ it("refuses a link that points nowhere", async () => {
908
+ const root = fixture();
909
+ const result = await syncWith(root, {
910
+ generators: generating(link(".claude/skills/ask", "../../.agents/skills/ask")),
911
+ });
912
+ expect(result.stderr).toBe(
913
+ "repo sync: .claude/skills/ask: link target ../../.agents/skills/ask does not exist\nrepo sync: nothing written\n",
914
+ );
915
+ });
916
+
917
+ it("refuses a real dir where a skill link goes and writes nothing", async () => {
918
+ // Only this repo skips adoption, which would otherwise move the dir to .repo/skills.
919
+ const root = fixture();
920
+ write(root, "package.json", JSON.stringify({ name: "@workerstack/repo" }));
921
+ write(root, ".claude/skills/ask/SKILL.md", "mine\n");
922
+ fs.mkdirSync(path.join(root, ".agents/skills/ask"), { recursive: true });
923
+ const before = snapshot(root);
924
+ const outputs = generating(file("bunfig.toml", BUNFIG), link(".claude/skills/ask", "../../.agents/skills/ask"));
925
+ expect(await syncWith(root, { generators: outputs })).toEqual({
926
+ code: 1,
927
+ stdout: "",
928
+ stderr:
929
+ "repo sync: .claude/skills/ask is a folder where sync puts a link; move it to .repo/skills/ask\nrepo sync: nothing written\n",
930
+ });
931
+ expect(snapshot(root)).toEqual(before);
932
+ });
933
+
934
+ it("restores everything when a write fails on the manifest", async () => {
935
+ const root = fixture();
936
+ await syncWith(root, only(file("old.txt")));
937
+ const before = snapshot(root);
938
+ const real = fs.renameSync;
939
+ vi.spyOn(fs, "renameSync").mockImplementation((from, to) => {
940
+ if (String(to).endsWith("sync.json")) throw new Error("EACCES: permission denied, rename");
941
+ return real(from, to);
942
+ });
943
+ const result = await syncWith(root, only(file("a.txt")));
944
+ expect(result).toEqual({
945
+ code: 1,
946
+ stdout: "",
947
+ stderr:
948
+ "repo sync: .repo/tmp/sync.json: cannot write: EACCES: permission denied, rename; restored 2 outputs, nothing changed\n",
949
+ });
950
+ vi.restoreAllMocks();
951
+ expect(snapshot(root)).toEqual(before);
952
+ });
953
+
954
+ it("--check still names a hand edit as out of date", async () => {
955
+ const root = fixture();
956
+ await sync(root);
957
+ write(root, "bunfig.toml", `${BUNFIG}# hand edit\n`);
958
+ expect(await sync(root, "--check")).toEqual({
959
+ code: 1,
960
+ stdout: "",
961
+ stderr: "repo sync: bunfig.toml is out of date; run `bun repo sync`\n",
962
+ });
963
+ });
964
+ });
965
+
966
+ describe("stray ignore files", () => {
967
+ const NESTED = "ignore rules live in the root .gitignore (or a public package's own); move them there";
968
+ const NPM = 'npm reads package.json "files"; delete it';
969
+ const NOTHING = "repo sync: nothing written\n";
970
+ const DEPS = "node" + "_modules";
971
+
972
+ /** The sync fixture as a git repo with a private package `packages/web` beside the public `packages/api`. */
973
+ function repoWith(files: Record<string, string>, { git = true } = {}): string {
974
+ const root = fixture();
975
+ write(root, "packages/web/package.json", JSON.stringify({ name: "web", private: true }));
976
+ write(root, "packages/web/references/INDEX.md", "# Web\n");
977
+ for (const [rel, text] of Object.entries(files)) write(root, rel, text);
978
+ if (git) execFileSync("git", ["init", "-q"], { cwd: root });
979
+ return root;
980
+ }
981
+
982
+ it("fails on a .gitignore in a private package and in src/, naming each, and writes nothing", async () => {
983
+ const root = repoWith({ "packages/web/.gitignore": "dist\n", "src/.gitignore": "x\n" });
984
+ const before = snapshot(root);
985
+ expect(await sync(root)).toEqual({
986
+ code: 1,
987
+ stdout: "",
988
+ stderr: `repo sync: packages/web/.gitignore: ${NESTED}\n` + `repo sync: src/.gitignore: ${NESTED}\n` + NOTHING,
989
+ });
990
+ expect(snapshot(root)).toEqual(before);
991
+ expect(exists(root, MANIFEST_REL)).toBe(false);
992
+ });
993
+
994
+ it("passes a .gitignore at the root and at a public package root", async () => {
995
+ const root = repoWith({ "packages/api/.gitignore": "dist\n" });
996
+ expect((await sync(root)).code).toBe(0);
997
+ });
998
+
999
+ it("fails on any .npmignore, at the root, in a public package and in a private one", async () => {
1000
+ const root = repoWith({ ".npmignore": "a\n", "packages/api/.npmignore": "a\n", "packages/web/.npmignore": "a\n" });
1001
+ expect(await sync(root)).toEqual({
1002
+ code: 1,
1003
+ stdout: "",
1004
+ stderr:
1005
+ `repo sync: .npmignore: ${NPM}\n` +
1006
+ `repo sync: packages/api/.npmignore: ${NPM}\n` +
1007
+ `repo sync: packages/web/.npmignore: ${NPM}\n` +
1008
+ NOTHING,
1009
+ });
1010
+ });
1011
+
1012
+ it("scans nothing outside a git repo", async () => {
1013
+ const root = repoWith({ "src/.gitignore": "x\n", ".npmignore": "a\n" }, { git: false });
1014
+ expect((await sync(root)).code).toBe(0);
1015
+ });
1016
+
1017
+ it("scans nothing when git fails", async () => {
1018
+ const root = repoWith({ "src/.gitignore": "x\n" }, { git: false });
1019
+ write(root, "." + "git", "not a repository\n");
1020
+ expect((await sync(root)).code).toBe(0);
1021
+ });
1022
+
1023
+ it("does not scan what git ignores, such as dependencies and an ignored dir", async () => {
1024
+ const root = repoWith({
1025
+ ".gitignore": `ignored/\n${DEPS}/\n`,
1026
+ [`${DEPS}/pkg/.npmignore`]: "a\n",
1027
+ "ignored/.gitignore": "x\n",
1028
+ });
1029
+ expect((await sync(root)).code).toBe(0);
1030
+ });
1031
+
1032
+ it("scans the repo as git sees it once sync has written the .gitignore block, so a new repo is not deadlocked", async () => {
1033
+ const root = repoWith({
1034
+ [`${DEPS}/x/.npmignore`]: "a\n",
1035
+ ".worktrees/w/.gitignore": "x\n",
1036
+ ".tmp/t/.gitignore": "x\n",
1037
+ });
1038
+ expect(fs.existsSync(path.join(root, ".gitignore"))).toBe(false);
1039
+ expect((await sync(root)).code).toBe(0);
1040
+ expect(read(root, ".gitignore")).toContain(`${DEPS}/\n`);
1041
+ });
1042
+
1043
+ it("does not report a tracked file that was deleted", async () => {
1044
+ const root = repoWith({ "src/.gitignore": "x\n" });
1045
+ execFileSync("git", ["add", "src/.gitignore"], { cwd: root });
1046
+ fs.rmSync(path.join(root, "src/.gitignore"));
1047
+ expect((await sync(root)).code).toBe(0);
1048
+ });
1049
+
1050
+ it("--check fails too, without the closing line", async () => {
1051
+ const root = repoWith({ "src/.gitignore": "x\n" });
1052
+ expect(await sync(root, "--check")).toEqual({
1053
+ code: 1,
1054
+ stdout: "",
1055
+ stderr: `repo sync: src/.gitignore: ${NESTED}\n`,
1056
+ });
1057
+ });
1058
+
1059
+ it("--postinstall prints the lines and exits 0, writing nothing", async () => {
1060
+ const root = repoWith({ "src/.gitignore": "x\n" });
1061
+ const before = snapshot(root);
1062
+ expect(await sync(root, "--postinstall")).toEqual({
1063
+ code: 0,
1064
+ stdout: "",
1065
+ stderr: `repo sync: src/.gitignore: ${NESTED}\n` + NOTHING,
1066
+ });
1067
+ expect(snapshot(root)).toEqual(before);
1068
+ });
1069
+
1070
+ it("lists a stray file before an invalid output's problem", async () => {
1071
+ const root = repoWith({ "src/.gitignore": "x\n" });
1072
+ const bad = { generators: generating(file("bad.json")), formats: [{ match: () => true, format: "json" as const }] };
1073
+ const result = await syncWith(root, bad);
1074
+ expect(result.code).toBe(1);
1075
+ expect(result.stderr.split("\n").slice(0, 2)).toEqual([
1076
+ `repo sync: src/.gitignore: ${NESTED}`,
1077
+ expect.stringMatching(/^repo sync: bad\.json: invalid JSON/),
1078
+ ]);
1079
+ expect(result.stderr.endsWith(NOTHING)).toBe(true);
1080
+ });
1081
+ });
1082
+
1083
+ describe("runSync from an old-format consumer", { timeout: 120_000 }, () => {
1084
+ const SELF_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
1085
+ const OVERVIEW = '---\nroot: true\ntargets: ["*"]\nglobs: ["**/*"]\n---\n# Mine\n\nOur overview.\n';
1086
+ const TESTING = '---\nroot: false\ntargets: ["*"]\nglobs:\n - src/**\n---\n# Testing\n\nRun the tests.\n';
1087
+
1088
+ /** A consumer on the stock package whose rules still carry root, targets and globs, with a `.grok/rules` file an earlier sync wrote. */
1089
+ function oldConsumer(): string {
1090
+ const root = tempDir();
1091
+ write(root, CONFIG_FILE, 'export default { packs: ["agents"], agents: { hosts: ["claude", "grok"] } }\n');
1092
+ write(root, "package.json", JSON.stringify({ name: "fixture" }));
1093
+ fs.mkdirSync(path.join(root, "node_modules", "@workerstack"), { recursive: true });
1094
+ fs.symlinkSync(SELF_ROOT, path.join(root, "node_modules", "@workerstack", "repo"), "dir");
1095
+ write(root, ".repo/rules/overview.md", OVERVIEW);
1096
+ write(root, ".repo/rules/testing.md", TESTING);
1097
+ write(root, ".grok/rules/testing.md", "Run the tests.\n");
1098
+ write(root, MANIFEST_REL, renderManifest([".grok/rules/testing.md"]));
1099
+ return root;
1100
+ }
1101
+
1102
+ const real = (cwd: string, ...args: string[]) => syncWith(cwd, {}, ...args);
1103
+
1104
+ /** Every file outside the linked package dir, with its bytes. */
1105
+ function files(root: string, dir = ""): Record<string, string> {
1106
+ const found: Record<string, string> = {};
1107
+ for (const entry of fs.readdirSync(path.join(root, dir), { withFileTypes: true })) {
1108
+ const rel = dir === "" ? entry.name : `${dir}/${entry.name}`;
1109
+ if (rel === "node_modules") continue;
1110
+ if (entry.isSymbolicLink()) found[rel] = `-> ${fs.readlinkSync(path.join(root, rel))}`;
1111
+ else if (entry.isDirectory()) Object.assign(found, files(root, rel));
1112
+ else found[rel] = fs.readFileSync(path.join(root, rel), "utf8");
1113
+ }
1114
+ return found;
1115
+ }
1116
+
1117
+ it("prints the rewrite and mark lines, then the summary with the removed .grok/rules file", async () => {
1118
+ const root = oldConsumer();
1119
+ const result = await real(root);
1120
+ expect(result.stderr).toBe("");
1121
+ expect(result.code).toBe(0);
1122
+ const lines = result.stdout.split("\n");
1123
+ expect(lines.slice(0, 3)).toEqual([
1124
+ "repo sync: rewrote .repo/rules/overview.md frontmatter (description added, root, targets and globs dropped)",
1125
+ "repo sync: marked .repo/rules/overview.md with overrides: overview (it replaces the stock rule)",
1126
+ "repo sync: rewrote .repo/rules/testing.md frontmatter (description added, globs → paths, root and targets dropped)",
1127
+ ]);
1128
+ expect(lines[3]).toMatch(/^repo sync: wrote .*AGENTS\.md.*; removed .*\.grok\/rules\/testing\.md/);
1129
+ expect(read(root, ".repo/rules/overview.md")).toBe(
1130
+ "---\ndescription: Mine\noverrides: overview\n---\n# Mine\n\nOur overview.\n",
1131
+ );
1132
+ expect(read(root, ".claude/rules/testing.md")).toBe("---\npaths:\n - src/**\n---\n# Testing\n\nRun the tests.\n");
1133
+ expect(read(root, "AGENTS.md")).toContain("- `src/**`: read `.repo/rules/testing.md`");
1134
+ expect(fs.existsSync(path.join(root, ".grok/rules/testing.md"))).toBe(false);
1135
+ });
1136
+
1137
+ it("converges: a second sync and --check have nothing to do", async () => {
1138
+ const root = oldConsumer();
1139
+ await real(root);
1140
+ const before = files(root);
1141
+ expect(await real(root)).toEqual({ code: 0, stdout: "repo sync: up to date\n", stderr: "" });
1142
+ expect(await real(root, "--check")).toEqual({ code: 0, stdout: "repo sync: up to date\n", stderr: "" });
1143
+ expect(files(root)).toEqual(before);
1144
+ });
1145
+
1146
+ it("--check counts the upgrade as migration steps and writes nothing", async () => {
1147
+ const root = oldConsumer();
1148
+ const before = files(root);
1149
+ expect(await real(root, "--check")).toEqual({
1150
+ code: 1,
1151
+ stdout: "",
1152
+ stderr: "repo sync: 2 migration steps pending; run `bun repo sync`\n",
1153
+ });
1154
+ expect(files(root)).toEqual(before);
1155
+ });
1156
+
1157
+ it("--check prints the problems without a nothing-written line", async () => {
1158
+ const root = oldConsumer();
1159
+ write(root, ".repo/rules/overview.md", "---\ndescription: new\n---\nB\n");
1160
+ write(root, ".repo/rules/testing.md", "---\ndescription: t\n---\nB\n");
1161
+ expect(await real(root, "--check")).toEqual({
1162
+ code: 1,
1163
+ stdout: "",
1164
+ stderr:
1165
+ "repo sync: .repo/rules/overview.md: a stock rule has this name; add `overrides: overview` or rename it\n",
1166
+ });
1167
+ });
1168
+
1169
+ it("--check says step, not steps, for a single pending upgrade", async () => {
1170
+ const root = oldConsumer();
1171
+ write(root, ".repo/rules/testing.md", "---\ndescription: t\n---\nB\n");
1172
+ expect((await real(root, "--check")).stderr).toBe("repo sync: 1 migration step pending; run `bun repo sync`\n");
1173
+ });
1174
+
1175
+ it("reports an upgrade that cannot write, and leaves the rule as it was", async () => {
1176
+ const root = oldConsumer();
1177
+ vi.spyOn(fs, "renameSync").mockImplementationOnce(() => {
1178
+ throw Object.assign(new Error("EACCES: permission denied, rename"), { code: "EACCES" });
1179
+ });
1180
+ const result = await real(root);
1181
+ expect(result.code).toBe(1);
1182
+ expect(result.stderr).toBe("repo sync: EACCES: permission denied, rename\n");
1183
+ expect(read(root, ".repo/rules/overview.md")).toBe(OVERVIEW);
1184
+ });
1185
+
1186
+ it("prints one line per problem when new rules clash with stock or name nothing", async () => {
1187
+ const root = oldConsumer();
1188
+ write(root, ".repo/rules/overview.md", "---\ndescription: new\n---\nB\n");
1189
+ write(root, ".repo/rules/testing.md", "---\ndescription: t\noverrides: nope\n---\nB\n");
1190
+ write(root, ".repo/rules/bad.md", "no fence\n");
1191
+ const before = files(root);
1192
+ const result = await real(root);
1193
+ expect(result.code).toBe(1);
1194
+ expect(result.stdout).toBe("");
1195
+ expect(result.stderr).toBe(
1196
+ [
1197
+ "repo sync: .repo/rules/bad.md: missing frontmatter (start the file with a --- block)",
1198
+ "repo sync: .repo/rules/overview.md: a stock rule has this name; add `overrides: overview` or rename it",
1199
+ 'repo sync: .repo/rules/testing.md: overrides "nope" but no stock rule has that name',
1200
+ "repo sync: nothing written",
1201
+ "",
1202
+ ].join("\n"),
1203
+ );
1204
+ expect(files(root)).toEqual(before);
1205
+ });
1206
+ });
1207
+
1208
+ describe("runSync over a .repo that breaks the closed layout", { timeout: 120_000 }, () => {
1209
+ const SELF_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
1210
+ const ALLOWED =
1211
+ "rules/, subagents/, skills/, lessons/, tmp/, global-settings/, settings/claude.json, hooks.jsonc, mcp-policy.jsonc, permissions.json, mcp.json, map.jsonc, lint-baseline.json (read by repo turn and repo check)";
1212
+ const LINES = [
1213
+ `.repo/notes.md: not part of .repo; allowed: ${ALLOWED}; explanations go in references/`,
1214
+ ".repo/scripts/: nothing reads it; move what you need out of .repo and delete it",
1215
+ '.repo/skills/deploy/SKILL.md: name "ship" must equal the folder name "deploy"',
1216
+ ".repo/skills/deploy/SKILL.md: link ./steps.md does not exist",
1217
+ '.repo/rules/api.md: paths "srv/**" matches no file',
1218
+ '.repo/rules/x.md: unknown frontmatter key "colour"; use description, paths, overrides',
1219
+ ]
1220
+ .map((line) => `repo sync: ${line}\n`)
1221
+ .join("");
1222
+ const NOTHING = "repo sync: nothing written\n";
1223
+
1224
+ function broken(): string {
1225
+ const root = tempDir();
1226
+ write(root, CONFIG_FILE, 'export default { packs: ["agents"], agents: { hosts: ["claude"] } }\n');
1227
+ write(root, "package.json", JSON.stringify({ name: "fixture" }));
1228
+ const scope = path.join(root, "node" + "_modules", "@workerstack");
1229
+ fs.mkdirSync(scope, { recursive: true });
1230
+ fs.symlinkSync(SELF_ROOT, path.join(scope, "repo"), "dir");
1231
+ write(root, ".repo/notes.md", "mine\n");
1232
+ write(root, ".repo/scripts/x.ts", "export {}\n");
1233
+ write(root, ".repo/skills/deploy/SKILL.md", "---\nname: ship\ndescription: d\n---\nSee [steps](./steps.md).\n");
1234
+ write(root, ".repo/rules/api.md", '---\ndescription: d\npaths: ["srv/**"]\n---\nApi.\n');
1235
+ write(root, ".repo/rules/x.md", "---\ndescription: d\ncolour: red\n---\nX.\n");
1236
+ execFileSync("git", ["init", "-q"], { cwd: root });
1237
+ return root;
1238
+ }
1239
+ const real = (cwd: string, ...args: string[]) => syncWith(cwd, {}, ...args);
1240
+ /** Every file outside the linked package dir and the repo's own metadata, with its bytes. */
1241
+ function tree(root: string, dir = ""): Record<string, string> {
1242
+ const found: Record<string, string> = {};
1243
+ for (const entry of fs.readdirSync(path.join(root, dir), { withFileTypes: true })) {
1244
+ const rel = dir === "" ? entry.name : `${dir}/${entry.name}`;
1245
+ if (rel === "node" + "_modules" || rel === "." + "git") continue;
1246
+ if (entry.isDirectory()) Object.assign(found, tree(root, rel));
1247
+ else found[rel] = fs.readFileSync(path.join(root, rel), "utf8");
1248
+ }
1249
+ return found;
1250
+ }
1251
+
1252
+ it("prints every problem in one refusal with nothing written, and exits 1", async () => {
1253
+ const root = broken();
1254
+ const before = tree(root);
1255
+ expect(await real(root)).toEqual({ code: 1, stdout: "", stderr: LINES + NOTHING });
1256
+ expect(tree(root)).toEqual(before);
1257
+ });
1258
+
1259
+ it("--check prints the same lines without nothing written, and exits 1", async () => {
1260
+ const root = broken();
1261
+ const before = tree(root);
1262
+ expect(await real(root, "--check")).toEqual({ code: 1, stdout: "", stderr: LINES });
1263
+ expect(tree(root)).toEqual(before);
1264
+ });
1265
+
1266
+ it("--postinstall prints the lines and exits 0", async () => {
1267
+ const result = await real(broken(), "--postinstall");
1268
+ expect(result.code).toBe(0);
1269
+ expect(result.stderr).toBe(LINES + NOTHING);
1270
+ });
1271
+ });
1272
+
1273
+ describe("runSync over a two-package workspace", { timeout: 120_000 }, () => {
1274
+ const SELF_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
1275
+ const rule = (extra: string, body: string) => `---\ndescription: d\n${extra}---\n${body}\n`;
1276
+
1277
+ function workspace(extra: Record<string, string> = {}): string {
1278
+ const root = tempDir();
1279
+ write(root, CONFIG_FILE, 'export default { packs: ["agents", "docs"], agents: { hosts: ["claude", "codex"] } }\n');
1280
+ write(root, "package.json", JSON.stringify({ name: "fixture", workspaces: ["packages/*"] }));
1281
+ const scope = path.join(root, "node" + "_modules", "@workerstack");
1282
+ fs.mkdirSync(scope, { recursive: true });
1283
+ fs.symlinkSync(SELF_ROOT, path.join(scope, "repo"), "dir");
1284
+ write(
1285
+ root,
1286
+ "packages/web/package.json",
1287
+ JSON.stringify({ name: "@acme/web", dependencies: { "@acme/api": "1.0.0", "@workerstack/ui": "1.0.0" } }),
1288
+ );
1289
+ write(root, "packages/web/references/INDEX.md", "# Web\n");
1290
+ write(root, "packages/api/package.json", JSON.stringify({ name: "@acme/api" }));
1291
+ write(root, "packages/api/references/INDEX.md", "# Api\n");
1292
+ write(root, "packages/web/src/api/a.ts", "export {}\n");
1293
+ write(
1294
+ root,
1295
+ "packages/web/.repo/rules/components.md",
1296
+ rule("", "Components live in src/components and use the ui kit."),
1297
+ );
1298
+ write(root, "packages/web/.repo/rules/api.md", rule("paths:\n - src/api/**\n", "Api."));
1299
+ write(
1300
+ root,
1301
+ "packages/web/.repo/skills/deploy/SKILL.md",
1302
+ "---\nname: deploy\ndescription: Ships web.\n---\nDeploy.\n",
1303
+ );
1304
+ for (const [rel, text] of Object.entries(extra)) write(root, rel, text);
1305
+ execFileSync("git", ["init", "-q"], { cwd: root });
1306
+ return root;
1307
+ }
1308
+ const real = (cwd: string, ...args: string[]) => syncWith(cwd, { post: [gitFiles] }, ...args);
1309
+
1310
+ it("writes the package files, links and deps, ignores them, and is then clean", async () => {
1311
+ const root = workspace();
1312
+ expect((await real(root)).code).toBe(0);
1313
+ expect(read(root, "packages/web/AGENTS.md")).toBe(
1314
+ "Start at references/INDEX.md in this package.\n\nComponents live in src/components and use the ui kit.\n\n## Rules by path\n- `packages/web/src/api/**`: read `packages/web/.repo/rules/api.md`\n",
1315
+ );
1316
+ expect(read(root, "packages/web/CLAUDE.md")).toBe("Start at references/INDEX.md in this package.\n");
1317
+ expect(read(root, ".claude/rules/packages/web/components.md")).toBe(
1318
+ "---\npaths:\n - packages/web/**\n---\nComponents live in src/components and use the ui kit.\n",
1319
+ );
1320
+ expect(linkText(path.join(root, "packages/web/.claude/skills/deploy"))).toBe("../../.repo/skills/deploy");
1321
+ expect(linkText(path.join(root, "packages/web/.agents/skills/deploy"))).toBe("../../.repo/skills/deploy");
1322
+ expect(read(root, "AGENTS.md")).not.toContain("Components live");
1323
+ expect(read(root, "packages/web/references/INDEX.md")).toBe(
1324
+ "# Web\n\n<!-- repo:deps -->\n- [@acme/api](../../api/references/INDEX.md)\n" +
1325
+ "- [@workerstack/ui](../../../node_modules/@workerstack/ui/references/INDEX.md)\n<!-- /repo:deps -->\n",
1326
+ );
1327
+ const ignored = execFileSync(
1328
+ "git",
1329
+ [
1330
+ "check-ignore",
1331
+ "packages/web/AGENTS.md",
1332
+ "packages/web/CLAUDE.md",
1333
+ "packages/web/.claude/skills/deploy",
1334
+ "packages/web/.agents/skills/deploy",
1335
+ "packages/api/AGENTS.md",
1336
+ ],
1337
+ { cwd: root, encoding: "utf8" },
1338
+ );
1339
+ expect(ignored.split("\n").filter(Boolean)).toHaveLength(5);
1340
+ expect(await real(root, "--check")).toEqual({ code: 0, stdout: "repo sync: up to date\n", stderr: "" });
1341
+ });
1342
+
1343
+ it("prints every package refusal in one go with nothing written", async () => {
1344
+ const root = workspace({
1345
+ "packages/web/.repo/lessons/a.md": "x\n",
1346
+ "packages/web/.repo/rules/x.md": rule('paths:\n - "../api/**"\n', "X."),
1347
+ "packages/web/.repo/rules/y.md": rule("overrides: overview\n", "Y."),
1348
+ ".repo/rules/packages/web/x.md": rule("paths:\n - packages/**\n", "Clash."),
1349
+ });
1350
+ expect(await real(root)).toEqual({
1351
+ code: 1,
1352
+ stdout: "",
1353
+ stderr:
1354
+ "repo sync: packages/web/.repo/lessons/: a package .repo holds only rules/ and skills/; lessons live in the root .repo\n" +
1355
+ 'repo sync: packages/web/.repo/rules/x.md: paths "../api/**" leaves the package; scope it inside packages/web\n' +
1356
+ "repo sync: packages/web/.repo/rules/y.md: overrides has no meaning in a package rule; remove it\n" +
1357
+ "repo sync: .claude/rules/packages/web/x.md: planned by .repo/rules/packages/web/x.md and packages/web/.repo/rules/x.md; rename one\n" +
1358
+ "repo sync: nothing written\n",
1359
+ });
1360
+ expect(fs.existsSync(path.join(root, "packages/web/AGENTS.md"))).toBe(false);
1361
+ });
1362
+ });
1363
+
1364
+ const hooksGit = (cwd: string, ...args: string[]) => spawnSync("git", args, { cwd, encoding: "utf8" });
1365
+ const hooksPath = (root: string) => hooksGit(root, "config", "--local", "--get", "core.hooksPath").stdout.trim();
1366
+ /**
1367
+ * A temp git repo with `packs`, its git kept from the user's global config by an empty one of its own, which
1368
+ * `global` writes to.
1369
+ */
1370
+ function hooksRepo(packs = '["standards"]') {
1371
+ const root = fs.realpathSync(tempDir());
1372
+ const globalConfig = path.join(tempDir(), "global.gitconfig");
1373
+ fs.writeFileSync(globalConfig, "");
1374
+ vi.stubEnv("GIT_CONFIG_GLOBAL", globalConfig);
1375
+ hooksGit(root, "init", "-q");
1376
+ write(root, "package.json", JSON.stringify({ name: "app" }));
1377
+ write(root, CONFIG_FILE, `export default { packs: ${packs} }\n`);
1378
+ return { root, global: (text: string) => fs.writeFileSync(globalConfig, text) };
1379
+ }
1380
+ const syncHooks = (root: string, ...args: string[]) => syncWith(root, { generators: [], post: [standards] }, ...args);
1381
+ const foreign = (value: string, scope = "") =>
1382
+ `repo sync: core.hooksPath is ${value}, so repo's hooks never run; unset it (git config ${scope}--unset core.hooksPath) and sync again\n`;
1383
+ const UP_TO_DATE = { code: 0, stdout: "repo sync: up to date\n", stderr: "" };
1384
+
1385
+ describe("runSync core.hooksPath", () => {
1386
+ it("points core.hooksPath at the hooks when it is unset, then leaves it", async () => {
1387
+ const { root } = hooksRepo();
1388
+ expect((await syncHooks(root)).stdout).toMatch(
1389
+ /; set core\.hooksPath to \.githooks(?:; marked .* executable in the index)?\n$/,
1390
+ );
1391
+ expect(hooksPath(root)).toBe(".githooks");
1392
+ expect(await syncHooks(root)).toEqual(UP_TO_DATE);
1393
+ hooksGit(root, "config", "--unset", "core.hooksPath");
1394
+ expect((await syncHooks(root, "--postinstall")).stdout).toBe("repo sync: set core.hooksPath to .githooks\n");
1395
+ expect(hooksPath(root)).toBe(".githooks");
1396
+ });
1397
+
1398
+ it("reports a foreign value on every run and changes nothing", async () => {
1399
+ const { root } = hooksRepo();
1400
+ hooksGit(root, "config", "core.hooksPath", ".husky");
1401
+ const reported = { code: 0, stdout: expect.any(String) as unknown, stderr: foreign(".husky") };
1402
+ expect(await syncHooks(root)).toEqual(reported);
1403
+ expect(await syncHooks(root, "--postinstall")).toEqual(reported);
1404
+ expect(hooksPath(root)).toBe(".husky");
1405
+ });
1406
+ });
1407
+
1408
+ describe("runSync core.hooksPath with the pack off", () => {
1409
+ it("unsets only its own value", async () => {
1410
+ const { root } = hooksRepo("[]");
1411
+ hooksGit(root, "config", "core.hooksPath", ".githooks");
1412
+ expect(await syncHooks(root)).toEqual({ code: 0, stdout: "repo sync: unset core.hooksPath\n", stderr: "" });
1413
+ expect(hooksGit(root, "config", "--local", "--get", "core.hooksPath").status).toBe(1);
1414
+ hooksGit(root, "config", "core.hooksPath", ".husky");
1415
+ expect(await syncHooks(root)).toEqual(UP_TO_DATE);
1416
+ expect(hooksPath(root)).toBe(".husky");
1417
+ });
1418
+
1419
+ it("leaves its own value alone when it comes from another scope than the repo's", async () => {
1420
+ const { root, global } = hooksRepo("[]");
1421
+ global("[core]\n\thooksPath = .githooks\n");
1422
+ expect(await syncHooks(root)).toEqual(UP_TO_DATE);
1423
+ });
1424
+ });
1425
+
1426
+ describe("runSync core.hooksPath where it does not apply", () => {
1427
+ it("--check never reads or writes it", async () => {
1428
+ const { root } = hooksRepo();
1429
+ await syncHooks(root);
1430
+ hooksGit(root, "config", "--unset", "core.hooksPath");
1431
+ expect(await syncHooks(root, "--check")).toEqual(UP_TO_DATE);
1432
+ hooksGit(root, "config", "core.hooksPath", ".husky");
1433
+ expect(await syncHooks(root, "--check")).toEqual(UP_TO_DATE);
1434
+ expect(hooksPath(root)).toBe(".husky");
1435
+ });
1436
+
1437
+ it("leaves the config of a repo it is nested in, whose toplevel is not the sync root", async () => {
1438
+ const { root: outer } = hooksRepo("[]");
1439
+ hooksGit(outer, "config", "core.hooksPath", ".githooks");
1440
+ const inner = path.join(outer, "inner");
1441
+ write(inner, "package.json", JSON.stringify({ name: "inner" }));
1442
+ write(inner, CONFIG_FILE, "export default { packs: [] }\n");
1443
+ expect(await syncHooks(inner)).toEqual(UP_TO_DATE);
1444
+ expect(hooksPath(outer)).toBe(".githooks");
1445
+ });
1446
+ });
1447
+
1448
+ describe("runSync core.hooksPath messages", () => {
1449
+ it("names the scope that holds a foreign value", async () => {
1450
+ const { root, global } = hooksRepo();
1451
+ global("[core]\n\thooksPath = ~/hooks\n");
1452
+ expect((await syncHooks(root)).stderr).toBe(foreign("~/hooks", "--global "));
1453
+ });
1454
+
1455
+ it("says when git cannot set it, and tries again on the next run", async () => {
1456
+ const { root } = hooksRepo();
1457
+ const fake = (_root: string, args: readonly string[]) => {
1458
+ if (args[0] === "rev-parse") return { status: 0, stdout: `${root}\n`, stderr: "" };
1459
+ if (args.includes("--get")) return { status: 1, stdout: "", stderr: "" };
1460
+ return { status: 255, stdout: "", stderr: "error: could not lock config file\n" };
1461
+ };
1462
+ const result = await syncWith(root, { generators: [], post: [standards], git: fake });
1463
+ expect(result).toMatchObject({
1464
+ code: 0,
1465
+ stderr: "repo sync: cannot set core.hooksPath to .githooks: error: could not lock config file\n",
1466
+ });
1467
+ expect((await syncHooks(root)).stdout).toMatch(
1468
+ /^repo sync: set core\.hooksPath to \.githooks(?:; marked .* executable in the index)?\n$/,
1469
+ );
1470
+ });
1471
+ });
1472
+
1473
+ describe("runSync hook modes", () => {
1474
+ // The next test covers win32, which has no execute bits to lose.
1475
+ it.skipIf(process.platform === "win32")(
1476
+ "rewrites a hook that lost its execute bits, which --check reports",
1477
+ async () => {
1478
+ const { root } = hooksRepo();
1479
+ await syncHooks(root);
1480
+ fs.chmodSync(path.join(root, ".githooks/pre-commit"), 0o644);
1481
+ const stale = "repo sync: .githooks/pre-commit is out of date; run `bun repo sync`\n";
1482
+ expect((await syncHooks(root, "--check")).stderr).toBe(stale);
1483
+ await syncHooks(root);
1484
+ expect(fs.statSync(path.join(root, ".githooks/pre-commit")).mode & 0o777).toBe(0o755);
1485
+ },
1486
+ );
1487
+
1488
+ it("does not compare execute bits on win32, which has none", async () => {
1489
+ const { root } = hooksRepo();
1490
+ await syncHooks(root);
1491
+ fs.chmodSync(path.join(root, ".githooks/pre-commit"), 0o644);
1492
+ const win32 = { generators: [], post: [standards], platform: "win32" as const };
1493
+ expect(await syncWith(root, win32, "--check")).toEqual(UP_TO_DATE);
1494
+ });
1495
+ });