@joshuafolkken/kit 1.1435.0 → 1.1455.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 (105) hide show
  1. package/.claude/skills/workflow-commands/backlogrun-handoff-report.md +1 -1
  2. package/.claude/skills/workflow-commands/backlogrun-park.md +2 -0
  3. package/.claude/skills/workflow-commands/backlogrun-steps.md +9 -7
  4. package/.claude/skills/workflow-commands/backlogrun.md +2 -2
  5. package/.claude/skills/workflow-commands/fullrun-steps.md +1 -1
  6. package/.claude/skills/workflow-commands/halfrun.md +3 -2
  7. package/.claude/skills/workflow-commands/kickoff.md +12 -13
  8. package/.claude/skills/workflow-commands/observation-filing.md +1 -1
  9. package/.claude/skills/workflow-commands/prerequisite.md +6 -0
  10. package/.claude/skills/workflow-commands/prrun.md +2 -1
  11. package/.claude/skills/workflow-commands/retrospective.md +5 -5
  12. package/.claude/skills/workflow-commands/split-assessment.md +2 -2
  13. package/CLAUDE.md +1 -1
  14. package/README.md +10 -19
  15. package/dist/CLAUDE.md +1 -1
  16. package/dist/hooks/batch-guard.js +6 -6
  17. package/dist/hooks/{chunk-3LE6VLSR.js → chunk-3C4XRYG6.js} +18 -5
  18. package/dist/hooks/{chunk-7KMN5QB5.js → chunk-5H4SSDSD.js} +3 -3
  19. package/dist/hooks/{chunk-YZXYOAZ3.js → chunk-7WQBPLUN.js} +11 -5
  20. package/dist/hooks/{chunk-2G5F2YCD.js → chunk-D5QBTOCZ.js} +3 -3
  21. package/dist/hooks/{chunk-MU4LJFIV.js → chunk-GPLGZ6IC.js} +3 -3
  22. package/dist/hooks/{chunk-MSYSUSY6.js → chunk-HFGWYGUV.js} +5 -5
  23. package/dist/hooks/{chunk-JXQBK323.js → chunk-HHI6FO3R.js} +132 -105
  24. package/dist/hooks/{chunk-XER6SSIB.js → chunk-LIAFCXL7.js} +17 -7
  25. package/dist/hooks/{chunk-4BHUPFKF.js → chunk-OLMO72ON.js} +1 -1
  26. package/dist/hooks/{chunk-EMB55BDF.js → chunk-TBJFTSUY.js} +2 -2
  27. package/dist/hooks/{chunk-5SJSSCJG.js → chunk-UT7YC3N2.js} +1 -1
  28. package/dist/hooks/{chunk-QL6QTPWN.js → chunk-WNLVGT7E.js} +3 -3
  29. package/dist/hooks/{chunk-YW452ZAX.js → chunk-Z4XI7CZS.js} +2 -2
  30. package/dist/hooks/codex-hook-adapter.js +13 -13
  31. package/dist/hooks/duplicate-read-guard.js +7 -7
  32. package/dist/hooks/format-edited.js +3 -3
  33. package/dist/hooks/inputs.json +229 -226
  34. package/dist/hooks/investigation-guard.js +7 -7
  35. package/dist/hooks/pretool-guard.js +12 -12
  36. package/dist/hooks/session-lang.js +3 -3
  37. package/dist/hooks/stop-guard.js +64 -11
  38. package/dist/josh.js +34 -11
  39. package/package.json +1 -1
  40. package/prompts/collaboration-workflow/issue-template.md +10 -8
  41. package/prompts/collaboration-workflow/shell-body.md +1 -1
  42. package/prompts/review.md +8 -4
  43. package/scripts/auto-ok/auto-ok-judge-cli.ts +125 -0
  44. package/scripts/auto-ok/auto-ok-judge-read.ts +95 -0
  45. package/scripts/auto-ok/auto-ok-judge-write.ts +109 -0
  46. package/scripts/auto-ok/auto-ok-judge.ts +67 -0
  47. package/scripts/backlog/backlog-drive-cli.ts +14 -0
  48. package/scripts/backlog/backlog-drive.ts +22 -3
  49. package/scripts/document/document-byte-budget.ts +1 -1
  50. package/scripts/fresh-machine/fresh-machine-patterns.ts +60 -15
  51. package/scripts/gate/project-checks.ts +5 -2
  52. package/scripts/gh/git-gh-issue-rest.ts +1 -0
  53. package/scripts/gh/git-gh-issue.ts +9 -0
  54. package/scripts/gh/git-pr-ai-review.ts +12 -1
  55. package/scripts/gh/git-pr-confirmation.ts +11 -7
  56. package/scripts/init/init-actions.ts +3 -3
  57. package/scripts/init/init-ai-copy.ts +1 -1
  58. package/scripts/init/init-basic.ts +1 -1
  59. package/scripts/init/init-bootstrap.ts +6 -2
  60. package/scripts/init/init-dry-run-report.ts +150 -0
  61. package/scripts/init/init-dry-run.ts +189 -0
  62. package/scripts/init/init-install.ts +8 -1
  63. package/scripts/init/init.ts +57 -19
  64. package/scripts/init/kit-setup-state.ts +11 -17
  65. package/scripts/init/own-formatter.ts +21 -0
  66. package/scripts/init/project-profile.ts +25 -6
  67. package/scripts/init/start-dry-run.ts +70 -0
  68. package/scripts/init/start-plan.ts +8 -3
  69. package/scripts/init/start-setup-pr.ts +13 -24
  70. package/scripts/init/start-steps.ts +16 -1
  71. package/scripts/init/start.ts +38 -18
  72. package/scripts/init/working-tree.ts +116 -0
  73. package/scripts/issue/issue-auto-ok.ts +68 -99
  74. package/scripts/issue/issue-file-cli.ts +17 -17
  75. package/scripts/issue/issue-file.ts +1 -6
  76. package/scripts/issue/issue-labels.ts +6 -2
  77. package/scripts/issue/issue-release-cli.ts +69 -23
  78. package/scripts/issue/issue-release.ts +3 -1
  79. package/scripts/josh/josh-commands-ai.ts +11 -0
  80. package/scripts/josh/josh-commands-issue.ts +3 -3
  81. package/scripts/josh/josh-commands-project.ts +2 -2
  82. package/scripts/josh/josh-commands-workflow.ts +1 -1
  83. package/scripts/josh/process-identity.ts +32 -6
  84. package/scripts/lane/lane-broker-server.ts +69 -0
  85. package/scripts/lane/lane-session-broker.ts +37 -14
  86. package/scripts/notify/confirmation-options.ts +59 -0
  87. package/scripts/notify/notify-opt-in.ts +33 -0
  88. package/scripts/notify/telegram-notify.ts +25 -8
  89. package/scripts/notify/telegram-test-logic.ts +33 -1
  90. package/scripts/notify/telegram-test.ts +4 -2
  91. package/scripts/observations/observation-ledger-migrate.ts +3 -15
  92. package/scripts/release/release-issue-close.ts +133 -0
  93. package/scripts/release/release-publish.ts +12 -1
  94. package/scripts/repo/repository-labels.ts +11 -1
  95. package/scripts/review/comment-ranges.ts +199 -0
  96. package/scripts/review/review-brief-cli.ts +22 -5
  97. package/scripts/review/review-code-tree.ts +119 -0
  98. package/scripts/review/review-round2-cli.ts +16 -5
  99. package/scripts/review/review-round2.ts +30 -10
  100. package/scripts/review/review-stamps.ts +22 -1
  101. package/scripts/rules/shell-body-trigger.ts +3 -2
  102. package/scripts/rules/stop-rules.ts +2 -1
  103. package/scripts/run/entry/run-entry-stop.ts +6 -3
  104. package/scripts/run/run-stop-notify.ts +2 -0
  105. package/scripts/uninstall/distributed-files.ts +1 -1
@@ -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 }
@@ -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
@@ -120,8 +121,9 @@ function has_commit(steps: ReadonlyArray<StartStep>): boolean {
120
121
  return steps.some((step) => COMMIT_STEPS.has(step))
121
122
  }
122
123
 
124
+ // A dry run asks nothing and changes nothing, so it needs neither a terminal nor `--yes`.
123
125
  function interaction_of(options: StartOptions, is_tty: boolean): Verdict<Interaction> {
124
- if (options.is_yes) return { value: 'unattended', refusal: undefined }
126
+ if (options.is_yes || options.is_dry_run) return { value: 'unattended', refusal: undefined }
125
127
  if (is_tty) return { value: 'interactive', refusal: undefined }
126
128
 
127
129
  return {
@@ -133,13 +135,16 @@ function interaction_of(options: StartOptions, is_tty: boolean): Verdict<Interac
133
135
 
134
136
  // `--yes` accepts the detected defaults, never the creation of a public-facing resource: an
135
137
  // unattended run creates a repository, pushes, or opens the setup Issue and pull request only when
136
- // `--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.
137
140
  function github_consent(
138
141
  steps: ReadonlyArray<StartStep>,
139
142
  options: StartOptions,
140
143
  interaction: Interaction,
141
144
  ): Verdict<Consent> {
142
- 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 }
143
148
  if (interaction === 'interactive') return { value: 'ask', refusal: undefined }
144
149
 
145
150
  return {
@@ -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 }
@@ -15,6 +15,7 @@ import { start_exec } from './start-exec'
15
15
  import { start_plan, type GitState, type StartStep, type Visibility } from './start-plan'
16
16
  import { start_setup_pr } from './start-setup-pr'
17
17
  import { workflow_mark } from './workflow-mark'
18
+ import { working_tree } from './working-tree'
18
19
 
19
20
  interface StepContext {
20
21
  root: string
@@ -83,6 +84,17 @@ function git_identity_refusal(steps: ReadonlyArray<StartStep>, root: string): st
83
84
  : undefined
84
85
  }
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
+
86
98
  // `josh init` refuses to run inside the kit source repository by printing and setting the exit code
87
99
  // rather than throwing, so its step would pass and the later steps would commit and publish the
88
100
  // source repository. The same check is raised before the first write instead.
@@ -141,7 +153,9 @@ async function open_setup_pull_request(context: RunContext): Promise<void> {
141
153
  // What was already changed before the setup ran, read whenever a setup pull request will follow, so
142
154
  // it can tell the setup's files from the user's own (#2872). Kit's own `josh init` needs it as much as
143
155
  // a caller's command: its `josh format` rewrites the project's own sources, which kit cannot name, and
144
- // 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.
145
159
  function baseline_of(
146
160
  steps: ReadonlyArray<StartStep>,
147
161
  context: StepContext,
@@ -239,6 +253,7 @@ const start_steps = {
239
253
  github_cli_refusal,
240
254
  git_identity_refusal,
241
255
  self_run_refusal,
256
+ uncommitted_refusal,
242
257
  run_steps,
243
258
  }
244
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,34 +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))
109
- stop_on(start_steps.git_identity_refusal(plan.steps, root))
121
+ stop_on(start_steps.git_identity_refusal(steps, root))
110
122
 
111
- return {
112
- steps: plan.steps,
113
- interaction,
114
- choices: { options, shape, consent, root, steps: plan.steps },
115
- }
123
+ return { steps, interaction, choices: { options, shape, consent, root, steps } }
116
124
  }
117
125
 
118
- async function main(args: ReadonlyArray<string>, is_tty: boolean): Promise<void> {
119
- const { steps, interaction, choices } = prepare(args, is_tty, process.cwd())
120
-
126
+ async function run_steps({ steps, interaction, choices }: Prepared): Promise<void> {
121
127
  const { root, options } = choices
122
128
  const { visibility, init_command, push_preflight_command } = options
123
-
124
- console.info(start_plan.plan_summary(steps, start_plan.step_labels(init_command)))
125
129
  const profile = await resolve_profile(choices, interaction)
126
130
  const context = { root, profile, visibility, init_command, push_preflight_command }
127
131
 
@@ -129,6 +133,22 @@ async function main(args: ReadonlyArray<string>, is_tty: boolean): Promise<void>
129
133
  console.info(`\n${READY_MESSAGE}`)
130
134
  }
131
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
+
132
152
  async function run(args: ReadonlyArray<string>, is_tty: boolean): Promise<number> {
133
153
  try {
134
154
  await main(args, is_tty)
@@ -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 }