@cod3vil/trunk 0.1.1
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/LICENSE +21 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +69 -0
- package/dist/commands/clone.d.ts +6 -0
- package/dist/commands/clone.js +114 -0
- package/dist/commands/init.d.ts +6 -0
- package/dist/commands/init.js +235 -0
- package/dist/commands/new.d.ts +6 -0
- package/dist/commands/new.js +357 -0
- package/dist/core/adopt.d.ts +14 -0
- package/dist/core/adopt.js +157 -0
- package/dist/core/agents.d.ts +30 -0
- package/dist/core/agents.js +31 -0
- package/dist/core/arguments.d.ts +92 -0
- package/dist/core/arguments.js +93 -0
- package/dist/core/detect.d.ts +28 -0
- package/dist/core/detect.js +169 -0
- package/dist/core/diff.d.ts +37 -0
- package/dist/core/diff.js +140 -0
- package/dist/core/env.d.ts +63 -0
- package/dist/core/env.js +140 -0
- package/dist/core/generate/aliases.d.ts +7 -0
- package/dist/core/generate/aliases.js +45 -0
- package/dist/core/generate/header.d.ts +2 -0
- package/dist/core/generate/header.js +81 -0
- package/dist/core/generate/index.d.ts +8 -0
- package/dist/core/generate/index.js +74 -0
- package/dist/core/generate/proxy.d.ts +8 -0
- package/dist/core/generate/proxy.js +56 -0
- package/dist/core/generate/steps.d.ts +7 -0
- package/dist/core/generate/steps.js +88 -0
- package/dist/core/generate/tmux.d.ts +4 -0
- package/dist/core/generate/tmux.js +98 -0
- package/dist/core/generate/toml.d.ts +16 -0
- package/dist/core/generate/toml.js +68 -0
- package/dist/core/gh.d.ts +60 -0
- package/dist/core/gh.js +101 -0
- package/dist/core/git.d.ts +79 -0
- package/dist/core/git.js +211 -0
- package/dist/core/journal.d.ts +54 -0
- package/dist/core/journal.js +147 -0
- package/dist/core/log.d.ts +8 -0
- package/dist/core/log.js +38 -0
- package/dist/core/pipeline.d.ts +119 -0
- package/dist/core/pipeline.js +473 -0
- package/dist/core/platform.d.ts +10 -0
- package/dist/core/platform.js +26 -0
- package/dist/core/prefix.d.ts +25 -0
- package/dist/core/prefix.js +59 -0
- package/dist/core/process.d.ts +27 -0
- package/dist/core/process.js +43 -0
- package/dist/core/repo.d.ts +79 -0
- package/dist/core/repo.js +294 -0
- package/dist/core/resolve.d.ts +127 -0
- package/dist/core/resolve.js +488 -0
- package/dist/core/result.d.ts +27 -0
- package/dist/core/result.js +32 -0
- package/dist/core/settings.d.ts +51 -0
- package/dist/core/settings.js +83 -0
- package/dist/core/tmuxRename.d.ts +36 -0
- package/dist/core/tmuxRename.js +79 -0
- package/dist/core/validate.d.ts +22 -0
- package/dist/core/validate.js +111 -0
- package/dist/core/version.d.ts +2 -0
- package/dist/core/version.js +35 -0
- package/dist/core/words.d.ts +16 -0
- package/dist/core/words.js +198 -0
- package/dist/core/wt.d.ts +41 -0
- package/dist/core/wt.js +59 -0
- package/dist/ui/SetupForm.d.ts +55 -0
- package/dist/ui/SetupForm.js +354 -0
- package/dist/ui/Summary.d.ts +15 -0
- package/dist/ui/Summary.js +74 -0
- package/dist/ui/fields/MultiSelect.d.ts +17 -0
- package/dist/ui/fields/MultiSelect.js +66 -0
- package/dist/ui/fields/Select.d.ts +17 -0
- package/dist/ui/fields/Select.js +37 -0
- package/dist/ui/fields/TextInput.d.ts +13 -0
- package/dist/ui/fields/TextInput.js +50 -0
- package/package.json +76 -0
- package/readme.md +147 -0
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { collectSettings } from '../ui/SetupForm.js';
|
|
2
|
+
import type { CliFlags } from './arguments.js';
|
|
3
|
+
import type { ToolProbe } from './env.js';
|
|
4
|
+
import { type GitOptions } from './git.js';
|
|
5
|
+
import { Journal } from './journal.js';
|
|
6
|
+
import type { ParsedRemote } from './repo.js';
|
|
7
|
+
import { type SetupValues } from './resolve.js';
|
|
8
|
+
import { type Outcome } from './result.js';
|
|
9
|
+
import { type AttachedRunner, type CommandRunner } from './process.js';
|
|
10
|
+
import type { Settings } from './settings.js';
|
|
11
|
+
export declare const setupBranch = "chore/trunk-setup";
|
|
12
|
+
export declare const configPath = ".config/wt.toml";
|
|
13
|
+
export declare const commitMessage: string;
|
|
14
|
+
export type SetupPrompts = Readonly<{
|
|
15
|
+
/** Returns undefined when the user aborts rather than choosing. */
|
|
16
|
+
choose: (question: string, options: readonly string[]) => Promise<string | undefined>;
|
|
17
|
+
confirm: (question: string) => Promise<boolean | undefined>;
|
|
18
|
+
}>;
|
|
19
|
+
export type SetupDependencies = Readonly<{
|
|
20
|
+
cwd?: string;
|
|
21
|
+
env?: NodeJS.ProcessEnv;
|
|
22
|
+
run?: CommandRunner;
|
|
23
|
+
attach?: AttachedRunner;
|
|
24
|
+
report?: (kind: 'success' | 'error' | 'info' | 'warning', line: string) => void;
|
|
25
|
+
interactive?: boolean;
|
|
26
|
+
prompts?: SetupPrompts;
|
|
27
|
+
collect?: typeof collectSettings;
|
|
28
|
+
now?: () => Date;
|
|
29
|
+
invocation?: Readonly<{
|
|
30
|
+
executable: string;
|
|
31
|
+
arguments: readonly string[];
|
|
32
|
+
}>;
|
|
33
|
+
}>;
|
|
34
|
+
export type SetupContext = Readonly<{
|
|
35
|
+
cwd: string;
|
|
36
|
+
env?: NodeJS.ProcessEnv;
|
|
37
|
+
git: GitOptions;
|
|
38
|
+
attach: AttachedRunner;
|
|
39
|
+
run: CommandRunner;
|
|
40
|
+
report: NonNullable<SetupDependencies['report']>;
|
|
41
|
+
interactive: boolean;
|
|
42
|
+
prompts?: SetupPrompts;
|
|
43
|
+
collect: typeof collectSettings;
|
|
44
|
+
now: () => Date;
|
|
45
|
+
invocation: Readonly<{
|
|
46
|
+
executable: string;
|
|
47
|
+
arguments: readonly string[];
|
|
48
|
+
}>;
|
|
49
|
+
tools: ToolProbe;
|
|
50
|
+
flags: CliFlags;
|
|
51
|
+
journal: Journal;
|
|
52
|
+
wtPath: string;
|
|
53
|
+
}>;
|
|
54
|
+
/**
|
|
55
|
+
* Steps 6 and 7 followed by the write: decide what to do about a config that is
|
|
56
|
+
* already there, resolve the settings for one that is not, and hand over to the
|
|
57
|
+
* branch that receives them. Shared so `clone` and `init` cannot diverge.
|
|
58
|
+
*/
|
|
59
|
+
export declare function configureProject(context: SetupContext, project: Readonly<{
|
|
60
|
+
remote: ParsedRemote;
|
|
61
|
+
projectDirectory: string;
|
|
62
|
+
gitDirectory: string;
|
|
63
|
+
defaultWorktree: string;
|
|
64
|
+
defaultBranch: string;
|
|
65
|
+
/** Values read back out of a config that already exists. */
|
|
66
|
+
adopted?: Partial<SetupValues>;
|
|
67
|
+
copyIgnoredExclude?: readonly string[];
|
|
68
|
+
/**
|
|
69
|
+
* Runs once the settings are known and before anything is written, for
|
|
70
|
+
* work that depends on what the user chose — `init` uses it to offer the
|
|
71
|
+
* tmux session renames a prefix change needs.
|
|
72
|
+
*/
|
|
73
|
+
onSettings?: (settings: Settings) => Promise<void>;
|
|
74
|
+
}>): Promise<Outcome>;
|
|
75
|
+
/**
|
|
76
|
+
* Creates the worktree that receives the config, writes it, lets wt check it,
|
|
77
|
+
* and only then commits. Separate because it is the part that touches the
|
|
78
|
+
* repository's history.
|
|
79
|
+
*/
|
|
80
|
+
export declare function setUpBranch(context: SetupContext, plan: Readonly<{
|
|
81
|
+
remote: ParsedRemote;
|
|
82
|
+
projectDirectory: string;
|
|
83
|
+
gitDirectory: string;
|
|
84
|
+
defaultWorktree: string;
|
|
85
|
+
settings: Settings;
|
|
86
|
+
/**
|
|
87
|
+
* The config the repository has now. It lives in the default worktree,
|
|
88
|
+
* not in the freshly created setup worktree, so it is passed in rather
|
|
89
|
+
* than read from where the new file is about to go.
|
|
90
|
+
*/
|
|
91
|
+
replacing?: string;
|
|
92
|
+
}>): Promise<Outcome>;
|
|
93
|
+
export declare function warnAboutWorktreePath(context: SetupContext, remote: ParsedRemote, actual: string): void;
|
|
94
|
+
/** Spelled out because the fix belongs in the user's config, not in trunk's. */
|
|
95
|
+
export declare function worktreePathWarning(identifier: string, actualPath: string): readonly string[];
|
|
96
|
+
/**
|
|
97
|
+
* Where a worktree actually landed. git reports resolved paths, so on a system
|
|
98
|
+
* where a temp or home directory is a symlink the string trunk proposed and the
|
|
99
|
+
* string git prints differ; both sides are resolved before comparing.
|
|
100
|
+
*/
|
|
101
|
+
export declare function locateWorktree(gitDirectory: string, expected: string, options: GitOptions, branch?: string): Promise<string>;
|
|
102
|
+
export declare function decideExistingConfig(context: SetupContext): Promise<'keep' | 'overwrite' | 'abort'>;
|
|
103
|
+
export declare function confirmStep(context: SetupContext, question: string): Promise<boolean | undefined>;
|
|
104
|
+
/** Step 11: approvals and the smoke test, both optional and both attached. */
|
|
105
|
+
export declare function finish(context: SetupContext, projectDirectory: string, worktree: string, branch: string): Promise<Outcome>;
|
|
106
|
+
/**
|
|
107
|
+
* Step 12. Every failure and abort lands here, where the journal turns into
|
|
108
|
+
* either a resume hint or a precise undo.
|
|
109
|
+
*/
|
|
110
|
+
export declare function interrupted(context: SetupContext, projectDirectory: string, reason: string): Promise<Outcome>;
|
|
111
|
+
export declare function readExistingConfig(worktree: string): Promise<string | undefined>;
|
|
112
|
+
export declare function folderState(path: string): Promise<'missing' | 'empty' | 'occupied'>;
|
|
113
|
+
export declare function createContext(flags: CliFlags, tools: ToolProbe, dependencies: SetupDependencies): SetupContext;
|
|
114
|
+
export declare function today(date: Date): string;
|
|
115
|
+
export declare function outputOf(result: Readonly<{
|
|
116
|
+
stdout: string;
|
|
117
|
+
stderr: string;
|
|
118
|
+
}>): string;
|
|
119
|
+
export declare function errorMessage(error: unknown): string;
|
|
@@ -0,0 +1,473 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The part of setup that every command shares: deciding what to do about a
|
|
3
|
+
* config that already exists, resolving the settings, creating the worktree
|
|
4
|
+
* that receives them, writing, validating, committing, publishing, and the
|
|
5
|
+
* approvals and smoke test afterwards.
|
|
6
|
+
*
|
|
7
|
+
* `trunk clone` reaches this after cloning; `trunk init` reaches it after
|
|
8
|
+
* finding an existing project. Both hand it the same shape, so the two commands
|
|
9
|
+
* cannot drift apart in how they set a repository up.
|
|
10
|
+
*/
|
|
11
|
+
import { mkdir, readFile, readdir, realpath, stat, writeFile, } from 'node:fs/promises';
|
|
12
|
+
import { join } from 'node:path';
|
|
13
|
+
import process from 'node:process';
|
|
14
|
+
import { collectSettings } from '../ui/SetupForm.js';
|
|
15
|
+
import { detectPackageManager } from './detect.js';
|
|
16
|
+
import { diffLines, formatDiff } from './diff.js';
|
|
17
|
+
import { compose } from './generate/index.js';
|
|
18
|
+
import { canOpenPullRequest, compareUrl, createPullRequest, publishCommands, } from './gh.js';
|
|
19
|
+
import { addWorktree, commitPath, listWorktreeEntries, } from './git.js';
|
|
20
|
+
import { Journal, resumeCommand, undoCommands, } from './journal.js';
|
|
21
|
+
import { step as reportStep } from './log.js';
|
|
22
|
+
import { supportsInteractiveInput } from './platform.js';
|
|
23
|
+
import { setupFlagsFromCli } from './resolve.js';
|
|
24
|
+
import { exitCodes, operationFailed, succeed, userAborted, } from './result.js';
|
|
25
|
+
import { runAttached, runCommand, } from './process.js';
|
|
26
|
+
import { validateGeneratedConfig } from './validate.js';
|
|
27
|
+
import { trunkVersion } from './version.js';
|
|
28
|
+
import { approvalsAdd, hookLogPath, runHook, switchCreateAttached, } from './wt.js';
|
|
29
|
+
export const setupBranch = 'chore/trunk-setup';
|
|
30
|
+
export const configPath = '.config/wt.toml';
|
|
31
|
+
export const commitMessage = `Add worktree automation
|
|
32
|
+
|
|
33
|
+
Generated by trunk ${trunkVersion()}: tmux session, dependency install, dev server and
|
|
34
|
+
Caddy route for each worktree. Run \`wt config approvals add\` after checking out.`;
|
|
35
|
+
/**
|
|
36
|
+
* Steps 6 and 7 followed by the write: decide what to do about a config that is
|
|
37
|
+
* already there, resolve the settings for one that is not, and hand over to the
|
|
38
|
+
* branch that receives them. Shared so `clone` and `init` cannot diverge.
|
|
39
|
+
*/
|
|
40
|
+
export async function configureProject(context, project) {
|
|
41
|
+
const { remote, projectDirectory, gitDirectory, defaultWorktree, defaultBranch, } = project;
|
|
42
|
+
// An existing config belongs to the repository, not to this run.
|
|
43
|
+
const existing = await readExistingConfig(defaultWorktree);
|
|
44
|
+
if (existing !== undefined) {
|
|
45
|
+
const decision = await decideExistingConfig(context);
|
|
46
|
+
if (decision === 'abort') {
|
|
47
|
+
return interrupted(context, projectDirectory, 'aborted');
|
|
48
|
+
}
|
|
49
|
+
if (decision === 'keep') {
|
|
50
|
+
context.report('info', 'wt.toml exists → kept');
|
|
51
|
+
return finish(context, projectDirectory, defaultWorktree, defaultBranch);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
// Detection and resolution happen inside the default worktree.
|
|
55
|
+
const detection = await detectPackageManager(defaultWorktree);
|
|
56
|
+
for (const warning of detection.warnings) {
|
|
57
|
+
context.report('warning', warning);
|
|
58
|
+
}
|
|
59
|
+
const collected = await context.collect({
|
|
60
|
+
folder: defaultWorktree,
|
|
61
|
+
resolveOptions: {
|
|
62
|
+
fixed: {
|
|
63
|
+
trunkVersion: trunkVersion(),
|
|
64
|
+
generatedOn: today(context.now()),
|
|
65
|
+
repoName: remote.repo,
|
|
66
|
+
hostLabel: remote.repo.toLowerCase(),
|
|
67
|
+
appDir: detection.appDir,
|
|
68
|
+
scripts: detection.scripts,
|
|
69
|
+
},
|
|
70
|
+
flags: setupFlagsFromCli(context.flags),
|
|
71
|
+
existing: project.adopted,
|
|
72
|
+
packageDetection: detection,
|
|
73
|
+
tools: context.tools,
|
|
74
|
+
},
|
|
75
|
+
invocation: context.invocation,
|
|
76
|
+
yes: context.flags.yes ?? false,
|
|
77
|
+
interactive: context.interactive,
|
|
78
|
+
});
|
|
79
|
+
if (collected.kind === 'outcome') {
|
|
80
|
+
return collected.outcome.code === exitCodes.userAborted
|
|
81
|
+
? interrupted(context, projectDirectory, 'aborted')
|
|
82
|
+
: collected.outcome;
|
|
83
|
+
}
|
|
84
|
+
const settings = project.copyIgnoredExclude
|
|
85
|
+
? { ...collected.settings, copyIgnoredExclude: project.copyIgnoredExclude }
|
|
86
|
+
: collected.settings;
|
|
87
|
+
await project.onSettings?.(settings);
|
|
88
|
+
return setUpBranch(context, {
|
|
89
|
+
remote,
|
|
90
|
+
projectDirectory,
|
|
91
|
+
gitDirectory,
|
|
92
|
+
defaultWorktree,
|
|
93
|
+
settings,
|
|
94
|
+
replacing: existing,
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* Shows what overwriting would change and asks. Without a terminal there is
|
|
99
|
+
* nobody to ask, and the run only reaches here because a flag already said to
|
|
100
|
+
* overwrite, so it proceeds and prints the diff for the log.
|
|
101
|
+
*/
|
|
102
|
+
async function confirmOverwrite(context, existing, settings) {
|
|
103
|
+
const diff = diffLines(existing, compose(settings));
|
|
104
|
+
if (diff.unchanged) {
|
|
105
|
+
context.report('info', `${configPath} is already up to date`);
|
|
106
|
+
return true;
|
|
107
|
+
}
|
|
108
|
+
context.report('info', `${configPath} changes: ${diff.added} added, ${diff.removed} removed`);
|
|
109
|
+
for (const line of formatDiff(diff)) {
|
|
110
|
+
context.report('info', line);
|
|
111
|
+
}
|
|
112
|
+
if (!context.interactive) {
|
|
113
|
+
return true;
|
|
114
|
+
}
|
|
115
|
+
const answer = await context.prompts?.confirm('write this config?');
|
|
116
|
+
return answer ?? true;
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* Creates the worktree that receives the config, writes it, lets wt check it,
|
|
120
|
+
* and only then commits. Separate because it is the part that touches the
|
|
121
|
+
* repository's history.
|
|
122
|
+
*/
|
|
123
|
+
export async function setUpBranch(context, plan) {
|
|
124
|
+
const { remote, projectDirectory, gitDirectory, defaultWorktree, settings } = plan;
|
|
125
|
+
// The config is unapproved until it is reviewed, so the worktree that
|
|
126
|
+
// receives it is created without hooks.
|
|
127
|
+
const direct = context.flags.direct ?? false;
|
|
128
|
+
const target = direct
|
|
129
|
+
? defaultWorktree
|
|
130
|
+
: join(projectDirectory, setupBranch.replaceAll('/', '-'));
|
|
131
|
+
if (!direct) {
|
|
132
|
+
context.journal.record({
|
|
133
|
+
kind: 'branch-worktree',
|
|
134
|
+
path: target,
|
|
135
|
+
branch: setupBranch,
|
|
136
|
+
note: 'wt.toml uncommitted',
|
|
137
|
+
});
|
|
138
|
+
const created = await createSetupWorktree(context, projectDirectory, gitDirectory, target);
|
|
139
|
+
if (created !== 0) {
|
|
140
|
+
return interrupted(context, projectDirectory, `could not create the ${setupBranch} worktree`);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
let worktree = defaultWorktree;
|
|
144
|
+
if (!direct) {
|
|
145
|
+
worktree = await locateWorktree(gitDirectory, target, context.git);
|
|
146
|
+
if (worktree !== target) {
|
|
147
|
+
context.journal.relocate(target, worktree);
|
|
148
|
+
warnAboutWorktreePath(context, remote, worktree);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
// Anything trunk cannot model is lost here, so the user sees what changes
|
|
152
|
+
// before it is written, and can still say no.
|
|
153
|
+
const replacing = plan.replacing ?? (await readExistingConfig(worktree));
|
|
154
|
+
if (replacing !== undefined) {
|
|
155
|
+
const approved = await confirmOverwrite(context, replacing, settings);
|
|
156
|
+
if (!approved) {
|
|
157
|
+
return interrupted(context, projectDirectory, 'left the existing config');
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
// Write, then let wt check its own file before anything is committed.
|
|
161
|
+
await writeConfig(worktree, settings);
|
|
162
|
+
context.report('success', `wrote ${configPath}`);
|
|
163
|
+
try {
|
|
164
|
+
const validation = await validateGeneratedConfig(worktree, settings, {
|
|
165
|
+
wtPath: context.wtPath,
|
|
166
|
+
env: context.env,
|
|
167
|
+
});
|
|
168
|
+
reportPreview(context, validation.preview);
|
|
169
|
+
}
|
|
170
|
+
catch (error) {
|
|
171
|
+
return interrupted(context, projectDirectory, errorMessage(error));
|
|
172
|
+
}
|
|
173
|
+
const shouldCommit = await confirmStep(context, 'commit the generated config?');
|
|
174
|
+
if (shouldCommit === undefined) {
|
|
175
|
+
return interrupted(context, projectDirectory, 'aborted');
|
|
176
|
+
}
|
|
177
|
+
if (!shouldCommit) {
|
|
178
|
+
return interrupted(context, projectDirectory, 'left uncommitted');
|
|
179
|
+
}
|
|
180
|
+
const committed = await commitPath(worktree, configPath, commitMessage, context.git);
|
|
181
|
+
if (committed.code !== 0) {
|
|
182
|
+
return interrupted(context, projectDirectory, `commit failed: ${outputOf(committed)}`);
|
|
183
|
+
}
|
|
184
|
+
context.journal.annotate(worktree, 'wt.toml committed');
|
|
185
|
+
context.report('success', 'committed the generated config');
|
|
186
|
+
await publish(context, remote, worktree, direct ? undefined : setupBranch);
|
|
187
|
+
return finish(context, projectDirectory, worktree, setupBranch);
|
|
188
|
+
}
|
|
189
|
+
/**
|
|
190
|
+
* Interactive runs let wt place the worktree, the same way the first one is
|
|
191
|
+
* placed. Without a terminal wt cannot ask where worktrees belong and would
|
|
192
|
+
* fall back to `.git.<branch>`, so plain git keeps the sibling layout whole.
|
|
193
|
+
*/
|
|
194
|
+
async function createSetupWorktree(context, projectDirectory, gitDirectory, target) {
|
|
195
|
+
if (context.interactive) {
|
|
196
|
+
return switchCreateAttached(projectDirectory, setupBranch, {
|
|
197
|
+
wtPath: context.wtPath,
|
|
198
|
+
attach: context.attach,
|
|
199
|
+
env: context.env,
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
const added = await addWorktree(gitDirectory, target, setupBranch, {
|
|
203
|
+
...context.git,
|
|
204
|
+
createBranch: true,
|
|
205
|
+
});
|
|
206
|
+
return added.code ?? 1;
|
|
207
|
+
}
|
|
208
|
+
export function warnAboutWorktreePath(context, remote, actual) {
|
|
209
|
+
for (const line of worktreePathWarning(remote.identifier, actual)) {
|
|
210
|
+
context.report('warning', line);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
/** Spelled out because the fix belongs in the user's config, not in trunk's. */
|
|
214
|
+
export function worktreePathWarning(identifier, actualPath) {
|
|
215
|
+
return Object.freeze([
|
|
216
|
+
`worktree-path is not configured for ${identifier}.`,
|
|
217
|
+
` Agents and scripts will create ${actualPath} instead of a sibling worktree.`,
|
|
218
|
+
' Fix: run `wt switch <branch>` in a terminal once, or add to ~/.config/worktrunk/config.toml:',
|
|
219
|
+
` [projects."${identifier}"]`,
|
|
220
|
+
' worktree-path = "{{ repo_path }}/../{{ branch | sanitize }}"',
|
|
221
|
+
]);
|
|
222
|
+
}
|
|
223
|
+
/**
|
|
224
|
+
* Where a worktree actually landed. git reports resolved paths, so on a system
|
|
225
|
+
* where a temp or home directory is a symlink the string trunk proposed and the
|
|
226
|
+
* string git prints differ; both sides are resolved before comparing.
|
|
227
|
+
*/
|
|
228
|
+
export async function locateWorktree(gitDirectory, expected, options, branch) {
|
|
229
|
+
const entries = await listWorktreeEntries(gitDirectory, options);
|
|
230
|
+
const target = await canonicalPath(expected);
|
|
231
|
+
const resolved = await Promise.all(entries.map(async (entry) => [entry, await canonicalPath(entry.path)]));
|
|
232
|
+
const match = resolved.find(([, canonical]) => canonical === target);
|
|
233
|
+
if (match) {
|
|
234
|
+
return match[0].path;
|
|
235
|
+
}
|
|
236
|
+
// Worktrunk may have placed it beside the git directory instead. The branch
|
|
237
|
+
// it holds identifies it; position in the list does not.
|
|
238
|
+
const byBranch = branch
|
|
239
|
+
? entries.find(entry => entry.branch === branch)
|
|
240
|
+
: undefined;
|
|
241
|
+
return byBranch?.path ?? expected;
|
|
242
|
+
}
|
|
243
|
+
async function canonicalPath(path) {
|
|
244
|
+
try {
|
|
245
|
+
return await realpath(path);
|
|
246
|
+
}
|
|
247
|
+
catch {
|
|
248
|
+
return path;
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
export async function decideExistingConfig(context) {
|
|
252
|
+
if (!context.interactive || (context.flags.yes ?? false)) {
|
|
253
|
+
return 'keep';
|
|
254
|
+
}
|
|
255
|
+
const answer = await context.prompts?.choose(`${configPath} already exists. Keep it, or overwrite with the generated one?`, ['keep', 'overwrite']);
|
|
256
|
+
if (answer === undefined) {
|
|
257
|
+
return 'abort';
|
|
258
|
+
}
|
|
259
|
+
return answer === 'overwrite' ? 'overwrite' : 'keep';
|
|
260
|
+
}
|
|
261
|
+
export async function confirmStep(context, question) {
|
|
262
|
+
if (!context.interactive || (context.flags.yes ?? false)) {
|
|
263
|
+
return true;
|
|
264
|
+
}
|
|
265
|
+
return context.prompts?.confirm(question) ?? true;
|
|
266
|
+
}
|
|
267
|
+
/**
|
|
268
|
+
* Publishing is opt-in without a terminal: `--yes` alone stops after the commit
|
|
269
|
+
* and prints the commands, so a scripted run never pushes by surprise.
|
|
270
|
+
*/
|
|
271
|
+
async function publish(context, remote, worktree, branch) {
|
|
272
|
+
if (!branch) {
|
|
273
|
+
return;
|
|
274
|
+
}
|
|
275
|
+
const withPullRequest = canOpenPullRequest(remote, context.tools.gh.path !== undefined);
|
|
276
|
+
const wanted = context.flags.yes ?? false
|
|
277
|
+
? false
|
|
278
|
+
: await confirmStep(context, 'push and open a pull request?');
|
|
279
|
+
if (!wanted) {
|
|
280
|
+
for (const command of publishCommands(branch, withPullRequest)) {
|
|
281
|
+
context.report('info', command);
|
|
282
|
+
}
|
|
283
|
+
const url = compareUrl(remote, branch);
|
|
284
|
+
if (url && !withPullRequest) {
|
|
285
|
+
context.report('info', url);
|
|
286
|
+
}
|
|
287
|
+
return;
|
|
288
|
+
}
|
|
289
|
+
const pushed = await context.run(context.git.gitPath ?? 'git', ['-C', worktree, 'push', '-u', 'origin', branch], { env: context.env });
|
|
290
|
+
if (pushed.code !== 0) {
|
|
291
|
+
context.report('error', `push failed: ${outputOf(pushed)}`);
|
|
292
|
+
return;
|
|
293
|
+
}
|
|
294
|
+
if (!withPullRequest) {
|
|
295
|
+
const url = compareUrl(remote, branch);
|
|
296
|
+
if (url) {
|
|
297
|
+
context.report('info', url);
|
|
298
|
+
}
|
|
299
|
+
return;
|
|
300
|
+
}
|
|
301
|
+
const pullRequest = await createPullRequest(worktree, {
|
|
302
|
+
ghPath: context.tools.gh.path,
|
|
303
|
+
run: context.run,
|
|
304
|
+
env: context.env,
|
|
305
|
+
});
|
|
306
|
+
context.report(pullRequest.code === 0 ? 'success' : 'error', outputOf(pullRequest));
|
|
307
|
+
}
|
|
308
|
+
/** Step 11: approvals and the smoke test, both optional and both attached. */
|
|
309
|
+
export async function finish(context, projectDirectory, worktree, branch) {
|
|
310
|
+
if (!context.interactive || (context.flags.yes ?? false)) {
|
|
311
|
+
context.report('info', `next: wt config approvals add (in ${worktree})`);
|
|
312
|
+
return succeed(`set up ${projectDirectory}`);
|
|
313
|
+
}
|
|
314
|
+
const approve = await confirmStep(context, 'run `wt config approvals add` now?');
|
|
315
|
+
if (approve) {
|
|
316
|
+
await approvalsAdd(worktree, {
|
|
317
|
+
wtPath: context.wtPath,
|
|
318
|
+
attach: context.attach,
|
|
319
|
+
env: context.env,
|
|
320
|
+
});
|
|
321
|
+
}
|
|
322
|
+
context.report('info', 'the smoke test starts a real dev server and Caddy route; stop them with `wt remove` or the `up` alias');
|
|
323
|
+
const smoke = await confirmStep(context, 'run the hooks once as a smoke test?');
|
|
324
|
+
if (!smoke) {
|
|
325
|
+
return succeed(`set up ${projectDirectory}`);
|
|
326
|
+
}
|
|
327
|
+
for (const hook of ['pre-start', 'post-start']) {
|
|
328
|
+
// eslint-disable-next-line no-await-in-loop
|
|
329
|
+
const code = await runHook(worktree, hook, {
|
|
330
|
+
wtPath: context.wtPath,
|
|
331
|
+
attach: context.attach,
|
|
332
|
+
env: context.env,
|
|
333
|
+
});
|
|
334
|
+
if (code !== 0) {
|
|
335
|
+
context.report('error', `${hook} failed`);
|
|
336
|
+
context.report('info', hookLogPath(projectDirectory, branch, hook));
|
|
337
|
+
return operationFailed(`${hook} failed in ${worktree}`);
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
return succeed(`set up ${projectDirectory}`);
|
|
341
|
+
}
|
|
342
|
+
/**
|
|
343
|
+
* Step 12. Every failure and abort lands here, where the journal turns into
|
|
344
|
+
* either a resume hint or a precise undo.
|
|
345
|
+
*/
|
|
346
|
+
export async function interrupted(context, projectDirectory, reason) {
|
|
347
|
+
context.report('error', reason);
|
|
348
|
+
if (context.journal.isEmpty) {
|
|
349
|
+
return operationFailed(reason);
|
|
350
|
+
}
|
|
351
|
+
context.report('info', 'created by this run:');
|
|
352
|
+
for (const line of context.journal.describe(context.cwd)) {
|
|
353
|
+
context.report('info', ` ${line}`);
|
|
354
|
+
}
|
|
355
|
+
const undo = undoCommands(context.journal, projectDirectory, context.wtPath, context.git.gitPath ?? 'git');
|
|
356
|
+
const choice = context.interactive
|
|
357
|
+
? await context.prompts?.choose('keep, or roll back what this run created?', ['keep', 'rollback'])
|
|
358
|
+
: 'keep';
|
|
359
|
+
if (choice === 'rollback') {
|
|
360
|
+
await rollback(context, projectDirectory, undo);
|
|
361
|
+
return userAborted(`rolled back ${projectDirectory}`);
|
|
362
|
+
}
|
|
363
|
+
context.report('info', `resume: ${resumeCommand(projectDirectory, context.cwd)}`);
|
|
364
|
+
for (const command of undo) {
|
|
365
|
+
context.report('info', ` ${describeUndo(command)}`);
|
|
366
|
+
}
|
|
367
|
+
return operationFailed(reason);
|
|
368
|
+
}
|
|
369
|
+
async function rollback(context, projectDirectory, commands) {
|
|
370
|
+
for (const command of commands) {
|
|
371
|
+
if (command.manual) {
|
|
372
|
+
context.report('warning', `not undone: ${command.purpose}. Run it yourself if you want to: ${describeCommand(command)}`);
|
|
373
|
+
continue;
|
|
374
|
+
}
|
|
375
|
+
// Rollback is ordered newest first, so the steps cannot run in parallel.
|
|
376
|
+
// eslint-disable-next-line no-await-in-loop
|
|
377
|
+
const result = await context.run(command.executable, command.arguments, {
|
|
378
|
+
env: context.env,
|
|
379
|
+
});
|
|
380
|
+
context.report(result.code === 0 ? 'success' : 'warning', `${command.purpose}${result.code === 0 ? '' : ` — failed: ${outputOf(result)}`}`);
|
|
381
|
+
}
|
|
382
|
+
await reportWorktrunkTrash(context, projectDirectory);
|
|
383
|
+
}
|
|
384
|
+
/**
|
|
385
|
+
* `wt remove` keeps a copy of what it removed, which is a good safety net and a
|
|
386
|
+
* bad surprise. When the folder survives the rollback, say the copy is there and
|
|
387
|
+
* whose it is, rather than deleting someone else's undo behind their back.
|
|
388
|
+
*/
|
|
389
|
+
async function reportWorktrunkTrash(context, projectDirectory) {
|
|
390
|
+
const trash = join(projectDirectory, '.git', 'wt', 'trash');
|
|
391
|
+
try {
|
|
392
|
+
await stat(trash);
|
|
393
|
+
}
|
|
394
|
+
catch {
|
|
395
|
+
return;
|
|
396
|
+
}
|
|
397
|
+
context.report('info', `worktrunk kept a copy of the removed worktree in ${trash}; delete it once you are sure you do not need it`);
|
|
398
|
+
}
|
|
399
|
+
function describeCommand(command) {
|
|
400
|
+
return `${command.executable} ${command.arguments.join(' ')}`;
|
|
401
|
+
}
|
|
402
|
+
function describeUndo(command) {
|
|
403
|
+
return `${command.executable} ${command.arguments.join(' ')} # ${command.purpose}`;
|
|
404
|
+
}
|
|
405
|
+
async function writeConfig(worktree, settings) {
|
|
406
|
+
await mkdir(join(worktree, '.config'), { recursive: true });
|
|
407
|
+
await writeFile(join(worktree, configPath), compose(settings));
|
|
408
|
+
}
|
|
409
|
+
export async function readExistingConfig(worktree) {
|
|
410
|
+
try {
|
|
411
|
+
return await readFile(join(worktree, configPath), 'utf8');
|
|
412
|
+
}
|
|
413
|
+
catch {
|
|
414
|
+
return undefined;
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
function reportPreview(context, preview) {
|
|
418
|
+
for (const [label, value] of [
|
|
419
|
+
['session', preview.session],
|
|
420
|
+
['port', preview.port],
|
|
421
|
+
['url', preview.url],
|
|
422
|
+
]) {
|
|
423
|
+
if (value !== undefined) {
|
|
424
|
+
context.report('info', `${label} ${value}`);
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
export async function folderState(path) {
|
|
429
|
+
try {
|
|
430
|
+
const entries = await readdir(path);
|
|
431
|
+
return entries.every(entry => ignorable.has(entry)) ? 'empty' : 'occupied';
|
|
432
|
+
}
|
|
433
|
+
catch {
|
|
434
|
+
return 'missing';
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
const ignorable = new Set(['.DS_Store', '.localized', 'Thumbs.db']);
|
|
438
|
+
export function createContext(flags, tools, dependencies) {
|
|
439
|
+
const report = dependencies.report ??
|
|
440
|
+
((kind, line) => {
|
|
441
|
+
reportStep(kind, line);
|
|
442
|
+
});
|
|
443
|
+
return {
|
|
444
|
+
cwd: dependencies.cwd ?? process.cwd(),
|
|
445
|
+
env: dependencies.env,
|
|
446
|
+
git: { gitPath: tools.git.path, run: dependencies.run },
|
|
447
|
+
attach: dependencies.attach ?? runAttached,
|
|
448
|
+
run: dependencies.run ?? runCommand,
|
|
449
|
+
report,
|
|
450
|
+
interactive: dependencies.interactive ??
|
|
451
|
+
(supportsInteractiveInput() && !(flags.yes ?? false)),
|
|
452
|
+
prompts: dependencies.prompts,
|
|
453
|
+
collect: dependencies.collect ?? collectSettings,
|
|
454
|
+
now: dependencies.now ?? (() => new Date()),
|
|
455
|
+
invocation: dependencies.invocation ?? {
|
|
456
|
+
executable: 'trunk',
|
|
457
|
+
arguments: process.argv.slice(2),
|
|
458
|
+
},
|
|
459
|
+
tools,
|
|
460
|
+
flags,
|
|
461
|
+
journal: new Journal(),
|
|
462
|
+
wtPath: tools.wt.path ?? 'wt',
|
|
463
|
+
};
|
|
464
|
+
}
|
|
465
|
+
export function today(date) {
|
|
466
|
+
return date.toISOString().slice(0, 10);
|
|
467
|
+
}
|
|
468
|
+
export function outputOf(result) {
|
|
469
|
+
return result.stderr.trim() || result.stdout.trim() || 'no output';
|
|
470
|
+
}
|
|
471
|
+
export function errorMessage(error) {
|
|
472
|
+
return error instanceof Error ? error.message : String(error);
|
|
473
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { type Outcome } from './result.js';
|
|
2
|
+
/** Returns an Outcome to stop on, or undefined when the platform is supported. */
|
|
3
|
+
export declare function guardPlatform(platform?: NodeJS.Platform): Outcome | undefined;
|
|
4
|
+
/**
|
|
5
|
+
* Whether trunk can show an interactive form. Ink puts the terminal into raw
|
|
6
|
+
* mode to read keys, which needs more than a TTY: a process whose stdin is a
|
|
7
|
+
* pipe, or a terminal that does not support it, throws on mount. Checking first
|
|
8
|
+
* means the user gets the re-run command instead of a React stack trace.
|
|
9
|
+
*/
|
|
10
|
+
export declare function supportsInteractiveInput(stdin?: NodeJS.ReadStream): boolean;
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The one hard platform rule. Everything trunk generates is POSIX shell run by
|
|
3
|
+
* tmux and wt hooks, which native Windows cannot execute; WSL reports itself as
|
|
4
|
+
* linux and is fine.
|
|
5
|
+
*/
|
|
6
|
+
import process from 'node:process';
|
|
7
|
+
import { exitCodes } from './result.js';
|
|
8
|
+
/** Returns an Outcome to stop on, or undefined when the platform is supported. */
|
|
9
|
+
export function guardPlatform(platform = process.platform) {
|
|
10
|
+
if (platform !== 'win32') {
|
|
11
|
+
return undefined;
|
|
12
|
+
}
|
|
13
|
+
return {
|
|
14
|
+
code: exitCodes.unsupportedEnvironment,
|
|
15
|
+
message: 'trunk supports macOS and Linux (including WSL).',
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Whether trunk can show an interactive form. Ink puts the terminal into raw
|
|
20
|
+
* mode to read keys, which needs more than a TTY: a process whose stdin is a
|
|
21
|
+
* pipe, or a terminal that does not support it, throws on mount. Checking first
|
|
22
|
+
* means the user gets the re-run command instead of a React stack trace.
|
|
23
|
+
*/
|
|
24
|
+
export function supportsInteractiveInput(stdin = process.stdin) {
|
|
25
|
+
return Boolean(stdin.isTTY) && typeof stdin.setRawMode === 'function';
|
|
26
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The tmux session prefix. Sessions are named `<prefix>_<branch>`, so the prefix
|
|
3
|
+
* is what keeps one repository's sessions apart from another's, and it has to
|
|
4
|
+
* survive tmux's own naming rules.
|
|
5
|
+
*/
|
|
6
|
+
export type PrefixValidation = Readonly<{
|
|
7
|
+
valid: true;
|
|
8
|
+
}> | Readonly<{
|
|
9
|
+
valid: false;
|
|
10
|
+
reason: string;
|
|
11
|
+
}>;
|
|
12
|
+
/**
|
|
13
|
+
* Suggests a prefix from a repository name: the first word in full, then the
|
|
14
|
+
* initial of each word after it (`acme-web-backend` becomes `acme-wb`). Short
|
|
15
|
+
* enough to read in a tmux status line, and stable for a given name.
|
|
16
|
+
*
|
|
17
|
+
* Two repositories can suggest the same prefix; the setup form shows the value
|
|
18
|
+
* so the user can change it.
|
|
19
|
+
*/
|
|
20
|
+
export declare function initials(name: string): string;
|
|
21
|
+
/**
|
|
22
|
+
* Checks a prefix the user typed. Each rule explains itself, and nothing is
|
|
23
|
+
* silently rewritten: a surprising prefix is worse than an error message.
|
|
24
|
+
*/
|
|
25
|
+
export declare function validatePrefix(prefix: string): PrefixValidation;
|