@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.
Files changed (81) hide show
  1. package/LICENSE +21 -0
  2. package/dist/cli.d.ts +2 -0
  3. package/dist/cli.js +69 -0
  4. package/dist/commands/clone.d.ts +6 -0
  5. package/dist/commands/clone.js +114 -0
  6. package/dist/commands/init.d.ts +6 -0
  7. package/dist/commands/init.js +235 -0
  8. package/dist/commands/new.d.ts +6 -0
  9. package/dist/commands/new.js +357 -0
  10. package/dist/core/adopt.d.ts +14 -0
  11. package/dist/core/adopt.js +157 -0
  12. package/dist/core/agents.d.ts +30 -0
  13. package/dist/core/agents.js +31 -0
  14. package/dist/core/arguments.d.ts +92 -0
  15. package/dist/core/arguments.js +93 -0
  16. package/dist/core/detect.d.ts +28 -0
  17. package/dist/core/detect.js +169 -0
  18. package/dist/core/diff.d.ts +37 -0
  19. package/dist/core/diff.js +140 -0
  20. package/dist/core/env.d.ts +63 -0
  21. package/dist/core/env.js +140 -0
  22. package/dist/core/generate/aliases.d.ts +7 -0
  23. package/dist/core/generate/aliases.js +45 -0
  24. package/dist/core/generate/header.d.ts +2 -0
  25. package/dist/core/generate/header.js +81 -0
  26. package/dist/core/generate/index.d.ts +8 -0
  27. package/dist/core/generate/index.js +74 -0
  28. package/dist/core/generate/proxy.d.ts +8 -0
  29. package/dist/core/generate/proxy.js +56 -0
  30. package/dist/core/generate/steps.d.ts +7 -0
  31. package/dist/core/generate/steps.js +88 -0
  32. package/dist/core/generate/tmux.d.ts +4 -0
  33. package/dist/core/generate/tmux.js +98 -0
  34. package/dist/core/generate/toml.d.ts +16 -0
  35. package/dist/core/generate/toml.js +68 -0
  36. package/dist/core/gh.d.ts +60 -0
  37. package/dist/core/gh.js +101 -0
  38. package/dist/core/git.d.ts +79 -0
  39. package/dist/core/git.js +211 -0
  40. package/dist/core/journal.d.ts +54 -0
  41. package/dist/core/journal.js +147 -0
  42. package/dist/core/log.d.ts +8 -0
  43. package/dist/core/log.js +38 -0
  44. package/dist/core/pipeline.d.ts +119 -0
  45. package/dist/core/pipeline.js +473 -0
  46. package/dist/core/platform.d.ts +10 -0
  47. package/dist/core/platform.js +26 -0
  48. package/dist/core/prefix.d.ts +25 -0
  49. package/dist/core/prefix.js +59 -0
  50. package/dist/core/process.d.ts +27 -0
  51. package/dist/core/process.js +43 -0
  52. package/dist/core/repo.d.ts +79 -0
  53. package/dist/core/repo.js +294 -0
  54. package/dist/core/resolve.d.ts +127 -0
  55. package/dist/core/resolve.js +488 -0
  56. package/dist/core/result.d.ts +27 -0
  57. package/dist/core/result.js +32 -0
  58. package/dist/core/settings.d.ts +51 -0
  59. package/dist/core/settings.js +83 -0
  60. package/dist/core/tmuxRename.d.ts +36 -0
  61. package/dist/core/tmuxRename.js +79 -0
  62. package/dist/core/validate.d.ts +22 -0
  63. package/dist/core/validate.js +111 -0
  64. package/dist/core/version.d.ts +2 -0
  65. package/dist/core/version.js +35 -0
  66. package/dist/core/words.d.ts +16 -0
  67. package/dist/core/words.js +198 -0
  68. package/dist/core/wt.d.ts +41 -0
  69. package/dist/core/wt.js +59 -0
  70. package/dist/ui/SetupForm.d.ts +55 -0
  71. package/dist/ui/SetupForm.js +354 -0
  72. package/dist/ui/Summary.d.ts +15 -0
  73. package/dist/ui/Summary.js +74 -0
  74. package/dist/ui/fields/MultiSelect.d.ts +17 -0
  75. package/dist/ui/fields/MultiSelect.js +66 -0
  76. package/dist/ui/fields/Select.d.ts +17 -0
  77. package/dist/ui/fields/Select.js +37 -0
  78. package/dist/ui/fields/TextInput.d.ts +13 -0
  79. package/dist/ui/fields/TextInput.js +50 -0
  80. package/package.json +76 -0
  81. package/readme.md +147 -0
@@ -0,0 +1,101 @@
1
+ /**
2
+ * Publishing the setup branch. `gh` is optional throughout: without it, or
3
+ * against a host it cannot speak to, trunk pushes and prints the compare URL
4
+ * so the user can open the pull request themselves.
5
+ */
6
+ import { runCommand } from './process.js';
7
+ /**
8
+ * `gh` only authenticates against GitHub, so an SSH alias is resolved to its
9
+ * real host before deciding. A non-GitHub remote still gets a push and a URL.
10
+ */
11
+ export function canOpenPullRequest(remote, ghInstalled) {
12
+ return (ghInstalled && remote.kind === 'hosted' && remote.realHost === 'github.com');
13
+ }
14
+ /** Where the user opens the pull request when trunk cannot do it for them. */
15
+ export function compareUrl(remote, branch) {
16
+ if (remote.kind !== 'hosted') {
17
+ return undefined;
18
+ }
19
+ return `https://${remote.realHost}/${remote.owner}/${remote.repo}/compare/${encodeURIComponent(branch)}?expand=1`;
20
+ }
21
+ export async function pushBranch(worktreePath, branch, options = {}) {
22
+ return (options.run ?? runCommand)(options.gitPath ?? 'git', ['-C', worktreePath, 'push', '-u', 'origin', branch], { env: options.env });
23
+ }
24
+ /**
25
+ * `--fill` reuses the commit message, which is why the commit body is written
26
+ * for teammates: it becomes the pull request description.
27
+ */
28
+ export async function createPullRequest(worktreePath, options = {}) {
29
+ return (options.run ?? runCommand)(options.ghPath ?? 'gh', ['pr', 'create', '--fill'], { cwd: worktreePath, env: options.env });
30
+ }
31
+ /** The two commands trunk prints when it stops after the commit. */
32
+ export function publishCommands(branch, withPullRequest) {
33
+ const commands = [`git push -u origin ${branch}`];
34
+ if (withPullRequest) {
35
+ commands.push('gh pr create --fill');
36
+ }
37
+ return Object.freeze(commands);
38
+ }
39
+ /**
40
+ * The accounts a repository can be created under: the signed-in user first,
41
+ * then the organisations they belong to. An empty list means gh could not
42
+ * answer, which the caller treats as "ask for the owner instead of guessing".
43
+ */
44
+ export async function listOwners(options = {}) {
45
+ const run = options.run ?? runCommand;
46
+ const gh = options.ghPath ?? 'gh';
47
+ const [user, orgs] = await Promise.all([
48
+ run(gh, ['api', 'user', '--jq', '.login'], { env: options.env }),
49
+ run(gh, ['api', 'user/orgs', '--jq', '.[].login'], { env: options.env }),
50
+ ]);
51
+ const owners = [];
52
+ const login = user.code === 0 ? user.stdout.trim() : '';
53
+ if (login) {
54
+ owners.push(Object.freeze({ login, kind: 'user' }));
55
+ }
56
+ if (orgs.code === 0) {
57
+ for (const line of orgs.stdout.split(/\r?\n/)) {
58
+ const value = line.trim();
59
+ if (value) {
60
+ owners.push(Object.freeze({ login: value, kind: 'org' }));
61
+ }
62
+ }
63
+ }
64
+ return Object.freeze(owners);
65
+ }
66
+ /** Which account gh would act as, for the confirmation and for auth errors. */
67
+ export async function activeAccount(options = {}) {
68
+ const result = await (options.run ?? runCommand)(options.ghPath ?? 'gh', ['api', 'user', '--jq', '.login'], { env: options.env });
69
+ return result.code === 0 ? result.stdout.trim() || undefined : undefined;
70
+ }
71
+ /**
72
+ * Creates the repository and nothing else: no push, no clone, no source. trunk
73
+ * builds the local side itself, so gh only has to make the remote exist.
74
+ */
75
+ export async function createRepository(owner, name, options = {}) {
76
+ return (options.run ?? runCommand)(options.ghPath ?? 'gh', [
77
+ 'repo',
78
+ 'create',
79
+ `${owner}/${name}`,
80
+ `--${options.visibility ?? 'private'}`,
81
+ '--disable-wiki',
82
+ ], { env: options.env });
83
+ }
84
+ /** What to run by hand; trunk never deletes a remote repository itself. */
85
+ export function deleteRepositoryCommand(owner, name) {
86
+ return `gh repo delete ${owner}/${name} --yes`;
87
+ }
88
+ /**
89
+ * The SSH URL to use for a new remote. gh reports an https URL, but someone
90
+ * with several accounts reaches each through its own ssh alias, so the alias
91
+ * whose resolved host matches is preferred — and among those, one a sibling
92
+ * project already uses, since that is demonstrably the right account.
93
+ */
94
+ export function sshRemoteUrl(owner, name, options) {
95
+ const { realHost, aliases, siblingHosts = [] } = options;
96
+ const matching = Object.entries(aliases)
97
+ .filter(([, hostName]) => hostName.toLowerCase() === realHost.toLowerCase())
98
+ .map(([alias]) => alias);
99
+ const preferred = matching.find(alias => siblingHosts.includes(alias)) ?? matching[0];
100
+ return `git@${preferred ?? realHost}:${owner}/${name}.git`;
101
+ }
@@ -0,0 +1,79 @@
1
+ import { type AttachedRunner, type CommandResult, type CommandRunner } from './process.js';
2
+ export type GitOptions = Readonly<{
3
+ gitPath?: string;
4
+ run?: CommandRunner;
5
+ }>;
6
+ export declare class GitCommandError extends Error {
7
+ readonly arguments: readonly string[];
8
+ readonly result: CommandResult;
9
+ constructor(arguments_: readonly string[], result: CommandResult);
10
+ }
11
+ export declare function runGit(arguments_: readonly string[], options?: GitOptions): Promise<CommandResult>;
12
+ /**
13
+ * The repository's shared git directory. For a linked worktree this is the main
14
+ * one rather than the worktree's own, which is what identifies the project.
15
+ * Returns undefined when the path is not a repository at all.
16
+ */
17
+ export declare function gitCommonDirectory(gitDirectory: string, options?: GitOptions): Promise<string | undefined>;
18
+ export declare function isBareRepository(gitDirectory: string, options?: GitOptions): Promise<boolean | undefined>;
19
+ /**
20
+ * The default branch of a repository that is already on disk. HEAD answers it
21
+ * normally; the git config key wt maintains covers a repository whose HEAD is
22
+ * detached. Never assume `main` — plenty of projects use something else.
23
+ */
24
+ export declare function existingDefaultBranch(gitDirectory: string, options?: GitOptions): Promise<string | undefined>;
25
+ /** The refspec a bare clone leaves out, which `wt step copy-ignored` needs. */
26
+ export declare const originFetchRefspec = "+refs/heads/*:refs/remotes/origin/*";
27
+ /**
28
+ * Clones into `<project>/.git`, the layout trunk sets up: a bare repository
29
+ * with the worktrees as siblings rather than inside it.
30
+ */
31
+ export declare function cloneBare(url: string, gitDirectory: string, options?: GitOptions & {
32
+ attach?: AttachedRunner;
33
+ }): Promise<number>;
34
+ /**
35
+ * A bare clone has no fetch refspec, so `origin/*` never appears and anything
36
+ * comparing against the remote silently sees nothing. Set it, then fetch once
37
+ * to populate the refs.
38
+ */
39
+ export declare function configureOriginFetch(gitDirectory: string, options?: GitOptions): Promise<CommandResult>;
40
+ /** Points the bare repository's HEAD at the branch the remote considers default. */
41
+ export declare function setHeadBranch(gitDirectory: string, branch: string, options?: GitOptions): Promise<CommandResult>;
42
+ /**
43
+ * Adds a worktree beside the bare repository, the sibling layout trunk sets up.
44
+ * `createBranch` starts a new branch from HEAD instead of checking out one that
45
+ * already exists.
46
+ */
47
+ export declare function addWorktree(gitDirectory: string, path: string, branch: string, options?: GitOptions & {
48
+ createBranch?: boolean;
49
+ }): Promise<CommandResult>;
50
+ export type WorktreeEntry = Readonly<{
51
+ path: string;
52
+ branch?: string;
53
+ }>;
54
+ /**
55
+ * Every worktree with the branch it holds. Finding a worktree by its branch is
56
+ * the only reliable way to know where wt put one, since wt may place it beside
57
+ * the git directory rather than where trunk proposed.
58
+ */
59
+ export declare function listWorktreeEntries(gitDirectory: string, options?: GitOptions): Promise<readonly WorktreeEntry[]>;
60
+ /** Absolute worktree paths, used to check where wt actually put one. */
61
+ export declare function listWorktrees(gitDirectory: string, options?: GitOptions): Promise<readonly string[]>;
62
+ /**
63
+ * Commits one path and nothing else, so an unrelated file the user was already
64
+ * working on is never swept into trunk's commit.
65
+ */
66
+ export declare function commitPath(worktreePath: string, path: string, message: string, options?: GitOptions): Promise<CommandResult>;
67
+ /** Pulls the branch out of `ls-remote --symref` output: `ref: refs/heads/x HEAD`. */
68
+ export declare function parseRemoteDefaultBranch(output: string): string | undefined;
69
+ /**
70
+ * Asks the remote which branch its HEAD points at. This is the one call that
71
+ * needs network and credentials, so its raw git error is passed through
72
+ * untouched: for an auth failure that message is the actionable part.
73
+ */
74
+ export declare function remoteDefaultBranch(url: string, options?: GitOptions): Promise<string>;
75
+ /**
76
+ * Wraps {@link remoteDefaultBranch} with a per-run cache so one trunk run hits
77
+ * the network once per URL. Failures are evicted so a retry can succeed.
78
+ */
79
+ export declare function createRemoteDefaultBranchResolver(options?: GitOptions): (url: string) => Promise<string>;
@@ -0,0 +1,211 @@
1
+ /**
2
+ * Thin wrappers over the git commands trunk needs to read. Every call passes an
3
+ * explicit `--git-dir` instead of changing directory, so a command can never be
4
+ * answered by whatever repository the process happens to sit in.
5
+ */
6
+ import { isAbsolute, resolve } from 'node:path';
7
+ import { runAttached, runCommand, } from './process.js';
8
+ export class GitCommandError extends Error {
9
+ arguments;
10
+ result;
11
+ constructor(arguments_, result) {
12
+ const detail = result.stderr.trim() || result.stdout.trim() || 'unknown error';
13
+ super(`git ${arguments_.join(' ')} failed: ${detail}`);
14
+ this.name = 'GitCommandError';
15
+ this.arguments = arguments_;
16
+ this.result = result;
17
+ }
18
+ }
19
+ export async function runGit(arguments_, options = {}) {
20
+ return (options.run ?? runCommand)(options.gitPath ?? 'git', arguments_);
21
+ }
22
+ /**
23
+ * The repository's shared git directory. For a linked worktree this is the main
24
+ * one rather than the worktree's own, which is what identifies the project.
25
+ * Returns undefined when the path is not a repository at all.
26
+ */
27
+ export async function gitCommonDirectory(gitDirectory, options = {}) {
28
+ const result = await runGit(['--git-dir', gitDirectory, 'rev-parse', '--git-common-dir'], options);
29
+ if (result.code !== 0) {
30
+ return undefined;
31
+ }
32
+ const value = result.stdout.trim();
33
+ if (!value) {
34
+ return undefined;
35
+ }
36
+ return isAbsolute(value) ? value : resolve(gitDirectory, value);
37
+ }
38
+ export async function isBareRepository(gitDirectory, options = {}) {
39
+ const result = await runGit(['--git-dir', gitDirectory, 'rev-parse', '--is-bare-repository'], options);
40
+ if (result.code !== 0) {
41
+ return undefined;
42
+ }
43
+ const value = result.stdout.trim();
44
+ return value === 'true' ? true : value === 'false' ? false : undefined;
45
+ }
46
+ /**
47
+ * The default branch of a repository that is already on disk. HEAD answers it
48
+ * normally; the git config key wt maintains covers a repository whose HEAD is
49
+ * detached. Never assume `main` — plenty of projects use something else.
50
+ */
51
+ export async function existingDefaultBranch(gitDirectory, options = {}) {
52
+ const symbolic = await runGit(['--git-dir', gitDirectory, 'symbolic-ref', '--short', 'HEAD'], options);
53
+ if (symbolic.code === 0 && symbolic.stdout.trim()) {
54
+ return symbolic.stdout.trim();
55
+ }
56
+ const configured = await runGit(['--git-dir', gitDirectory, 'config', '--get', 'worktrunk.default-branch'], options);
57
+ return configured.code === 0 && configured.stdout.trim()
58
+ ? configured.stdout.trim()
59
+ : undefined;
60
+ }
61
+ /** The refspec a bare clone leaves out, which `wt step copy-ignored` needs. */
62
+ export const originFetchRefspec = '+refs/heads/*:refs/remotes/origin/*';
63
+ /**
64
+ * Clones into `<project>/.git`, the layout trunk sets up: a bare repository
65
+ * with the worktrees as siblings rather than inside it.
66
+ */
67
+ export async function cloneBare(url, gitDirectory, options = {}) {
68
+ return (options.attach ?? runAttached)(options.gitPath ?? 'git', [
69
+ 'clone',
70
+ '--bare',
71
+ '--progress',
72
+ url,
73
+ gitDirectory,
74
+ ]);
75
+ }
76
+ /**
77
+ * A bare clone has no fetch refspec, so `origin/*` never appears and anything
78
+ * comparing against the remote silently sees nothing. Set it, then fetch once
79
+ * to populate the refs.
80
+ */
81
+ export async function configureOriginFetch(gitDirectory, options = {}) {
82
+ const configured = await runGit([
83
+ '--git-dir',
84
+ gitDirectory,
85
+ 'config',
86
+ 'remote.origin.fetch',
87
+ originFetchRefspec,
88
+ ], options);
89
+ if (configured.code !== 0) {
90
+ return configured;
91
+ }
92
+ return runGit(['--git-dir', gitDirectory, 'fetch', 'origin', '--prune'], options);
93
+ }
94
+ /** Points the bare repository's HEAD at the branch the remote considers default. */
95
+ export async function setHeadBranch(gitDirectory, branch, options = {}) {
96
+ return runGit(['--git-dir', gitDirectory, 'symbolic-ref', 'HEAD', `refs/heads/${branch}`], options);
97
+ }
98
+ /**
99
+ * Adds a worktree beside the bare repository, the sibling layout trunk sets up.
100
+ * `createBranch` starts a new branch from HEAD instead of checking out one that
101
+ * already exists.
102
+ */
103
+ export async function addWorktree(gitDirectory, path, branch, options = {}) {
104
+ const arguments_ = options.createBranch
105
+ ? ['worktree', 'add', '-b', branch, path]
106
+ : ['worktree', 'add', path, branch];
107
+ return runGit(['--git-dir', gitDirectory, ...arguments_], options);
108
+ }
109
+ /**
110
+ * Every worktree with the branch it holds. Finding a worktree by its branch is
111
+ * the only reliable way to know where wt put one, since wt may place it beside
112
+ * the git directory rather than where trunk proposed.
113
+ */
114
+ export async function listWorktreeEntries(gitDirectory, options = {}) {
115
+ const result = await runGit(['--git-dir', gitDirectory, 'worktree', 'list', '--porcelain'], options);
116
+ if (result.code !== 0) {
117
+ return Object.freeze([]);
118
+ }
119
+ const entries = [];
120
+ let path;
121
+ let branch;
122
+ const flush = () => {
123
+ if (path) {
124
+ entries.push(Object.freeze({ path, branch }));
125
+ }
126
+ path = undefined;
127
+ branch = undefined;
128
+ };
129
+ for (const line of result.stdout.split(/\r?\n/)) {
130
+ if (line.startsWith('worktree ')) {
131
+ flush();
132
+ path = line.slice('worktree '.length).trim();
133
+ }
134
+ else if (line.startsWith('branch refs/heads/')) {
135
+ branch = line.slice('branch refs/heads/'.length).trim();
136
+ }
137
+ }
138
+ flush();
139
+ return Object.freeze(entries);
140
+ }
141
+ /** Absolute worktree paths, used to check where wt actually put one. */
142
+ export async function listWorktrees(gitDirectory, options = {}) {
143
+ const result = await runGit(['--git-dir', gitDirectory, 'worktree', 'list', '--porcelain'], options);
144
+ if (result.code !== 0) {
145
+ return Object.freeze([]);
146
+ }
147
+ return Object.freeze(result.stdout
148
+ .split(/\r?\n/)
149
+ .filter(line => line.startsWith('worktree '))
150
+ .map(line => line.slice('worktree '.length).trim())
151
+ .filter(Boolean));
152
+ }
153
+ /**
154
+ * Commits one path and nothing else, so an unrelated file the user was already
155
+ * working on is never swept into trunk's commit.
156
+ */
157
+ export async function commitPath(worktreePath, path, message, options = {}) {
158
+ const staged = await runGit(['-C', worktreePath, 'add', '--', path], options);
159
+ if (staged.code !== 0) {
160
+ return staged;
161
+ }
162
+ return runGit(['-C', worktreePath, 'commit', '--only', '--message', message, '--', path], options);
163
+ }
164
+ /** Pulls the branch out of `ls-remote --symref` output: `ref: refs/heads/x HEAD`. */
165
+ export function parseRemoteDefaultBranch(output) {
166
+ for (const line of output.split(/\r?\n/)) {
167
+ const match = /^ref:\s+refs\/heads\/(.+?)\s+HEAD$/.exec(line.trim());
168
+ if (match) {
169
+ return match[1];
170
+ }
171
+ }
172
+ return undefined;
173
+ }
174
+ /**
175
+ * Asks the remote which branch its HEAD points at. This is the one call that
176
+ * needs network and credentials, so its raw git error is passed through
177
+ * untouched: for an auth failure that message is the actionable part.
178
+ */
179
+ export async function remoteDefaultBranch(url, options = {}) {
180
+ const result = await runGit(['ls-remote', '--symref', url, 'HEAD'], options);
181
+ if (result.code !== 0) {
182
+ throw new GitCommandError(['ls-remote', '--symref', url, 'HEAD'], result);
183
+ }
184
+ const branch = parseRemoteDefaultBranch(result.stdout);
185
+ if (!branch) {
186
+ throw new Error(`Remote HEAD did not name a branch: ${url}`);
187
+ }
188
+ return branch;
189
+ }
190
+ /**
191
+ * Wraps {@link remoteDefaultBranch} with a per-run cache so one trunk run hits
192
+ * the network once per URL. Failures are evicted so a retry can succeed.
193
+ */
194
+ export function createRemoteDefaultBranchResolver(options = {}) {
195
+ const cache = new Map();
196
+ return async (url) => {
197
+ const cached = cache.get(url);
198
+ if (cached) {
199
+ return cached;
200
+ }
201
+ const pending = remoteDefaultBranch(url, options);
202
+ cache.set(url, pending);
203
+ try {
204
+ return await pending;
205
+ }
206
+ catch (error) {
207
+ cache.delete(url);
208
+ throw error;
209
+ }
210
+ };
211
+ }
@@ -0,0 +1,54 @@
1
+ export type JournalEntryKind =
2
+ /** The project folder itself, and whether trunk is the one that made it. */
3
+ 'folder' | 'bare-repo' | 'worktree'
4
+ /** A worktree together with the branch created for it. */
5
+ | 'branch-worktree'
6
+ /** A repository created on GitHub, which rollback never deletes. */
7
+ | 'github-repo';
8
+ export type JournalEntry = Readonly<{
9
+ kind: JournalEntryKind;
10
+ path: string;
11
+ branch?: string;
12
+ /** Trails the description, e.g. `wt.toml uncommitted`. */
13
+ note?: string;
14
+ }>;
15
+ export type UndoCommand = Readonly<{
16
+ executable: string;
17
+ arguments: readonly string[];
18
+ /** What the command is for, shown when the user keeps the run instead. */
19
+ purpose: string;
20
+ /**
21
+ * Printed for the user to run, never run by trunk. Deleting a repository
22
+ * someone may already have pushed to is not trunk's call to make.
23
+ */
24
+ manual?: boolean;
25
+ }>;
26
+ export declare class Journal {
27
+ private readonly records;
28
+ /** Called before the step that creates the thing, never after. */
29
+ record(entry: JournalEntry): void;
30
+ /**
31
+ * Corrects an entry after the fact, for when wt placed a worktree somewhere
32
+ * other than where trunk proposed. Rollback has to target the real path.
33
+ */
34
+ relocate(from: string, to: string): void;
35
+ /** Adds a trailing note to the most recent matching entry. */
36
+ annotate(path: string, note: string): void;
37
+ get entries(): readonly JournalEntry[];
38
+ get isEmpty(): boolean;
39
+ /** True only when trunk created the folder, which gates removing it. */
40
+ get createdFolder(): string | undefined;
41
+ /**
42
+ * The `created by this run:` block, with the paths in one column. Paths are
43
+ * shown relative to the working directory because that is how the user typed
44
+ * them and how the resume command will repeat them.
45
+ */
46
+ describe(workingDirectory: string): readonly string[];
47
+ }
48
+ /**
49
+ * How to undo the run, newest first: a worktree cannot be removed after the
50
+ * repository it belongs to is gone.
51
+ */
52
+ export declare function undoCommands(journal: Journal, projectDirectory: string, wtPath?: string, gitPath?: string): readonly UndoCommand[];
53
+ /** How to pick the run back up after keeping what it created. */
54
+ export declare function resumeCommand(projectDirectory: string, workingDirectory: string): string;
@@ -0,0 +1,147 @@
1
+ /**
2
+ * A record of what one run created, written before each step acts rather than
3
+ * after it succeeds. An interrupted run can then say exactly what exists and
4
+ * undo precisely that, without guessing from the state on disk.
5
+ *
6
+ * The journal only describes work; it never performs it. The command owns the
7
+ * running, so the order and the wording stay reviewable in one place.
8
+ */
9
+ import { relative } from 'node:path';
10
+ const descriptions = {
11
+ folder: 'project folder',
12
+ 'bare-repo': 'bare repo',
13
+ worktree: 'worktree',
14
+ 'branch-worktree': 'worktree + branch',
15
+ 'github-repo': 'GitHub repository',
16
+ };
17
+ export class Journal {
18
+ records = [];
19
+ /** Called before the step that creates the thing, never after. */
20
+ record(entry) {
21
+ this.records.push(Object.freeze({ ...entry }));
22
+ }
23
+ /**
24
+ * Corrects an entry after the fact, for when wt placed a worktree somewhere
25
+ * other than where trunk proposed. Rollback has to target the real path.
26
+ */
27
+ relocate(from, to) {
28
+ const index = this.records.findIndex(record => record.path === from);
29
+ if (index !== -1) {
30
+ this.records[index] = Object.freeze({ ...this.records[index], path: to });
31
+ }
32
+ }
33
+ /** Adds a trailing note to the most recent matching entry. */
34
+ annotate(path, note) {
35
+ const entry = this.records.findLast(record => record.path === path);
36
+ if (entry) {
37
+ this.records[this.records.indexOf(entry)] = Object.freeze({
38
+ ...entry,
39
+ note,
40
+ });
41
+ }
42
+ }
43
+ get entries() {
44
+ return Object.freeze([...this.records]);
45
+ }
46
+ get isEmpty() {
47
+ return this.records.length === 0;
48
+ }
49
+ /** True only when trunk created the folder, which gates removing it. */
50
+ get createdFolder() {
51
+ return this.records.find(entry => entry.kind === 'folder')?.path;
52
+ }
53
+ /**
54
+ * The `created by this run:` block, with the paths in one column. Paths are
55
+ * shown relative to the working directory because that is how the user typed
56
+ * them and how the resume command will repeat them.
57
+ */
58
+ describe(workingDirectory) {
59
+ const rows = this.records.map(entry => ({
60
+ path: entry.kind === 'github-repo'
61
+ ? entry.path
62
+ : displayPath(entry.path, workingDirectory),
63
+ description: describeEntry(entry),
64
+ }));
65
+ const width = Math.max(0, ...rows.map(row => row.path.length));
66
+ return Object.freeze(rows.map(row => `${row.path.padEnd(width)} ${row.description}`));
67
+ }
68
+ }
69
+ /**
70
+ * How to undo the run, newest first: a worktree cannot be removed after the
71
+ * repository it belongs to is gone.
72
+ */
73
+ export function undoCommands(journal, projectDirectory, wtPath = 'wt', gitPath = 'git') {
74
+ const commands = [];
75
+ for (const entry of [...journal.entries].reverse()) {
76
+ switch (entry.kind) {
77
+ case 'branch-worktree': {
78
+ commands.push({
79
+ executable: wtPath,
80
+ arguments: [
81
+ '-C',
82
+ projectDirectory,
83
+ 'remove',
84
+ entry.branch ?? '',
85
+ '--no-hooks',
86
+ '--yes',
87
+ ],
88
+ purpose: `remove the ${entry.branch ?? 'setup'} worktree and branch`,
89
+ });
90
+ break;
91
+ }
92
+ case 'worktree': {
93
+ commands.push({
94
+ executable: gitPath,
95
+ arguments: [
96
+ '-C',
97
+ projectDirectory,
98
+ 'worktree',
99
+ 'remove',
100
+ entry.path,
101
+ '--force',
102
+ ],
103
+ purpose: 'remove the default worktree',
104
+ });
105
+ break;
106
+ }
107
+ case 'folder': {
108
+ commands.push({
109
+ executable: 'rm',
110
+ arguments: ['-rf', entry.path],
111
+ purpose: 'remove the project folder trunk created',
112
+ });
113
+ break;
114
+ }
115
+ case 'github-repo': {
116
+ commands.push({
117
+ executable: 'gh',
118
+ arguments: ['repo', 'delete', entry.path, '--yes'],
119
+ purpose: 'delete the GitHub repository trunk created',
120
+ manual: true,
121
+ });
122
+ break;
123
+ }
124
+ case 'bare-repo': {
125
+ // Removed with the folder; deleting it alone would strand the
126
+ // worktrees that point at it.
127
+ break;
128
+ }
129
+ }
130
+ }
131
+ return Object.freeze(commands);
132
+ }
133
+ /** How to pick the run back up after keeping what it created. */
134
+ export function resumeCommand(projectDirectory, workingDirectory) {
135
+ return `trunk init ${displayPath(projectDirectory, workingDirectory)}`;
136
+ }
137
+ function describeEntry(entry) {
138
+ const description = descriptions[entry.kind];
139
+ return entry.note ? `${description} (${entry.note})` : description;
140
+ }
141
+ function displayPath(path, workingDirectory) {
142
+ const value = relative(workingDirectory, path);
143
+ if (!value) {
144
+ return '.';
145
+ }
146
+ return value.startsWith('.') ? value : `./${value}`;
147
+ }
@@ -0,0 +1,8 @@
1
+ import { type Outcome } from './result.js';
2
+ type StepKind = 'success' | 'error' | 'info' | 'warning';
3
+ export declare function step(kind: StepKind, message: string): void;
4
+ /** One JSON line on stdout, for callers that parse trunk's output. */
5
+ export declare function result(value: unknown): void;
6
+ /** Prints a command's closing line. A message-less Outcome stays silent. */
7
+ export declare function reportOutcome(outcome: Outcome): void;
8
+ export {};
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Plain-text progress reporting. Human-readable step lines go to stderr and
3
+ * machine-readable results to stdout, so a caller can pipe trunk's output
4
+ * somewhere useful while the person still sees what happened. It deliberately
5
+ * avoids Ink: `--yes` runs never mount a UI.
6
+ */
7
+ import process from 'node:process';
8
+ import { exitCodes } from './result.js';
9
+ /** ANSI colour numbers, applied only when the terminal will render them. */
10
+ const stepStyles = {
11
+ success: { symbol: '✓', color: 32 },
12
+ error: { symbol: '✗', color: 31 },
13
+ info: { symbol: '→', color: 36 },
14
+ warning: { symbol: '⚠', color: 33 },
15
+ };
16
+ export function step(kind, message) {
17
+ const { symbol, color } = stepStyles[kind];
18
+ const decoratedSymbol = shouldUseColor()
19
+ ? `\u001B[${color}m${symbol}\u001B[39m`
20
+ : symbol;
21
+ process.stderr.write(`${decoratedSymbol} ${message}\n`);
22
+ }
23
+ /** One JSON line on stdout, for callers that parse trunk's output. */
24
+ export function result(value) {
25
+ process.stdout.write(`${JSON.stringify(value)}\n`);
26
+ }
27
+ /** Prints a command's closing line. A message-less Outcome stays silent. */
28
+ export function reportOutcome(outcome) {
29
+ if (!outcome.message) {
30
+ return;
31
+ }
32
+ step(outcome.code === exitCodes.success ? 'success' : 'error', outcome.message);
33
+ }
34
+ function shouldUseColor() {
35
+ // Step lines go to stderr, so that is the stream whose TTY state matters.
36
+ // NO_COLOR only counts when it is set to a non-empty value.
37
+ return !process.env['NO_COLOR'] && Boolean(process.stderr.isTTY);
38
+ }