@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
package/dist/core/gh.js
ADDED
|
@@ -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>;
|
package/dist/core/git.js
ADDED
|
@@ -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 {};
|
package/dist/core/log.js
ADDED
|
@@ -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
|
+
}
|