@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.
- package/.claude/skills/workflow-commands/backlogrun-handoff-report.md +1 -1
- package/.claude/skills/workflow-commands/backlogrun-park.md +2 -0
- package/.claude/skills/workflow-commands/backlogrun-steps.md +9 -7
- package/.claude/skills/workflow-commands/backlogrun.md +2 -2
- package/.claude/skills/workflow-commands/fullrun-steps.md +1 -1
- package/.claude/skills/workflow-commands/halfrun.md +3 -2
- package/.claude/skills/workflow-commands/kickoff.md +12 -13
- package/.claude/skills/workflow-commands/observation-filing.md +1 -1
- package/.claude/skills/workflow-commands/prerequisite.md +6 -0
- package/.claude/skills/workflow-commands/prrun.md +2 -1
- package/.claude/skills/workflow-commands/retrospective.md +5 -5
- package/.claude/skills/workflow-commands/split-assessment.md +2 -2
- package/CLAUDE.md +1 -1
- package/README.md +10 -19
- package/dist/CLAUDE.md +1 -1
- package/dist/hooks/batch-guard.js +6 -6
- package/dist/hooks/{chunk-3LE6VLSR.js → chunk-3C4XRYG6.js} +18 -5
- package/dist/hooks/{chunk-7KMN5QB5.js → chunk-5H4SSDSD.js} +3 -3
- package/dist/hooks/{chunk-YZXYOAZ3.js → chunk-7WQBPLUN.js} +11 -5
- package/dist/hooks/{chunk-2G5F2YCD.js → chunk-D5QBTOCZ.js} +3 -3
- package/dist/hooks/{chunk-MU4LJFIV.js → chunk-GPLGZ6IC.js} +3 -3
- package/dist/hooks/{chunk-MSYSUSY6.js → chunk-HFGWYGUV.js} +5 -5
- package/dist/hooks/{chunk-JXQBK323.js → chunk-HHI6FO3R.js} +132 -105
- package/dist/hooks/{chunk-XER6SSIB.js → chunk-LIAFCXL7.js} +17 -7
- package/dist/hooks/{chunk-4BHUPFKF.js → chunk-OLMO72ON.js} +1 -1
- package/dist/hooks/{chunk-EMB55BDF.js → chunk-TBJFTSUY.js} +2 -2
- package/dist/hooks/{chunk-5SJSSCJG.js → chunk-UT7YC3N2.js} +1 -1
- package/dist/hooks/{chunk-QL6QTPWN.js → chunk-WNLVGT7E.js} +3 -3
- package/dist/hooks/{chunk-YW452ZAX.js → chunk-Z4XI7CZS.js} +2 -2
- package/dist/hooks/codex-hook-adapter.js +13 -13
- package/dist/hooks/duplicate-read-guard.js +7 -7
- package/dist/hooks/format-edited.js +3 -3
- package/dist/hooks/inputs.json +229 -226
- package/dist/hooks/investigation-guard.js +7 -7
- package/dist/hooks/pretool-guard.js +12 -12
- package/dist/hooks/session-lang.js +3 -3
- package/dist/hooks/stop-guard.js +64 -11
- package/dist/josh.js +34 -11
- package/package.json +1 -1
- package/prompts/collaboration-workflow/issue-template.md +10 -8
- package/prompts/collaboration-workflow/shell-body.md +1 -1
- package/prompts/review.md +8 -4
- package/scripts/auto-ok/auto-ok-judge-cli.ts +125 -0
- package/scripts/auto-ok/auto-ok-judge-read.ts +95 -0
- package/scripts/auto-ok/auto-ok-judge-write.ts +109 -0
- package/scripts/auto-ok/auto-ok-judge.ts +67 -0
- package/scripts/backlog/backlog-drive-cli.ts +14 -0
- package/scripts/backlog/backlog-drive.ts +22 -3
- package/scripts/document/document-byte-budget.ts +1 -1
- package/scripts/fresh-machine/fresh-machine-patterns.ts +60 -15
- package/scripts/gate/project-checks.ts +5 -2
- package/scripts/gh/git-gh-issue-rest.ts +1 -0
- package/scripts/gh/git-gh-issue.ts +9 -0
- package/scripts/gh/git-pr-ai-review.ts +12 -1
- package/scripts/gh/git-pr-confirmation.ts +11 -7
- package/scripts/init/init-actions.ts +3 -3
- package/scripts/init/init-ai-copy.ts +1 -1
- package/scripts/init/init-basic.ts +1 -1
- package/scripts/init/init-bootstrap.ts +6 -2
- package/scripts/init/init-dry-run-report.ts +150 -0
- package/scripts/init/init-dry-run.ts +189 -0
- package/scripts/init/init-install.ts +8 -1
- package/scripts/init/init.ts +57 -19
- package/scripts/init/kit-setup-state.ts +11 -17
- package/scripts/init/own-formatter.ts +21 -0
- package/scripts/init/project-profile.ts +25 -6
- package/scripts/init/start-dry-run.ts +70 -0
- package/scripts/init/start-plan.ts +8 -3
- package/scripts/init/start-setup-pr.ts +13 -24
- package/scripts/init/start-steps.ts +16 -1
- package/scripts/init/start.ts +38 -18
- package/scripts/init/working-tree.ts +116 -0
- package/scripts/issue/issue-auto-ok.ts +68 -99
- package/scripts/issue/issue-file-cli.ts +17 -17
- package/scripts/issue/issue-file.ts +1 -6
- package/scripts/issue/issue-labels.ts +6 -2
- package/scripts/issue/issue-release-cli.ts +69 -23
- package/scripts/issue/issue-release.ts +3 -1
- package/scripts/josh/josh-commands-ai.ts +11 -0
- package/scripts/josh/josh-commands-issue.ts +3 -3
- package/scripts/josh/josh-commands-project.ts +2 -2
- package/scripts/josh/josh-commands-workflow.ts +1 -1
- package/scripts/josh/process-identity.ts +32 -6
- package/scripts/lane/lane-broker-server.ts +69 -0
- package/scripts/lane/lane-session-broker.ts +37 -14
- package/scripts/notify/confirmation-options.ts +59 -0
- package/scripts/notify/notify-opt-in.ts +33 -0
- package/scripts/notify/telegram-notify.ts +25 -8
- package/scripts/notify/telegram-test-logic.ts +33 -1
- package/scripts/notify/telegram-test.ts +4 -2
- package/scripts/observations/observation-ledger-migrate.ts +3 -15
- package/scripts/release/release-issue-close.ts +133 -0
- package/scripts/release/release-publish.ts +12 -1
- package/scripts/repo/repository-labels.ts +11 -1
- package/scripts/review/comment-ranges.ts +199 -0
- package/scripts/review/review-brief-cli.ts +22 -5
- package/scripts/review/review-code-tree.ts +119 -0
- package/scripts/review/review-round2-cli.ts +16 -5
- package/scripts/review/review-round2.ts +30 -10
- package/scripts/review/review-stamps.ts +22 -1
- package/scripts/rules/shell-body-trigger.ts +3 -2
- package/scripts/rules/stop-rules.ts +2 -1
- package/scripts/run/entry/run-entry-stop.ts +6 -3
- package/scripts/run/run-stop-notify.ts +2 -0
- 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
|
-
|
|
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
|
|
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 = {
|
|
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 }
|
package/scripts/init/start.ts
CHANGED
|
@@ -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 {
|
|
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
|
|
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
|
|
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(
|
|
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
|
|
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 }
|