@joshuafolkken/kit 1.1387.0 → 1.1453.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 (278) hide show
  1. package/.claude/skills/epic-commands/SKILL.md +1 -1
  2. package/.claude/skills/epic-commands/epic-bundle.md +5 -0
  3. package/.claude/skills/workflow-commands/backlogrun-lanes.md +5 -3
  4. package/.claude/skills/workflow-commands/backlogrun-park.md +2 -0
  5. package/.claude/skills/workflow-commands/backlogrun-steps.md +13 -10
  6. package/.claude/skills/workflow-commands/backlogrun.md +3 -2
  7. package/.claude/skills/workflow-commands/chain-rule.md +4 -2
  8. package/.claude/skills/workflow-commands/fullrun-steps.md +1 -1
  9. package/.claude/skills/workflow-commands/halfrun.md +1 -1
  10. package/.claude/skills/workflow-commands/issue-comments.md +6 -0
  11. package/.claude/skills/workflow-commands/kickoff.md +12 -13
  12. package/.claude/skills/workflow-commands/observation-filing.md +1 -1
  13. package/.claude/skills/workflow-commands/observation-ledger.md +3 -2
  14. package/.claude/skills/workflow-commands/pre-gate-cut.md +5 -3
  15. package/.claude/skills/workflow-commands/prerequisite.md +6 -0
  16. package/.claude/skills/workflow-commands/retrospective.md +5 -5
  17. package/.claude/skills/workflow-commands/split-assessment.md +2 -2
  18. package/README.md +27 -4
  19. package/dist/hooks/audit-provision.js +2 -2
  20. package/dist/hooks/batch-guard.js +7 -7
  21. package/dist/hooks/{chunk-2YSW3CXH.js → chunk-3C4XRYG6.js} +35 -11
  22. package/dist/hooks/{chunk-GJ7JPIHN.js → chunk-5C7L6FRC.js} +2704 -190
  23. package/dist/hooks/{chunk-FZ6FUSBM.js → chunk-723HKZO5.js} +1 -1
  24. package/dist/hooks/{chunk-STDSEPWU.js → chunk-D3NJXN5M.js} +4 -4
  25. package/dist/hooks/{chunk-NEC4AT4Z.js → chunk-DGT54IH5.js} +4 -0
  26. package/dist/hooks/{chunk-LMWBRJIP.js → chunk-EF5GQ6WS.js} +3 -3
  27. package/dist/hooks/{chunk-JBBMAPC4.js → chunk-IX6TTTI6.js} +4 -0
  28. package/dist/hooks/{chunk-IYVOLN6A.js → chunk-LBFYEOXJ.js} +3 -3
  29. package/dist/hooks/{chunk-SL4GQ2XP.js → chunk-MBJYQPEK.js} +106 -23
  30. package/dist/hooks/{chunk-WNFJLBXI.js → chunk-NO4AALP5.js} +3 -3
  31. package/dist/hooks/{chunk-J3KSS7II.js → chunk-OLMO72ON.js} +1 -1
  32. package/dist/hooks/{chunk-DCY2GWHC.js → chunk-SHYUQOIS.js} +5 -5
  33. package/dist/hooks/{chunk-EAQEGME7.js → chunk-UT7YC3N2.js} +1 -1
  34. package/dist/hooks/{chunk-EBSHN25W.js → chunk-W5ZKGTSY.js} +4 -4
  35. package/dist/hooks/{chunk-GNDJ7NJ7.js → chunk-Y3GKIKGW.js} +209 -56
  36. package/dist/hooks/{chunk-U32VSRNG.js → chunk-Z4XI7CZS.js} +89 -34
  37. package/dist/hooks/codex-hook-adapter.js +15 -15
  38. package/dist/hooks/duplicate-read-guard.js +8 -8
  39. package/dist/hooks/format-edited.js +5 -5
  40. package/dist/hooks/inputs.json +261 -201
  41. package/dist/hooks/investigation-guard.js +8 -8
  42. package/dist/hooks/pretool-guard.js +14 -14
  43. package/dist/hooks/session-lang.js +69 -16
  44. package/dist/hooks/stop-guard.js +13 -38
  45. package/dist/josh.js +95 -19
  46. package/package.json +4 -3
  47. package/prompts/collaboration-workflow/issue-template.md +10 -8
  48. package/prompts/review.md +30 -4
  49. package/scripts/agent/claude-agent-argv.ts +13 -1
  50. package/scripts/auth/auth-setup-cli.ts +101 -0
  51. package/scripts/auth/claude-token-output.ts +78 -0
  52. package/scripts/auth/claude-token.ts +68 -0
  53. package/scripts/auto-ok/auto-ok-judge-cli.ts +125 -0
  54. package/scripts/auto-ok/auto-ok-judge-read.ts +95 -0
  55. package/scripts/auto-ok/auto-ok-judge-write.ts +109 -0
  56. package/scripts/auto-ok/auto-ok-judge.ts +67 -0
  57. package/scripts/backlog/backlog-drive-cli.ts +14 -0
  58. package/scripts/backlog/backlog-drive-epic.ts +24 -8
  59. package/scripts/backlog/backlog-drive-launch.ts +3 -0
  60. package/scripts/backlog/backlog-drive-named-offer.ts +7 -2
  61. package/scripts/backlog/backlog-drive-named.ts +4 -2
  62. package/scripts/backlog/backlog-drive-unread.ts +56 -0
  63. package/scripts/backlog/backlog-drive.ts +38 -11
  64. package/scripts/backlog/backlog-overlap.ts +22 -5
  65. package/scripts/backlog/backlog-ready.ts +1 -3
  66. package/scripts/backlog/backlogrun-args.ts +13 -2
  67. package/scripts/backlog/backlogrun-cli.ts +91 -19
  68. package/scripts/backlog/backlogrun-launch.ts +24 -0
  69. package/scripts/backlog/backlogrun-replace.ts +166 -0
  70. package/scripts/backlog/backlogrun-stop.ts +178 -0
  71. package/scripts/behavior/behavior-cli.ts +6 -1
  72. package/scripts/doctor/doctor-io.ts +60 -9
  73. package/scripts/doctor/doctor.ts +20 -1
  74. package/scripts/document/document-byte-budget.ts +3 -3
  75. package/scripts/epic/epic-bundle-cli.ts +8 -8
  76. package/scripts/epic/epic-bundle-referenced.ts +2 -5
  77. package/scripts/epic/epic-bundle.ts +39 -4
  78. package/scripts/exports/unused-members-cli.ts +2 -1
  79. package/scripts/followup/followup-checks-event.ts +82 -0
  80. package/scripts/followup/git-followup-finish.ts +16 -2
  81. package/scripts/followup/git-followup-workflow.ts +5 -2
  82. package/scripts/followup/git-pr-followup-checks.ts +18 -4
  83. package/scripts/followup/git-pr-followup-wrapup.ts +5 -0
  84. package/scripts/followup/git-pr-followup.ts +4 -1
  85. package/scripts/fresh-machine/fresh-machine-cli.ts +204 -0
  86. package/scripts/fresh-machine/fresh-machine-docker.ts +65 -0
  87. package/scripts/fresh-machine/fresh-machine-image.ts +62 -0
  88. package/scripts/fresh-machine/fresh-machine-log.ts +61 -0
  89. package/scripts/fresh-machine/fresh-machine-patterns.ts +175 -0
  90. package/scripts/fresh-machine/fresh-machine-run.ts +215 -0
  91. package/scripts/fresh-machine/fresh-machine-script.ts +45 -0
  92. package/scripts/fresh-machine/fresh-machine-spec.ts +57 -0
  93. package/scripts/fresh-machine/specs/kit-test-backlogrun-python.json +34 -0
  94. package/scripts/fresh-machine/specs/kit-test-backlogrun.json +31 -0
  95. package/scripts/gate/gate-report.ts +70 -15
  96. package/scripts/gate/project-checks.ts +18 -4
  97. package/scripts/gate/scoped-green.ts +2 -1
  98. package/scripts/gate/verification-gate.ts +4 -3
  99. package/scripts/gh/gh-author-association.ts +14 -0
  100. package/scripts/gh/git-gh-conditional.ts +66 -0
  101. package/scripts/gh/git-gh-exec.ts +1 -1
  102. package/scripts/gh/git-gh-issue-read.ts +6 -3
  103. package/scripts/gh/git-gh-issue-rest.ts +1 -0
  104. package/scripts/gh/git-gh-issue-write.ts +5 -1
  105. package/scripts/gh/git-gh-issue.ts +13 -1
  106. package/scripts/gh/git-pr-checks-eval.ts +13 -0
  107. package/scripts/gh/git-pr-checks-parse.ts +8 -1
  108. package/scripts/gh/git-pr-checks-watch.ts +11 -4
  109. package/scripts/gh/git-pr-checks.ts +36 -2
  110. package/scripts/git/git-command.ts +16 -4
  111. package/scripts/git/git-schemas.ts +1 -0
  112. package/scripts/git/git-spawn-sync.ts +1 -1
  113. package/scripts/hooks/pretool-guard.ts +3 -1
  114. package/scripts/init/hook-command-rewrite.ts +31 -0
  115. package/scripts/init/init-actions.ts +7 -4
  116. package/scripts/init/init-ai-copy.ts +34 -11
  117. package/scripts/init/init-basic.ts +9 -4
  118. package/scripts/init/init-bootstrap.ts +6 -2
  119. package/scripts/init/init-dry-run-report.ts +150 -0
  120. package/scripts/init/init-dry-run.ts +189 -0
  121. package/scripts/init/init-install.ts +8 -1
  122. package/scripts/init/init-logic-toolchain.ts +76 -0
  123. package/scripts/init/init-logic.ts +2 -0
  124. package/scripts/init/init.ts +62 -42
  125. package/scripts/init/kit-setup-state.ts +11 -17
  126. package/scripts/init/own-formatter.ts +21 -0
  127. package/scripts/init/project-profile.ts +60 -12
  128. package/scripts/init/start-dry-run.ts +70 -0
  129. package/scripts/init/start-exec.ts +27 -1
  130. package/scripts/init/start-plan.ts +23 -3
  131. package/scripts/init/start-setup-pr.ts +13 -24
  132. package/scripts/init/start-steps.ts +62 -1
  133. package/scripts/init/start.ts +38 -17
  134. package/scripts/init/workflow-mark.ts +35 -0
  135. package/scripts/init/working-tree.ts +116 -0
  136. package/scripts/issue/issue-ack-cli.ts +84 -0
  137. package/scripts/issue/issue-auto-ok.ts +68 -99
  138. package/scripts/issue/issue-file-cli.ts +17 -17
  139. package/scripts/issue/issue-file.ts +1 -6
  140. package/scripts/issue/issue-labels.ts +46 -10
  141. package/scripts/issue/issue-release-cli.ts +69 -23
  142. package/scripts/issue/issue-release.ts +3 -1
  143. package/scripts/issue/issue-state-cli.ts +14 -4
  144. package/scripts/issue/issue-watch-diff.ts +152 -0
  145. package/scripts/issue/issue-watch-gate.ts +81 -0
  146. package/scripts/issue/issue-watch-hook.ts +54 -0
  147. package/scripts/issue/issue-watch.ts +240 -0
  148. package/scripts/issue/measure-rerun-cli.ts +3 -4
  149. package/scripts/josh/josh-command-map.ts +4 -0
  150. package/scripts/josh/josh-commands-ai.ts +11 -0
  151. package/scripts/josh/josh-commands-backlog.ts +2 -2
  152. package/scripts/josh/josh-commands-development.ts +1 -1
  153. package/scripts/josh/josh-commands-issue.ts +10 -3
  154. package/scripts/josh/josh-commands-lane.ts +11 -2
  155. package/scripts/josh/josh-commands-project.ts +26 -2
  156. package/scripts/josh/josh-verdict.ts +33 -4
  157. package/scripts/josh/process-identity.ts +32 -6
  158. package/scripts/lane/claude-lane-broker.ts +142 -0
  159. package/scripts/lane/lane-await.ts +6 -2
  160. package/scripts/lane/lane-broker-server.ts +69 -0
  161. package/scripts/lane/lane-cache.ts +37 -7
  162. package/scripts/lane/lane-capacity.ts +8 -6
  163. package/scripts/lane/lane-child-invocation.ts +1 -0
  164. package/scripts/lane/lane-cli.ts +15 -1
  165. package/scripts/lane/lane-close.ts +15 -6
  166. package/scripts/lane/lane-dispatch.ts +36 -10
  167. package/scripts/lane/lane-environment.ts +1 -1
  168. package/scripts/lane/lane-launch-cli.ts +13 -4
  169. package/scripts/lane/lane-ledger.ts +21 -3
  170. package/scripts/lane/lane-measure-cli.ts +10 -2
  171. package/scripts/lane/lane-open.ts +9 -1
  172. package/scripts/lane/lane-preserve.ts +49 -0
  173. package/scripts/lane/lane-reap.ts +20 -5
  174. package/scripts/lane/lane-registry.ts +6 -0
  175. package/scripts/lane/lane-restart-cli.ts +51 -0
  176. package/scripts/lane/lane-restart.ts +175 -0
  177. package/scripts/lane/lane-session-broker.ts +363 -0
  178. package/scripts/lane/lane-spent.ts +195 -0
  179. package/scripts/lane/lane-stage-stats.ts +27 -3
  180. package/scripts/lane/lane-stop-stats.ts +96 -0
  181. package/scripts/lane/openai-lane-supervisor.ts +2 -2
  182. package/scripts/lane/process-tools.ts +31 -0
  183. package/scripts/lib/buffered-process.ts +36 -2
  184. package/scripts/lib/pnpm-reach.ts +27 -0
  185. package/scripts/lib/status-icons.ts +5 -1
  186. package/scripts/lint/lint-parallel.ts +19 -2
  187. package/scripts/observations/observation-ledger-home.ts +1 -0
  188. package/scripts/observations/observation-ledger-migrate.ts +3 -15
  189. package/scripts/release/release-issue-close.ts +133 -0
  190. package/scripts/release/release-publish.ts +12 -1
  191. package/scripts/repo/repository-labels.ts +11 -1
  192. package/scripts/review/comment-ranges.ts +199 -0
  193. package/scripts/review/review-attest-cli.ts +21 -6
  194. package/scripts/review/review-brief-cli.ts +24 -7
  195. package/scripts/review/review-code-tree.ts +119 -0
  196. package/scripts/review/review-comment-round.ts +114 -0
  197. package/scripts/review/review-record-cli.ts +39 -17
  198. package/scripts/review/review-round2-cli.ts +16 -5
  199. package/scripts/review/review-round2.ts +30 -10
  200. package/scripts/review/review-stamps.ts +22 -1
  201. package/scripts/rules/implementation-cut.ts +3 -3
  202. package/scripts/rules/pre-gate-cut.ts +1 -1
  203. package/scripts/run/board/run-board-checks.ts +42 -0
  204. package/scripts/run/board/run-board-cli.ts +2 -0
  205. package/scripts/run/board/run-board-every.ts +3 -1
  206. package/scripts/run/board/run-board-fit.ts +16 -5
  207. package/scripts/run/board/run-board-fresh.ts +64 -0
  208. package/scripts/run/board/run-board-header.ts +129 -53
  209. package/scripts/run/board/run-board-labels.ts +47 -4
  210. package/scripts/run/board/run-board-lane-event.ts +66 -0
  211. package/scripts/run/board/run-board-layout.ts +57 -9
  212. package/scripts/run/board/run-board-now.ts +234 -0
  213. package/scripts/run/board/run-board-operation-read.ts +75 -0
  214. package/scripts/run/board/run-board-operation.ts +150 -0
  215. package/scripts/run/board/run-board-phase.ts +26 -1
  216. package/scripts/run/board/run-board-plan.ts +4 -6
  217. package/scripts/run/board/run-board-read.ts +49 -3
  218. package/scripts/run/board/run-board-reason.ts +124 -0
  219. package/scripts/run/board/run-board-render-checks.ts +55 -0
  220. package/scripts/run/board/run-board-render-legend.ts +12 -3
  221. package/scripts/run/board/run-board-render-slot.ts +2 -2
  222. package/scripts/run/board/run-board-render-tail.ts +128 -0
  223. package/scripts/run/board/run-board-render-thanks.ts +50 -0
  224. package/scripts/run/board/run-board-render-title.ts +58 -0
  225. package/scripts/run/board/run-board-render.ts +96 -80
  226. package/scripts/run/board/run-board-row-yield.ts +32 -0
  227. package/scripts/run/board/run-board-setup.ts +61 -13
  228. package/scripts/run/board/run-board-state.ts +10 -0
  229. package/scripts/run/board/run-board-status.ts +66 -7
  230. package/scripts/run/board/run-board-thanks-csv.ts +71 -0
  231. package/scripts/run/board/run-board-thanks-read.ts +112 -0
  232. package/scripts/run/board/run-board-thanks.ts +53 -0
  233. package/scripts/run/board/run-board-tick.ts +50 -21
  234. package/scripts/run/board/run-board-usual.ts +44 -0
  235. package/scripts/run/carry/run-carry-args.ts +9 -1
  236. package/scripts/run/carry/run-carry-cli.ts +2 -45
  237. package/scripts/run/carry/run-carry-end.ts +73 -0
  238. package/scripts/run/carry/run-carry.ts +8 -1
  239. package/scripts/run/entry/run-entry-cli.ts +5 -0
  240. package/scripts/run/event/run-event-checks.ts +54 -0
  241. package/scripts/run/event/run-event-render.ts +2 -0
  242. package/scripts/run/event/run-event-scope.ts +1 -0
  243. package/scripts/run/event/run-event-setup.ts +55 -11
  244. package/scripts/run/event/run-event-stream.ts +27 -11
  245. package/scripts/run/merge/run-merge-cli.ts +7 -4
  246. package/scripts/run/merge/run-merge-ship.ts +61 -0
  247. package/scripts/run/merge/run-merge-steps.ts +7 -14
  248. package/scripts/run/merge/run-merge-token.ts +16 -2
  249. package/scripts/run/run-headless.ts +1 -0
  250. package/scripts/run/run-step.ts +2 -0
  251. package/scripts/run/ship/run-ship-cli.ts +22 -2
  252. package/scripts/run/ship/run-ship-return.ts +31 -13
  253. package/scripts/run/ship/run-ship-review-steps.ts +17 -4
  254. package/scripts/run/ship/run-ship-stage.ts +1 -0
  255. package/scripts/run/wake/run-wake-await.ts +130 -0
  256. package/scripts/run/wake/run-wake-cli.ts +60 -71
  257. package/scripts/run/wake/run-wake-describe.ts +34 -2
  258. package/scripts/run/wake/run-wake-driver.ts +11 -4
  259. package/scripts/run/wake/run-wake-session.ts +20 -3
  260. package/scripts/run/wake/run-wake-stop.ts +37 -0
  261. package/scripts/run/wake/run-wake-supervise.ts +44 -0
  262. package/scripts/sync/claude-settings-merge.ts +144 -0
  263. package/scripts/sync/sync-ai-files.ts +28 -3
  264. package/scripts/sync/sync.ts +15 -0
  265. package/scripts/test/project-test-script.ts +73 -0
  266. package/scripts/test/skip-marker.ts +14 -1
  267. package/scripts/test/test-related.ts +3 -2
  268. package/scripts/test/test-unit-guard.ts +78 -14
  269. package/scripts/time-runtime/time-reported-failure.ts +11 -3
  270. package/scripts/uninstall/distributed-files.ts +92 -0
  271. package/scripts/uninstall/uninstall-plan.ts +119 -0
  272. package/scripts/uninstall/uninstall-report.ts +61 -0
  273. package/scripts/uninstall/uninstall.ts +206 -0
  274. package/templates/AGENTS.basic.md +4 -0
  275. package/templates/GEMINI.basic.md +6 -0
  276. package/dist/hooks/chunk-6V5OPIUU.js +0 -13
  277. package/scripts/lane/openai-review-broker.ts +0 -262
  278. package/scripts/run/board/run-board-track.ts +0 -22
@@ -0,0 +1,70 @@
1
+ import path from 'node:path'
2
+ import { repository_labels } from '#scripts/repo/repository-labels'
3
+ import { init_dry_run } from './init-dry-run'
4
+ import type { ProjectProfile } from './project-profile'
5
+ import type { StartStep } from './start-plan'
6
+ import type { Choices } from './start-prompt'
7
+ import { start_setup_pr } from './start-setup-pr'
8
+ import { workflow_mark } from './workflow-mark'
9
+
10
+ // What `josh start --dry-run` adds to the plan `josh start` prints: what each GitHub step would
11
+ // create, and what its setup step would write. Nothing here creates, pushes or writes (#3830).
12
+
13
+ const TITLE = 'josh start --dry-run — nothing was written. Its setup step would:'
14
+ const NONE = '(none)'
15
+ const UNREAD_LABELS =
16
+ 'add the workflow labels it lacks — the repository’s labels could not be read to name them'
17
+
18
+ // The labels are read from the repository when it already exists — the same listing the real step
19
+ // compares against — and a repository this run would create has none.
20
+ function label_line(steps: ReadonlyArray<StartStep>): string {
21
+ const repo = steps.includes('repository') ? undefined : '{owner}/{repo}'
22
+ const labels = repository_labels.planned_labels(repo)
23
+
24
+ if (labels === undefined) return UNREAD_LABELS
25
+
26
+ return `add the labels it lacks: ${labels.length === 0 ? NONE : labels.join(', ')}`
27
+ }
28
+
29
+ function github_lines({ steps, root, options }: Choices): Array<string> {
30
+ const lines: Readonly<Partial<Record<StartStep, string | undefined>>> = {
31
+ repository: `create the ${options.visibility} repository ${path.basename(root)} as origin, and push main`,
32
+ labels: steps.includes('labels') ? label_line(steps) : undefined,
33
+ setup_pr: `open the Issue "${start_setup_pr.SETUP_TITLE}" (or reuse the open one), push a branch named after it, and open a pull request into main`,
34
+ }
35
+
36
+ return steps.map((step) => lines[step]).filter((line) => line !== undefined)
37
+ }
38
+
39
+ function github_report(choices: Choices): string {
40
+ const lines = github_lines(choices).map((line) => ` ${line}`)
41
+
42
+ return ['GitHub:', ...(lines.length === 0 ? [` ${NONE}`] : lines)].join('\n')
43
+ }
44
+
45
+ // A caller's setup command writes files kit cannot name, so it is named rather than simulated. Kit's
46
+ // own setup runs on a scratch copy, told it sets up the GitHub workflow as the real step tells it,
47
+ // with the repository the run would have created by then.
48
+ function setup_report({ steps, root, options, shape }: Choices): string | undefined {
49
+ if (!steps.includes('initialize')) return undefined
50
+ const profile: ProjectProfile = options.profile ?? shape.profile
51
+
52
+ if (options.init_command !== undefined) {
53
+ return `The setup step would run ${options.init_command} --profile ${profile}, which is not simulated.`
54
+ }
55
+
56
+ workflow_mark.request()
57
+ const has_git = shape.has_git || steps.includes('git_init')
58
+
59
+ return init_dry_run.plan_report({ root, profile, has_git, is_install: true }, TITLE)
60
+ }
61
+
62
+ function report(choices: Choices): string {
63
+ const setup = setup_report(choices)
64
+ const sections = [github_report(choices), ...(setup === undefined ? [] : [setup])]
65
+
66
+ return `\n${sections.join('\n\n')}`
67
+ }
68
+
69
+ const start_dry_run = { report }
70
+ export { start_dry_run }
@@ -1,6 +1,6 @@
1
1
  import { git_gh_exec } from '#scripts/gh/git-gh-exec'
2
2
  import { git_location_environment } from '#scripts/git/git-location-environment'
3
- import { git_spawn_sync } from '#scripts/git/git-spawn-sync'
3
+ import { git_spawn_sync, type GitSyncOptions } from '#scripts/git/git-spawn-sync'
4
4
  import { COMMAND_TIMEOUT_MS, SUITE_TIMEOUT_MS } from '#scripts/lib/timeouts'
5
5
  import { execaSync } from 'execa'
6
6
  import type { Visibility } from './start-plan'
@@ -16,6 +16,8 @@ import type { Visibility } from './start-plan'
16
16
  // project's own commit and push hooks, which may run its whole suite, so it takes the suite budget:
17
17
  // long enough for that, short enough that a push that never answers ends the run.
18
18
  const SUCCESS_EXIT_CODE = 0
19
+ const GIT_DIRECTORY = '.git'
20
+ const IDENTITY_VARIABLES = ['GIT_AUTHOR_IDENT', 'GIT_COMMITTER_IDENT']
19
21
 
20
22
  function root_environment(): Record<string, string | undefined> {
21
23
  return git_location_environment.location_free_environment()
@@ -29,6 +31,29 @@ function git_read(args: ReadonlyArray<string>, root: string): string | undefined
29
31
  return git_spawn_sync.read(args, { cwd: root, env: root_environment() })
30
32
  }
31
33
 
34
+ // Asked of git itself, so the answer is the one its commit would reach: config, the environment and
35
+ // the identity git can detect all count (#3754).
36
+ //
37
+ // **Only an answer from git counts as a missing identity.** A git that could not be started or never
38
+ // answered is a different failure, which the first step that runs git reports as what it is.
39
+ function is_identity_refused(variable: string, options: GitSyncOptions): boolean {
40
+ const { exit_code } = git_spawn_sync.run(['var', variable], options)
41
+
42
+ return exit_code !== undefined && exit_code !== SUCCESS_EXIT_CODE
43
+ }
44
+
45
+ // A plan that creates the repository asks from where it will be: an identity given per directory
46
+ // (`includeIf "gitdir:…"`) is matched against the git directory, which no `cwd` supplies before
47
+ // `git init` has run. The path stays relative to `root` so git resolves it as its commit will — by
48
+ // the path the user reached the directory through as well as the physical one, which is what a
49
+ // `gitdir:` under a symlinked parent is written against.
50
+ function lacks_git_identity(root: string, is_new_repository: boolean): boolean {
51
+ const location = is_new_repository ? { GIT_DIR: GIT_DIRECTORY } : {}
52
+ const options = { cwd: root, env: { ...root_environment(), ...location } }
53
+
54
+ return IDENTITY_VARIABLES.some((variable) => is_identity_refused(variable, options))
55
+ }
56
+
32
57
  function git_run(args: ReadonlyArray<string>, root: string): void {
33
58
  const options = { cwd: root, env: root_environment(), timeout: SUITE_TIMEOUT_MS }
34
59
  const { exit_code } = git_spawn_sync.run(args, { ...options, stdio: 'inherit' })
@@ -86,6 +111,7 @@ const start_exec = {
86
111
  git_succeeds,
87
112
  git_read,
88
113
  git_run,
114
+ lacks_git_identity,
89
115
  is_gh_installed,
90
116
  is_gh_signed_in,
91
117
  create_github_repository,
@@ -7,6 +7,7 @@ interface StartOptions {
7
7
  profile: ProjectProfile | undefined
8
8
  is_yes: boolean
9
9
  is_github: boolean
10
+ is_dry_run: boolean
10
11
  visibility: Visibility
11
12
  init_command: string | undefined
12
13
  push_preflight_command: string | undefined
@@ -58,6 +59,9 @@ const STEP_LABELS: Readonly<Record<StartStep, string>> = {
58
59
  // so they are also the steps a caller's push preflight guards (#3702).
59
60
  const GITHUB_WRITE_STEPS: ReadonlySet<StartStep> = new Set(['repository', 'setup_pr'])
60
61
 
62
+ // The steps that create a commit, which git refuses without an author identity (#3754).
63
+ const COMMIT_STEPS: ReadonlySet<StartStep> = new Set(['commit', 'setup_pr'])
64
+
61
65
  function refused(refusal: string): StepPlan {
62
66
  return { steps: [], refusal }
63
67
  }
@@ -113,8 +117,13 @@ function has_push(steps: ReadonlyArray<StartStep>): boolean {
113
117
  return steps.some((step) => GITHUB_WRITE_STEPS.has(step))
114
118
  }
115
119
 
120
+ function has_commit(steps: ReadonlyArray<StartStep>): boolean {
121
+ return steps.some((step) => COMMIT_STEPS.has(step))
122
+ }
123
+
124
+ // A dry run asks nothing and changes nothing, so it needs neither a terminal nor `--yes`.
116
125
  function interaction_of(options: StartOptions, is_tty: boolean): Verdict<Interaction> {
117
- if (options.is_yes) return { value: 'unattended', refusal: undefined }
126
+ if (options.is_yes || options.is_dry_run) return { value: 'unattended', refusal: undefined }
118
127
  if (is_tty) return { value: 'interactive', refusal: undefined }
119
128
 
120
129
  return {
@@ -126,13 +135,16 @@ function interaction_of(options: StartOptions, is_tty: boolean): Verdict<Interac
126
135
 
127
136
  // `--yes` accepts the detected defaults, never the creation of a public-facing resource: an
128
137
  // unattended run creates a repository, pushes, or opens the setup Issue and pull request only when
129
- // `--github` asks for it by name.
138
+ // `--github` asks for it by name. A dry run writes to GitHub no more than it writes anywhere else, so
139
+ // it lists the GitHub steps without that consent.
130
140
  function github_consent(
131
141
  steps: ReadonlyArray<StartStep>,
132
142
  options: StartOptions,
133
143
  interaction: Interaction,
134
144
  ): Verdict<Consent> {
135
- if (!has_push(steps) || options.is_github) return { value: 'granted', refusal: undefined }
145
+ const is_consented = options.is_github || options.is_dry_run
146
+
147
+ if (is_consented || !has_push(steps)) return { value: 'granted', refusal: undefined }
136
148
  if (interaction === 'interactive') return { value: 'ask', refusal: undefined }
137
149
 
138
150
  return {
@@ -167,6 +179,12 @@ function preflight_report(command: string, cause: string): string {
167
179
  return `josh start stopped before its first step: the push preflight (${command}) failed. Nothing was changed.\nCause: ${cause}`
168
180
  }
169
181
 
182
+ // What a run prints when the scanner the pre-push audit needs could not be put in place: every push
183
+ // the plan makes would be refused by that hook, so it stops while nothing has changed (#3755).
184
+ function scanner_report(cause: string): string {
185
+ return `josh start stopped before its first step: osv-scanner, which the pre-push audit needs, is not installed and could not be fetched. Nothing was changed.\nCause: ${cause}\nInstall osv-scanner (https://google.github.io/osv-scanner/) or restore the network, then run josh start again.`
186
+ }
187
+
170
188
  function plan_summary(
171
189
  steps: ReadonlyArray<StartStep>,
172
190
  labels: Readonly<Record<StartStep, string>> = STEP_LABELS,
@@ -179,10 +197,12 @@ function plan_summary(
179
197
  const start_plan = {
180
198
  plan_steps,
181
199
  has_push,
200
+ has_commit,
182
201
  interaction_of,
183
202
  github_consent,
184
203
  progress_report,
185
204
  preflight_report,
205
+ scanner_report,
186
206
  plan_summary,
187
207
  step_labels,
188
208
  STEP_LABELS,
@@ -9,28 +9,19 @@ import { IGNORE_FOR_RELEASE_LABEL } from '#scripts/issue/issue-labels'
9
9
  import { KIT_PACKAGE_NAME } from '#scripts/version/kit-descriptor'
10
10
  import { kit_written_paths } from './kit-written-paths'
11
11
  import { start_exec } from './start-exec'
12
+ import { working_tree } from './working-tree'
12
13
 
13
14
  const SETUP_TITLE = `Set up ${KIT_PACKAGE_NAME}`
14
15
  const SETUP_BODY = [
15
16
  `Adds the files \`josh start\` wrote while setting up ${KIT_PACKAGE_NAME}, so main carries the`,
16
17
  'rules, settings and workflows every later Issue is run with. Only the files the setup wrote are in',
17
- 'the pull request; anything else in the working tree is left as it was.',
18
+ 'the pull request: `josh start` runs only on a working tree with nothing uncommitted.',
18
19
  ].join('\n')
19
20
  const NAME_ONLY = '--name-only'
20
21
  const NOTHING_TO_COMMIT = 'The kit setup has no changes to commit, so no pull request was opened.'
21
22
  const OPEN_SETUP_ISSUES_QUERY = `?state=open&labels=${IGNORE_FOR_RELEASE_LABEL}&per_page=100`
22
23
  const FIRST_SETUP_ISSUE_FILTER = `[.[] | select(.title == "${SETUP_TITLE}") | .number][0] // empty`
23
24
 
24
- // Listings are read NUL-separated (`-z`), so a path is never quoted — a name a caller's initialize
25
- // command wrote need not be ASCII the way kit's own are (#2872).
26
- function paths_of(output: string | undefined = ''): Array<string> {
27
- return output.split('\0').filter((listed) => listed.length > 0)
28
- }
29
-
30
- function listing(args: ReadonlyArray<string>, root: string): Array<string> {
31
- return paths_of(start_exec.git_read([...args, '-z'], root))
32
- }
33
-
34
25
  function setup_issue(issue_number: string): IssueInfo {
35
26
  return git_issue.parse(`${SETUP_TITLE} ${issue_cite.plain(issue_number)}`)
36
27
  }
@@ -49,19 +40,10 @@ function is_setup_branch(branch: string | undefined): boolean {
49
40
  return setup_branch_number(branch) !== undefined
50
41
  }
51
42
 
52
- // Untracked files the ignore rules let through, plus tracked files the setup changed — two plain
53
- // listings, so nothing has to be parsed out of `git status`.
54
- function changed_paths(root: string): Array<string> {
55
- const untracked = listing(['ls-files', '--others', '--exclude-standard'], root)
56
- const modified = listing(['diff', NAME_ONLY, 'HEAD'], root)
57
-
58
- return [...untracked, ...modified]
59
- }
60
-
61
43
  // On the setup branch a failed commit left, that run's `git add` already staged its files, so they
62
44
  // read as changed before this run's command ran — the staged ones count as setup output (#2872).
63
45
  function carried_paths(root: string, current: string | undefined): Array<string> {
64
- return is_setup_branch(current) ? listing(['diff', NAME_ONLY, '--cached'], root) : []
46
+ return is_setup_branch(current) ? working_tree.listing(['diff', NAME_ONLY, '--cached'], root) : []
65
47
  }
66
48
 
67
49
  // The Issue carries the release classification the pull request is opened with, so the PR step finds
@@ -124,13 +106,15 @@ function commit_kit_paths(paths: ReadonlyArray<string>, issue: IssueInfo, root:
124
106
 
125
107
  // A caller's initialize command writes files kit cannot name, so with a `baseline` — what was already
126
108
  // changed before that command ran — everything changed since joins kit's own files, while a change
127
- // the user had made before the run still stays out (#2872).
109
+ // the user had made before the run still stays out (#2872). `josh start` refuses a dirty tree on
110
+ // `main` (#3835), so the baseline there is empty; it still keeps the two apart on the setup branch a
111
+ // failed commit left, where the re-run starts with that run's output uncommitted.
128
112
  function setup_paths(
129
113
  root: string,
130
114
  baseline: ReadonlyArray<string> | undefined,
131
115
  current: string | undefined,
132
116
  ): Array<string> {
133
- const changed = changed_paths(root)
117
+ const changed = working_tree.changed_paths(root)
134
118
  const kit_paths = kit_written_paths.select(changed)
135
119
 
136
120
  if (baseline === undefined) return kit_paths
@@ -161,6 +145,11 @@ async function open(root: string, baseline?: ReadonlyArray<string>): Promise<voi
161
145
  await git_pr.create_with_issue_info(issue)
162
146
  }
163
147
 
164
- const start_setup_pr = { open, changed_paths, is_setup_branch }
148
+ const start_setup_pr = {
149
+ SETUP_TITLE,
150
+ open,
151
+ changed_paths: working_tree.changed_paths,
152
+ is_setup_branch,
153
+ }
165
154
 
166
155
  export { start_setup_pr }
@@ -2,6 +2,8 @@ import path from 'node:path'
2
2
  import { git_spawn_sync } from '#scripts/git/git-spawn-sync'
3
3
  import { error_text } from '#scripts/lib/error-message'
4
4
  import { repository_labels } from '#scripts/repo/repository-labels'
5
+ import { security_audit } from '#scripts/security/security-audit'
6
+ import { security_audit_provision } from '#scripts/security/security-audit-provision'
5
7
  import { self_sync_guard } from '#scripts/self-sync-guard/self-sync-guard-logic'
6
8
  import { parseCommandString } from 'execa'
7
9
  import { main as init_main } from './init'
@@ -12,6 +14,8 @@ import type { ProjectProfile, ProjectShape } from './project-profile'
12
14
  import { start_exec } from './start-exec'
13
15
  import { start_plan, type GitState, type StartStep, type Visibility } from './start-plan'
14
16
  import { start_setup_pr } from './start-setup-pr'
17
+ import { workflow_mark } from './workflow-mark'
18
+ import { working_tree } from './working-tree'
15
19
 
16
20
  interface StepContext {
17
21
  root: string
@@ -29,6 +33,12 @@ const GH_INSTALL_HINT =
29
33
  'GitHub CLI (gh) is not installed. Install it from https://cli.github.com/, run gh auth login, then run josh start again. Nothing was changed.'
30
34
  const GH_AUTH_HINT =
31
35
  'GitHub CLI is not signed in. Run gh auth login, then run josh start again. Nothing was changed.'
36
+ const GIT_IDENTITY_HINT =
37
+ 'Git has no author identity, which the commit josh start creates needs. Run git config --global user.name "Your Name" and git config --global user.email "you@example.com", then run josh start again. Nothing was changed.'
38
+ // Printed before the scanner is looked for: a first run downloads tens of megabytes, and a terminal
39
+ // that says nothing for minutes reads as a hang the user interrupts (#3755).
40
+ const SCANNER_NOTICE =
41
+ 'Checking for osv-scanner, which the pre-push audit needs. A first run downloads it, which can take a few minutes on a slow connection.'
32
42
 
33
43
  const { git_succeeds, git_read, run_local } = start_exec
34
44
 
@@ -63,6 +73,28 @@ function github_cli_refusal(root: string): string | undefined {
63
73
  return undefined
64
74
  }
65
75
 
76
+ // A machine Git was just installed on has no author identity, and the commit that needs one comes
77
+ // after the repository and kit's files are written. It is asked for before the first write, and only
78
+ // when the plan commits — a re-run with nothing left to commit is not stopped by it (#3754).
79
+ function git_identity_refusal(steps: ReadonlyArray<StartStep>, root: string): string | undefined {
80
+ if (!start_plan.has_commit(steps)) return undefined
81
+
82
+ return start_exec.lacks_git_identity(root, steps.includes('git_init'))
83
+ ? GIT_IDENTITY_HINT
84
+ : undefined
85
+ }
86
+
87
+ // A repository with a history receives the setup as a commit, and the user's own uncommitted change to
88
+ // a file kit also writes would land in it, so the tree must be clean before the first write (#3835).
89
+ // The setup branch a failed commit or push left checked out is the exception: what is uncommitted
90
+ // there is that run's own output, which the re-run resumes with. Without a commit there is nothing to
91
+ // keep apart — the initial commit takes every file.
92
+ function uncommitted_refusal(root: string, state: GitState): string | undefined {
93
+ if (!state.has_commits || start_setup_pr.is_setup_branch(state.branch)) return undefined
94
+
95
+ return working_tree.dirty_tree_refusal(root, 'josh start')
96
+ }
97
+
66
98
  // `josh init` refuses to run inside the kit source repository by printing and setting the exit code
67
99
  // rather than throwing, so its step would pass and the later steps would commit and publish the
68
100
  // source repository. The same check is raised before the first write instead.
@@ -85,7 +117,10 @@ function create_git_repository(context: StepContext): void {
85
117
  // A toolkit that layers its files over kit's (`josh-app init`) names its own setup instead, so every
86
118
  // other step and guard stays here rather than in a copy of this command (#2872). That command runs
87
119
  // without a shell, is given the same profile, and a non-zero exit stops the run before the commit.
120
+ // Either one is told the project is set up for the GitHub Issue workflow (`workflow-mark.ts`).
88
121
  function initialize(context: StepContext): void {
122
+ workflow_mark.request()
123
+
89
124
  if (context.init_command === undefined) {
90
125
  init_main(['--profile', context.profile])
91
126
 
@@ -118,7 +153,9 @@ async function open_setup_pull_request(context: RunContext): Promise<void> {
118
153
  // What was already changed before the setup ran, read whenever a setup pull request will follow, so
119
154
  // it can tell the setup's files from the user's own (#2872). Kit's own `josh init` needs it as much as
120
155
  // a caller's command: its `josh format` rewrites the project's own sources, which kit cannot name, and
121
- // leaving them out failed the pull request's Prettier check.
156
+ // leaving them out failed the pull request's Prettier check. On `main` it is empty, since a dirty tree
157
+ // is refused there (#3835); it is kept for the setup branch a failed commit left, where the earlier
158
+ // run's output is already uncommitted when the re-run starts.
122
159
  function baseline_of(
123
160
  steps: ReadonlyArray<StartStep>,
124
161
  context: StepContext,
@@ -148,6 +185,27 @@ function run_push_preflight(steps: ReadonlyArray<StartStep>, context: StepContex
148
185
  }
149
186
  }
150
187
 
188
+ // The `full` profile installs kit's pre-push hook, whose audit refuses a push without osv-scanner. The
189
+ // scanner is otherwise fetched at an agent's session start, which a first `josh start` from a terminal
190
+ // has not had, so it is fetched here — forced, because a person asked for this run. A scanner that
191
+ // still cannot be found stops the run before the first step rather than at the push (#3755).
192
+ async function ensure_audit_scanner(
193
+ steps: ReadonlyArray<StartStep>,
194
+ context: StepContext,
195
+ ): Promise<void> {
196
+ if (context.profile !== 'full' || !start_plan.has_push(steps)) return
197
+
198
+ const { platform, arch } = process
199
+
200
+ console.info(`\n${SCANNER_NOTICE}`)
201
+ const outcome = await security_audit_provision.report(context.root, platform, arch, true)
202
+
203
+ console.info(outcome)
204
+ if (security_audit.resolve_scanner(context.root, platform) !== undefined) return
205
+
206
+ throw new Error(start_plan.scanner_report(outcome))
207
+ }
208
+
151
209
  type StepAction = (context: RunContext) => void | Promise<void>
152
210
 
153
211
  const STEP_ACTIONS: Readonly<Record<StartStep, StepAction>> = {
@@ -178,6 +236,7 @@ async function run_steps(steps: ReadonlyArray<StartStep>, context: StepContext):
178
236
  const labels = start_plan.step_labels(context.init_command)
179
237
 
180
238
  run_push_preflight(steps, context)
239
+ await ensure_audit_scanner(steps, context)
181
240
  const run_context: RunContext = { ...context, baseline: baseline_of(steps, context) }
182
241
 
183
242
  for (const [index, step] of steps.entries()) {
@@ -192,7 +251,9 @@ async function run_steps(steps: ReadonlyArray<StartStep>, context: StepContext):
192
251
  const start_steps = {
193
252
  read_git_state,
194
253
  github_cli_refusal,
254
+ git_identity_refusal,
195
255
  self_run_refusal,
256
+ uncommitted_refusal,
196
257
  run_steps,
197
258
  }
198
259
  export { start_steps }
@@ -1,7 +1,9 @@
1
1
  #!/usr/bin/env tsx
2
2
  import { fileURLToPath } from 'node:url'
3
3
  import { error_text } from '#scripts/lib/error-message'
4
- import { project_profile, type ProjectProfile } from './project-profile'
4
+ import { init_dry_run } from './init-dry-run'
5
+ import { project_profile, type ProjectProfile, type ProjectShape } from './project-profile'
6
+ import { start_dry_run } from './start-dry-run'
5
7
  import {
6
8
  start_plan,
7
9
  type Consent,
@@ -15,13 +17,14 @@ import { start_steps } from './start-steps'
15
17
  const ARGUMENT_START_INDEX = 2
16
18
  const READY_MESSAGE = 'GitHub workflow ready. Use kickoff new in your assistant to plan an Issue.'
17
19
  const START_USAGE =
18
- 'josh start [--profile basic|full] [--yes] [--github] [--public] [--init-command <command>] [--push-preflight-command <command>]'
20
+ 'josh start [--profile basic|full] [--yes] [--github] [--public] [--dry-run] [--init-command <command>] [--push-preflight-command <command>]'
19
21
  const INIT_COMMAND_FLAG = '--init-command'
20
22
  const PUSH_PREFLIGHT_COMMAND_FLAG = '--push-preflight-command'
21
23
  const YES_FLAG = '--yes'
22
24
  const GITHUB_FLAG = '--github'
23
25
  const PUBLIC_FLAG = '--public'
24
- const SWITCHES: ReadonlySet<string> = new Set([YES_FLAG, GITHUB_FLAG, PUBLIC_FLAG])
26
+ const { DRY_RUN_FLAG } = init_dry_run
27
+ const SWITCHES: ReadonlySet<string> = new Set([YES_FLAG, GITHUB_FLAG, PUBLIC_FLAG, DRY_RUN_FLAG])
25
28
 
26
29
  interface Prepared {
27
30
  steps: ReadonlyArray<StartStep>
@@ -67,6 +70,7 @@ function parse_start_options(args: ReadonlyArray<string>): StartOptions {
67
70
  profile: project_profile.requested_profile(profile_args, START_USAGE),
68
71
  is_yes: rest.includes(YES_FLAG),
69
72
  is_github: rest.includes(GITHUB_FLAG),
73
+ is_dry_run: rest.includes(DRY_RUN_FLAG),
70
74
  visibility: rest.includes(PUBLIC_FLAG) ? 'public' : 'private',
71
75
  init_command: init.command,
72
76
  push_preflight_command,
@@ -94,33 +98,34 @@ async function resolve_profile(
94
98
  return await start_prompt.confirm_choices(choices)
95
99
  }
96
100
 
101
+ function planned_steps(root: string, shape: ProjectShape): ReadonlyArray<StartStep> {
102
+ const state = start_steps.read_git_state(root, shape)
103
+ const plan = start_plan.plan_steps(state)
104
+
105
+ stop_on(plan.refusal)
106
+ stop_on(start_steps.uncommitted_refusal(root, state))
107
+
108
+ return plan.steps
109
+ }
110
+
97
111
  // Every refusal is raised here, before the first write, so a run that stops here changed nothing.
98
112
  function prepare(args: ReadonlyArray<string>, is_tty: boolean, root: string): Prepared {
99
113
  stop_on(start_steps.self_run_refusal(root))
100
114
  const options = parse_start_options(args)
101
115
  const interaction = settle(start_plan.interaction_of(options, is_tty))
102
116
  const shape = project_profile.inspect_project(root, options.profile)
103
- const plan = start_plan.plan_steps(start_steps.read_git_state(root, shape))
104
-
105
- stop_on(plan.refusal)
106
- const consent: Consent = settle(start_plan.github_consent(plan.steps, options, interaction))
117
+ const steps = planned_steps(root, shape)
118
+ const consent: Consent = settle(start_plan.github_consent(steps, options, interaction))
107
119
 
108
120
  stop_on(start_steps.github_cli_refusal(root))
121
+ stop_on(start_steps.git_identity_refusal(steps, root))
109
122
 
110
- return {
111
- steps: plan.steps,
112
- interaction,
113
- choices: { options, shape, consent, root, steps: plan.steps },
114
- }
123
+ return { steps, interaction, choices: { options, shape, consent, root, steps } }
115
124
  }
116
125
 
117
- async function main(args: ReadonlyArray<string>, is_tty: boolean): Promise<void> {
118
- const { steps, interaction, choices } = prepare(args, is_tty, process.cwd())
119
-
126
+ async function run_steps({ steps, interaction, choices }: Prepared): Promise<void> {
120
127
  const { root, options } = choices
121
128
  const { visibility, init_command, push_preflight_command } = options
122
-
123
- console.info(start_plan.plan_summary(steps, start_plan.step_labels(init_command)))
124
129
  const profile = await resolve_profile(choices, interaction)
125
130
  const context = { root, profile, visibility, init_command, push_preflight_command }
126
131
 
@@ -128,6 +133,22 @@ async function main(args: ReadonlyArray<string>, is_tty: boolean): Promise<void>
128
133
  console.info(`\n${READY_MESSAGE}`)
129
134
  }
130
135
 
136
+ // A dry run stops after the plan, adding what each step would create, before the first write.
137
+ async function main(args: ReadonlyArray<string>, is_tty: boolean): Promise<void> {
138
+ const prepared = prepare(args, is_tty, process.cwd())
139
+ const { steps, choices } = prepared
140
+
141
+ console.info(start_plan.plan_summary(steps, start_plan.step_labels(choices.options.init_command)))
142
+
143
+ if (choices.options.is_dry_run) {
144
+ console.info(start_dry_run.report(choices))
145
+
146
+ return
147
+ }
148
+
149
+ await run_steps(prepared)
150
+ }
151
+
131
152
  async function run(args: ReadonlyArray<string>, is_tty: boolean): Promise<number> {
132
153
  try {
133
154
  await main(args, is_tty)
@@ -0,0 +1,35 @@
1
+ import { json_value } from '#scripts/lib/json-value'
2
+
3
+ // `josh start` sets a project up for the GitHub Issue workflow and `josh init` alone does not. The
4
+ // difference decides whether a `basic` project receives `.claude/settings.json`, whose permissions,
5
+ // hooks and plugin an unattended `backlogrun` agent runs on (#3796).
6
+ //
7
+ // `josh start` hands the mark to the setup it runs through this variable, not an argument: that setup
8
+ // may be the project's own kit it hands off to or a toolkit's command (`--init-command`), and an older
9
+ // one refuses an argument it does not know but ignores a variable. The setup records the mark in
10
+ // `package.json`, where every later `josh init` and `josh sync` reads it.
11
+ const WORKFLOW_ENV = 'JOSH_WORKFLOW'
12
+ const GITHUB_WORKFLOW = 'github'
13
+
14
+ function is_requested(environment: NodeJS.ProcessEnv = process.env): boolean {
15
+ return environment[WORKFLOW_ENV] === GITHUB_WORKFLOW
16
+ }
17
+
18
+ function is_recorded(manifest: Record<string, unknown> | undefined): boolean {
19
+ const josh = manifest?.['josh']
20
+
21
+ return json_value.is_record(josh) && josh['workflow'] === GITHUB_WORKFLOW
22
+ }
23
+
24
+ function request(environment: NodeJS.ProcessEnv = process.env): void {
25
+ environment[WORKFLOW_ENV] = GITHUB_WORKFLOW
26
+ }
27
+
28
+ // The `josh` record's field for the mark — none for a project `josh start` did not set up, so a
29
+ // `josh init` never writes the mark of its own accord.
30
+ function recorded_fields(has_workflow: boolean): Record<string, string> {
31
+ return has_workflow ? { workflow: GITHUB_WORKFLOW } : {}
32
+ }
33
+
34
+ const workflow_mark = { is_requested, is_recorded, request, recorded_fields }
35
+ export { workflow_mark }
@@ -0,0 +1,116 @@
1
+ import { git_location_environment } from '#scripts/git/git-location-environment'
2
+ import { git_spawn_sync } from '#scripts/git/git-spawn-sync'
3
+ import { start_exec } from './start-exec'
4
+
5
+ const UNTRACKED_ARGUMENTS = ['ls-files', '--others', '--exclude-standard']
6
+ // `--relative` keeps the listing to the project and its paths relative to it, the way `ls-files`
7
+ // already reads them, for a project that sits below its repository's top level.
8
+ const MODIFIED_ARGUMENTS = ['diff', '--name-only', '--relative', 'HEAD']
9
+ // Under an unborn HEAD every indexed file is uncommitted, so the index itself is the listing.
10
+ const INDEXED_ARGUMENTS = ['ls-files', '--cached']
11
+ const HISTORY_ARGUMENTS = ['rev-list', '--max-count=1', '--all']
12
+ const UNBORN_HEAD_EXIT_CODE = 1
13
+ const NOT_A_REPOSITORY = 'not a git repository'
14
+
15
+ // What a project's working tree holds that is not committed, read one way for every setup command:
16
+ // the setup pull request tells the setup's files from the user's own with it, and `josh init` refuses
17
+ // to write over it.
18
+
19
+ // Listings are read NUL-separated (`-z`), so a path is never quoted — a name a caller's initialize
20
+ // command wrote need not be ASCII the way kit's own are (#2872).
21
+ function paths_of(output: string | undefined = ''): Array<string> {
22
+ return output.split('\0').filter((listed) => listed.length > 0)
23
+ }
24
+
25
+ function listing(args: ReadonlyArray<string>, root: string): Array<string> {
26
+ return paths_of(start_exec.git_read([...args, '-z'], root))
27
+ }
28
+
29
+ // Untracked files the ignore rules let through, plus tracked files changed since HEAD, staged or not
30
+ // — two plain listings, so nothing has to be parsed out of `git status`.
31
+ function changed_paths(root: string): Array<string> {
32
+ const untracked = listing(UNTRACKED_ARGUMENTS, root)
33
+ const modified = listing(MODIFIED_ARGUMENTS, root)
34
+
35
+ return [...untracked, ...modified]
36
+ }
37
+
38
+ // A read that failed is thrown rather than read as an empty listing: the refusal guards the user's
39
+ // work, so a git that did not answer must never pass for a clean tree.
40
+ function strict_listing(args: ReadonlyArray<string>, root: string): Array<string> {
41
+ const output = start_exec.git_read([...args, '-z'], root)
42
+
43
+ if (output === undefined) throw new Error(`git ${args.join(' ')} failed in ${root}`)
44
+
45
+ return paths_of(output)
46
+ }
47
+
48
+ type HeadState = 'born' | 'unborn' | 'absent'
49
+
50
+ // Asked of git rather than of a `.git` beside the project, so a project below its repository's top
51
+ // level is still found. `--verify --quiet` exits 1 for an unborn HEAD, and only `not a git repository`
52
+ // (read under the C locale) proves there is no repository; any other failure is an unanswered
53
+ // question, not a missing commit.
54
+ function head_state(root: string): HeadState {
55
+ const { exit_code, stderr } = git_spawn_sync.run(['rev-parse', '--verify', '--quiet', 'HEAD'], {
56
+ cwd: root,
57
+ env: { ...git_spawn_sync.C_LOCALE, ...git_location_environment.location_free_environment() },
58
+ })
59
+
60
+ if (exit_code === 0) return 'born'
61
+ if (stderr.includes(NOT_A_REPOSITORY)) return 'absent'
62
+ if (exit_code === UNBORN_HEAD_EXIT_CODE) return 'unborn'
63
+ throw new Error(`git rev-parse could not read HEAD in ${root}`)
64
+ }
65
+
66
+ // An unborn HEAD is either the repository `josh start` has yet to make its first commit in, or an
67
+ // orphan branch (`git checkout --orphan`) whose index still holds another branch's files — told
68
+ // apart by whether any ref reaches a commit.
69
+ function has_history(root: string): boolean {
70
+ const output = start_exec.git_read(HISTORY_ARGUMENTS, root)
71
+
72
+ if (output === undefined) throw new Error(`git ${HISTORY_ARGUMENTS.join(' ')} failed in ${root}`)
73
+
74
+ return output.trim().length > 0
75
+ }
76
+
77
+ // What the tracked files are compared with, or undefined when there is nothing to tell the user's
78
+ // work from — a directory outside any repository, or a repository without any commit.
79
+ function tracked_arguments(root: string): ReadonlyArray<string> | undefined {
80
+ const state = head_state(root)
81
+
82
+ if (state === 'born') return MODIFIED_ARGUMENTS
83
+ if (state === 'unborn' && has_history(root)) return INDEXED_ARGUMENTS
84
+
85
+ return undefined
86
+ }
87
+
88
+ function uncommitted_paths(root: string): Array<string> {
89
+ const tracked = tracked_arguments(root)
90
+
91
+ if (tracked === undefined) return []
92
+
93
+ const untracked = strict_listing(UNTRACKED_ARGUMENTS, root)
94
+ const modified = strict_listing(tracked, root)
95
+
96
+ return [...untracked, ...modified]
97
+ }
98
+
99
+ // The refusal a setup command stops with before its first write, or undefined when nothing is
100
+ // uncommitted: what kit then writes would land in the same files as the user's own work, and
101
+ // `git diff` could no longer tell the two apart.
102
+ function dirty_tree_refusal(root: string, command: string): string | undefined {
103
+ const paths = uncommitted_paths(root)
104
+
105
+ if (paths.length === 0) return undefined
106
+
107
+ return [
108
+ `${command} refuses to run: the working tree has uncommitted changes.`,
109
+ ...paths.map((listed) => ` ${listed}`),
110
+ `Commit them, or stash them with \`git stash push -u -- .\`, then run ${command} again.`,
111
+ ].join('\n')
112
+ }
113
+
114
+ const working_tree = { listing, changed_paths, uncommitted_paths, dirty_tree_refusal }
115
+
116
+ export { working_tree }