@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/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 theCodeD3vil
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/dist/cli.d.ts
ADDED
package/dist/cli.js
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Entry point. It parses the command line, checks the machine can run trunk,
|
|
4
|
+
* hands the work to one command module, and turns the returned Outcome into an
|
|
5
|
+
* exit code. All the real logic lives in `core/` and `commands/`; nothing here
|
|
6
|
+
* knows how a project is set up.
|
|
7
|
+
*/
|
|
8
|
+
import process from 'node:process';
|
|
9
|
+
import { parseArguments } from './core/arguments.js';
|
|
10
|
+
import { checkRequiredTools, probe, wtVersionWarning, } from './core/env.js';
|
|
11
|
+
import { reportOutcome, step } from './core/log.js';
|
|
12
|
+
import { guardPlatform } from './core/platform.js';
|
|
13
|
+
import { badUsage, succeed } from './core/result.js';
|
|
14
|
+
import { runClone } from './commands/clone.js';
|
|
15
|
+
import { runInit } from './commands/init.js';
|
|
16
|
+
import { runNew } from './commands/new.js';
|
|
17
|
+
const cli = parseArguments();
|
|
18
|
+
async function dispatch() {
|
|
19
|
+
const unsupportedPlatform = guardPlatform();
|
|
20
|
+
if (unsupportedPlatform) {
|
|
21
|
+
return unsupportedPlatform;
|
|
22
|
+
}
|
|
23
|
+
const [command, ...rest] = cli.input;
|
|
24
|
+
// Only the real commands need tools, so printing usage stays instant and
|
|
25
|
+
// works on a machine that has neither git nor wt installed.
|
|
26
|
+
let tools;
|
|
27
|
+
if (command === 'clone' || command === 'init' || command === 'new') {
|
|
28
|
+
tools = await probe();
|
|
29
|
+
const missingTools = checkRequiredTools(tools);
|
|
30
|
+
if (missingTools) {
|
|
31
|
+
return missingTools;
|
|
32
|
+
}
|
|
33
|
+
// An older wt still works; its templates may just render differently, so
|
|
34
|
+
// this warns and carries on rather than refusing to run.
|
|
35
|
+
const versionWarning = wtVersionWarning(tools.wt.version);
|
|
36
|
+
if (versionWarning) {
|
|
37
|
+
step('warning', versionWarning);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
switch (command) {
|
|
41
|
+
case 'clone': {
|
|
42
|
+
return runClone(rest, cli.flags, tools, {
|
|
43
|
+
invocation: { executable: 'trunk', arguments: process.argv.slice(2) },
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
case 'init': {
|
|
47
|
+
return runInit(rest, cli.flags, tools, {
|
|
48
|
+
invocation: { executable: 'trunk', arguments: process.argv.slice(2) },
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
case 'new': {
|
|
52
|
+
return runNew(rest, cli.flags, tools, {
|
|
53
|
+
invocation: { executable: 'trunk', arguments: process.argv.slice(2) },
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
case undefined: {
|
|
57
|
+
// Printed here rather than through `cli.showHelp`, which exits inside
|
|
58
|
+
// meow; `process.exit` stays a single call site at the bottom of this file.
|
|
59
|
+
process.stdout.write(`${cli.help}\n`);
|
|
60
|
+
return succeed();
|
|
61
|
+
}
|
|
62
|
+
default: {
|
|
63
|
+
return badUsage(`unknown command: ${command}`);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
const outcome = await dispatch();
|
|
68
|
+
reportOutcome(outcome);
|
|
69
|
+
process.exit(outcome.code);
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { CliFlags } from '../core/arguments.js';
|
|
2
|
+
import type { ToolProbe } from '../core/env.js';
|
|
3
|
+
import { type SetupDependencies } from '../core/pipeline.js';
|
|
4
|
+
import { type Outcome } from '../core/result.js';
|
|
5
|
+
export type { SetupDependencies as CloneDependencies } from '../core/pipeline.js';
|
|
6
|
+
export declare function runClone(arguments_: readonly string[], flags: CliFlags, tools: ToolProbe, dependencies?: SetupDependencies): Promise<Outcome>;
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `trunk clone <url> [dir]`: from a remote URL to a project on disk, then into
|
|
3
|
+
* the shared setup pipeline.
|
|
4
|
+
*
|
|
5
|
+
* Nothing here ever changes directory: every git call carries `--git-dir` or
|
|
6
|
+
* `-C`, so the pipeline is safe to run from inside another worktree. And every
|
|
7
|
+
* step records what it is about to create in the journal before acting, so an
|
|
8
|
+
* interrupted run can describe or undo precisely what exists.
|
|
9
|
+
*/
|
|
10
|
+
import { join, basename, resolve as resolvePath } from 'node:path';
|
|
11
|
+
import { addWorktree, cloneBare, configureOriginFetch, remoteDefaultBranch, setHeadBranch, } from '../core/git.js';
|
|
12
|
+
import { configureProject, createContext, errorMessage, folderState, interrupted, locateWorktree, outputOf, warnAboutWorktreePath, } from '../core/pipeline.js';
|
|
13
|
+
import { loadSshAliases, parseRemote } from '../core/repo.js';
|
|
14
|
+
import { badUsage } from '../core/result.js';
|
|
15
|
+
import { switchAttached } from '../core/wt.js';
|
|
16
|
+
export async function runClone(arguments_, flags, tools, dependencies = {}) {
|
|
17
|
+
const [url, directory, ...extra] = arguments_;
|
|
18
|
+
if (!url) {
|
|
19
|
+
return badUsage('trunk clone needs a remote URL');
|
|
20
|
+
}
|
|
21
|
+
if (extra.length > 0) {
|
|
22
|
+
return badUsage(`unexpected argument: ${extra[0]}`);
|
|
23
|
+
}
|
|
24
|
+
const context = createContext(flags, tools, dependencies);
|
|
25
|
+
let remote;
|
|
26
|
+
try {
|
|
27
|
+
remote = parseRemote(url, await loadSshAliases(), context.cwd);
|
|
28
|
+
}
|
|
29
|
+
catch (error) {
|
|
30
|
+
return badUsage(errorMessage(error));
|
|
31
|
+
}
|
|
32
|
+
const projectDirectory = resolvePath(context.cwd, directory ?? (remote.repo || basename(url)));
|
|
33
|
+
const emptiness = await folderState(projectDirectory);
|
|
34
|
+
if (emptiness === 'occupied') {
|
|
35
|
+
return badUsage(`${projectDirectory} already exists and is not empty; choose another folder`);
|
|
36
|
+
}
|
|
37
|
+
try {
|
|
38
|
+
return await pipeline(context, remote, projectDirectory, emptiness);
|
|
39
|
+
}
|
|
40
|
+
catch (error) {
|
|
41
|
+
return interrupted(context, projectDirectory, errorMessage(error));
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
async function pipeline(context, remote, projectDirectory, emptiness) {
|
|
45
|
+
const gitDirectory = join(projectDirectory, '.git');
|
|
46
|
+
// Step 2: the bare clone, which is also what creates the folder.
|
|
47
|
+
if (emptiness === 'missing') {
|
|
48
|
+
context.journal.record({ kind: 'folder', path: projectDirectory });
|
|
49
|
+
}
|
|
50
|
+
context.journal.record({ kind: 'bare-repo', path: gitDirectory });
|
|
51
|
+
context.report('info', `cloning ${remote.url}`);
|
|
52
|
+
const cloned = await cloneBare(remote.url, gitDirectory, {
|
|
53
|
+
...context.git,
|
|
54
|
+
attach: context.attach,
|
|
55
|
+
});
|
|
56
|
+
if (cloned !== 0) {
|
|
57
|
+
return interrupted(context, projectDirectory, 'git clone failed');
|
|
58
|
+
}
|
|
59
|
+
// Step 3: a bare clone has no fetch refspec, so origin/* would stay empty.
|
|
60
|
+
const configured = await configureOriginFetch(gitDirectory, context.git);
|
|
61
|
+
if (configured.code !== 0) {
|
|
62
|
+
return interrupted(context, projectDirectory, `could not configure the origin refspec: ${outputOf(configured)}`);
|
|
63
|
+
}
|
|
64
|
+
// Step 4: the remote decides the default branch; never assume main.
|
|
65
|
+
const defaultBranch = await remoteDefaultBranch(remote.url, context.git);
|
|
66
|
+
await setHeadBranch(gitDirectory, defaultBranch, context.git);
|
|
67
|
+
context.report('success', `default branch ${defaultBranch}`);
|
|
68
|
+
// Step 5: let wt place the first worktree so it can ask its own question.
|
|
69
|
+
const defaultWorktree = await createFirstWorktree(context, {
|
|
70
|
+
remote,
|
|
71
|
+
projectDirectory,
|
|
72
|
+
gitDirectory,
|
|
73
|
+
branch: defaultBranch,
|
|
74
|
+
});
|
|
75
|
+
if (!defaultWorktree) {
|
|
76
|
+
return interrupted(context, projectDirectory, `could not create the ${defaultBranch} worktree`);
|
|
77
|
+
}
|
|
78
|
+
return configureProject(context, {
|
|
79
|
+
remote,
|
|
80
|
+
projectDirectory,
|
|
81
|
+
gitDirectory,
|
|
82
|
+
defaultWorktree,
|
|
83
|
+
defaultBranch,
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* Worktrunk owns worktree placement, so the attached call is the one that gets
|
|
88
|
+
* it right. Without a terminal it cannot ask, so trunk falls back to plain git and
|
|
89
|
+
* says what that will cost.
|
|
90
|
+
*/
|
|
91
|
+
async function createFirstWorktree(context, location) {
|
|
92
|
+
const { remote, projectDirectory, gitDirectory, branch } = location;
|
|
93
|
+
const expected = join(projectDirectory, branch.replaceAll('/', '-'));
|
|
94
|
+
context.journal.record({ kind: 'worktree', path: expected, branch });
|
|
95
|
+
if (context.interactive) {
|
|
96
|
+
await switchAttached(projectDirectory, branch, {
|
|
97
|
+
wtPath: context.wtPath,
|
|
98
|
+
attach: context.attach,
|
|
99
|
+
env: context.env,
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
else {
|
|
103
|
+
const added = await addWorktree(gitDirectory, expected, branch, context.git);
|
|
104
|
+
if (added.code !== 0) {
|
|
105
|
+
return undefined;
|
|
106
|
+
}
|
|
107
|
+
warnAboutWorktreePath(context, remote, expected);
|
|
108
|
+
}
|
|
109
|
+
const actual = await locateWorktree(gitDirectory, expected, context.git);
|
|
110
|
+
if (actual !== expected) {
|
|
111
|
+
warnAboutWorktreePath(context, remote, actual);
|
|
112
|
+
}
|
|
113
|
+
return actual;
|
|
114
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { CliFlags } from '../core/arguments.js';
|
|
2
|
+
import type { ToolProbe } from '../core/env.js';
|
|
3
|
+
import { type SetupDependencies } from '../core/pipeline.js';
|
|
4
|
+
import { type Outcome } from '../core/result.js';
|
|
5
|
+
export type { SetupDependencies as InitDependencies } from '../core/pipeline.js';
|
|
6
|
+
export declare function runInit(arguments_: readonly string[], flags: CliFlags, tools: ToolProbe, dependencies?: SetupDependencies): Promise<Outcome>;
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `trunk init [dir]`: set up a project that is already on disk in the bare
|
|
3
|
+
* layout — cloned by hand, already using worktrunk, or left behind by an
|
|
4
|
+
* interrupted `trunk clone`.
|
|
5
|
+
*
|
|
6
|
+
* Everything from the existing-config question onward is the same code
|
|
7
|
+
* `trunk clone` runs. What is specific here is finding the project, refusing a
|
|
8
|
+
* plain clone, and repairing whatever a hand-made setup is missing.
|
|
9
|
+
*/
|
|
10
|
+
import { join, resolve as resolvePath } from 'node:path';
|
|
11
|
+
import { adoptConfig } from '../core/adopt.js';
|
|
12
|
+
import { addWorktree, configureOriginFetch, existingDefaultBranch, listWorktrees, originFetchRefspec, remoteDefaultBranch, runGit, setHeadBranch, } from '../core/git.js';
|
|
13
|
+
import { configureProject, confirmStep, createContext, errorMessage, interrupted, locateWorktree, readExistingConfig, warnAboutWorktreePath, } from '../core/pipeline.js';
|
|
14
|
+
import { detectLayout, loadSshAliases, parseRemote, } from '../core/repo.js';
|
|
15
|
+
import { badUsage, unsupportedEnvironment, } from '../core/result.js';
|
|
16
|
+
import { listSessions, manualKillCommands, planRenames, renameSessions, } from '../core/tmuxRename.js';
|
|
17
|
+
import { switchAttached } from '../core/wt.js';
|
|
18
|
+
export async function runInit(arguments_, flags, tools, dependencies = {}) {
|
|
19
|
+
const [directory, ...extra] = arguments_;
|
|
20
|
+
if (extra.length > 0) {
|
|
21
|
+
return badUsage(`unexpected argument: ${extra[0]}`);
|
|
22
|
+
}
|
|
23
|
+
const context = createContext(flags, tools, dependencies);
|
|
24
|
+
const target = resolvePath(context.cwd, directory ?? '.');
|
|
25
|
+
const layout = await detectLayout(target, context.git);
|
|
26
|
+
switch (layout.kind) {
|
|
27
|
+
case 'plain-clone': {
|
|
28
|
+
return refusePlainClone(context, layout);
|
|
29
|
+
}
|
|
30
|
+
case 'empty': {
|
|
31
|
+
return badUsage(`${target} holds no repository; use \`trunk clone <url> ${directory ?? '<dir>'}\` instead`);
|
|
32
|
+
}
|
|
33
|
+
case 'occupied': {
|
|
34
|
+
return badUsage(`${target} is not a git repository`);
|
|
35
|
+
}
|
|
36
|
+
default: {
|
|
37
|
+
break;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
const projectDirectory = layout.projectRoot ?? target;
|
|
41
|
+
const gitDirectory = layout.gitDir ?? join(projectDirectory, '.git');
|
|
42
|
+
try {
|
|
43
|
+
return await setUpExisting(context, projectDirectory, gitDirectory);
|
|
44
|
+
}
|
|
45
|
+
catch (error) {
|
|
46
|
+
return interrupted(context, projectDirectory, errorMessage(error));
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
async function setUpExisting(context, projectDirectory, gitDirectory) {
|
|
50
|
+
const url = await originUrl(gitDirectory, context.git);
|
|
51
|
+
if (!url) {
|
|
52
|
+
return badUsage(`${projectDirectory} has no origin remote; trunk needs one to name the project`);
|
|
53
|
+
}
|
|
54
|
+
let remote;
|
|
55
|
+
try {
|
|
56
|
+
remote = parseRemote(url, await loadSshAliases(), context.cwd);
|
|
57
|
+
}
|
|
58
|
+
catch (error) {
|
|
59
|
+
return badUsage(errorMessage(error));
|
|
60
|
+
}
|
|
61
|
+
const defaultBranch = await repairDefaultBranch(context, gitDirectory, remote);
|
|
62
|
+
await repairFetchRefspec(context, gitDirectory);
|
|
63
|
+
const defaultWorktree = await ensureWorktree(context, {
|
|
64
|
+
remote,
|
|
65
|
+
projectDirectory,
|
|
66
|
+
gitDirectory,
|
|
67
|
+
branch: defaultBranch,
|
|
68
|
+
});
|
|
69
|
+
if (!defaultWorktree) {
|
|
70
|
+
return interrupted(context, projectDirectory, `could not create the ${defaultBranch} worktree`);
|
|
71
|
+
}
|
|
72
|
+
// Adoption runs before the form so the repository's own choices become the
|
|
73
|
+
// defaults, rather than trunk's.
|
|
74
|
+
const existing = await readExistingConfig(defaultWorktree);
|
|
75
|
+
const adoption = existing ? adoptConfig(existing) : undefined;
|
|
76
|
+
for (const note of adoption?.notes ?? []) {
|
|
77
|
+
context.report('warning', note.message);
|
|
78
|
+
}
|
|
79
|
+
const outcome = await configureProject(context, {
|
|
80
|
+
remote,
|
|
81
|
+
projectDirectory,
|
|
82
|
+
gitDirectory,
|
|
83
|
+
defaultWorktree,
|
|
84
|
+
defaultBranch,
|
|
85
|
+
adopted: adoption?.values,
|
|
86
|
+
copyIgnoredExclude: adoption?.copyIgnoredExclude,
|
|
87
|
+
async onSettings(settings) {
|
|
88
|
+
await offerSessionRenames(context, adoption?.values.prefix, settings.prefix);
|
|
89
|
+
},
|
|
90
|
+
});
|
|
91
|
+
return outcome;
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* Sessions are named `<prefix>_<branch>`, so changing the prefix without
|
|
95
|
+
* renaming them leaves sessions `wt remove` can no longer find. Offered rather
|
|
96
|
+
* than done: the sessions belong to whoever is working in them.
|
|
97
|
+
*/
|
|
98
|
+
async function offerSessionRenames(context, oldPrefix, newPrefix) {
|
|
99
|
+
if (!oldPrefix || oldPrefix === newPrefix || !context.tools.tmux.path) {
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
const tmuxPath = context.tools.tmux.path;
|
|
103
|
+
const sessions = await listSessions({
|
|
104
|
+
tmuxPath,
|
|
105
|
+
run: context.run,
|
|
106
|
+
env: context.env,
|
|
107
|
+
});
|
|
108
|
+
const renames = planRenames(sessions, oldPrefix, newPrefix);
|
|
109
|
+
if (renames.length === 0) {
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
context.report('info', `the prefix changed from ${oldPrefix} to ${newPrefix}; these tmux sessions would be renamed:`);
|
|
113
|
+
for (const rename of renames) {
|
|
114
|
+
context.report('info', ` ${rename.from} → ${rename.to}`);
|
|
115
|
+
}
|
|
116
|
+
const wanted = await confirmStep(context, 'rename them now?');
|
|
117
|
+
if (!wanted) {
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
const result = await renameSessions(renames, {
|
|
121
|
+
tmuxPath,
|
|
122
|
+
run: context.run,
|
|
123
|
+
env: context.env,
|
|
124
|
+
});
|
|
125
|
+
for (const rename of result.renamed) {
|
|
126
|
+
context.report('success', `renamed ${rename.from} to ${rename.to}`);
|
|
127
|
+
}
|
|
128
|
+
if (result.failed.length > 0) {
|
|
129
|
+
context.report('warning', '`wt remove` will not find these sessions; kill them by hand:');
|
|
130
|
+
for (const command of manualKillCommands(result.failed)) {
|
|
131
|
+
context.report('info', ` ${command}`);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
/**
|
|
136
|
+
* Trunk sets up bare-layout projects and never converts a checkout in place:
|
|
137
|
+
* the checkout may hold uncommitted work, ignored files and a shell someone is
|
|
138
|
+
* standing in. It prints the recipe and lets the user decide.
|
|
139
|
+
*/
|
|
140
|
+
async function refusePlainClone(context, layout) {
|
|
141
|
+
const clone = layout.worktreeRoot ?? layout.projectRoot ?? layout.path;
|
|
142
|
+
const gitDirectory = layout.gitDir ?? join(clone, '.git');
|
|
143
|
+
const url = (await originUrl(gitDirectory, context.git)) ?? '<remote url>';
|
|
144
|
+
const name = clone.split('/').at(-1) ?? 'project';
|
|
145
|
+
const dirty = await describeUnsavedWork(context, clone);
|
|
146
|
+
if (dirty.length > 0) {
|
|
147
|
+
context.report('warning', `${clone} still holds ${dirty.join(' and ')}; keep it until that is safe`);
|
|
148
|
+
}
|
|
149
|
+
for (const line of [
|
|
150
|
+
`${clone} is a normal clone; trunk sets up bare-layout projects.`,
|
|
151
|
+
'',
|
|
152
|
+
` trunk clone ${url} ${name}-wt`,
|
|
153
|
+
` cp ${name}/.env* ${name}-wt/<default branch>/`,
|
|
154
|
+
` # check nothing uncommitted or unpushed is left in ${name}, then:`,
|
|
155
|
+
` rm -rf ${name} && mv ${name}-wt ${name}`,
|
|
156
|
+
]) {
|
|
157
|
+
context.report('info', line);
|
|
158
|
+
}
|
|
159
|
+
return unsupportedEnvironment(`${clone} is a normal clone`);
|
|
160
|
+
}
|
|
161
|
+
/** Uncommitted and unpushed work, named so the warning is specific. */
|
|
162
|
+
async function describeUnsavedWork(context, clone) {
|
|
163
|
+
const found = [];
|
|
164
|
+
const status = await runGit(['-C', clone, 'status', '--porcelain'], context.git);
|
|
165
|
+
if (status.code === 0 && status.stdout.trim()) {
|
|
166
|
+
found.push('uncommitted changes');
|
|
167
|
+
}
|
|
168
|
+
const unpushed = await runGit(['-C', clone, 'log', '--branches', '--not', '--remotes', '--oneline'], context.git);
|
|
169
|
+
if (unpushed.code === 0 && unpushed.stdout.trim()) {
|
|
170
|
+
found.push('unpushed commits');
|
|
171
|
+
}
|
|
172
|
+
return Object.freeze(found);
|
|
173
|
+
}
|
|
174
|
+
/** A hand-made bare clone usually has no refspec, so `origin/*` is empty. */
|
|
175
|
+
async function repairFetchRefspec(context, gitDirectory) {
|
|
176
|
+
const configured = await runGit(['--git-dir', gitDirectory, 'config', '--get', 'remote.origin.fetch'], context.git);
|
|
177
|
+
if (configured.code === 0 &&
|
|
178
|
+
configured.stdout.trim() === originFetchRefspec) {
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
const repaired = await configureOriginFetch(gitDirectory, context.git);
|
|
182
|
+
context.report(repaired.code === 0 ? 'success' : 'warning', repaired.code === 0
|
|
183
|
+
? 'set the origin fetch refspec and fetched'
|
|
184
|
+
: 'could not set the origin fetch refspec');
|
|
185
|
+
}
|
|
186
|
+
/** HEAD first, then the remote; record it the way wt expects to find it. */
|
|
187
|
+
async function repairDefaultBranch(context, gitDirectory, remote) {
|
|
188
|
+
const existing = await existingDefaultBranch(gitDirectory, context.git);
|
|
189
|
+
if (existing) {
|
|
190
|
+
return existing;
|
|
191
|
+
}
|
|
192
|
+
const branch = await remoteDefaultBranch(remote.url, context.git);
|
|
193
|
+
await setHeadBranch(gitDirectory, branch, context.git);
|
|
194
|
+
await runGit(['--git-dir', gitDirectory, 'config', 'worktrunk.default-branch', branch], context.git);
|
|
195
|
+
context.report('success', `recorded the default branch ${branch}`);
|
|
196
|
+
return branch;
|
|
197
|
+
}
|
|
198
|
+
/**
|
|
199
|
+
* Reuses the worktree for the default branch when there is one, which is also
|
|
200
|
+
* what makes `trunk init` the resume command after an interrupted clone.
|
|
201
|
+
*/
|
|
202
|
+
async function ensureWorktree(context, location) {
|
|
203
|
+
const { remote, projectDirectory, gitDirectory, branch } = location;
|
|
204
|
+
const expected = join(projectDirectory, branch.replaceAll('/', '-'));
|
|
205
|
+
const worktrees = await listWorktrees(gitDirectory, context.git);
|
|
206
|
+
const existing = await locateWorktree(gitDirectory, expected, context.git);
|
|
207
|
+
if (worktrees.includes(existing) && existing !== projectDirectory) {
|
|
208
|
+
return existing;
|
|
209
|
+
}
|
|
210
|
+
context.journal.record({ kind: 'worktree', path: expected, branch });
|
|
211
|
+
if (context.interactive) {
|
|
212
|
+
await switchAttached(projectDirectory, branch, {
|
|
213
|
+
wtPath: context.wtPath,
|
|
214
|
+
attach: context.attach,
|
|
215
|
+
env: context.env,
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
else {
|
|
219
|
+
const added = await addWorktree(gitDirectory, expected, branch, context.git);
|
|
220
|
+
if (added.code !== 0) {
|
|
221
|
+
return undefined;
|
|
222
|
+
}
|
|
223
|
+
warnAboutWorktreePath(context, remote, expected);
|
|
224
|
+
}
|
|
225
|
+
const actual = await locateWorktree(gitDirectory, expected, context.git);
|
|
226
|
+
if (actual !== expected) {
|
|
227
|
+
context.journal.relocate(expected, actual);
|
|
228
|
+
warnAboutWorktreePath(context, remote, actual);
|
|
229
|
+
}
|
|
230
|
+
return actual;
|
|
231
|
+
}
|
|
232
|
+
async function originUrl(gitDirectory, options) {
|
|
233
|
+
const result = await runGit(['--git-dir', gitDirectory, 'config', '--get', 'remote.origin.url'], options);
|
|
234
|
+
return result.code === 0 ? result.stdout.trim() || undefined : undefined;
|
|
235
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { CliFlags } from '../core/arguments.js';
|
|
2
|
+
import type { ToolProbe } from '../core/env.js';
|
|
3
|
+
import { type SetupDependencies } from '../core/pipeline.js';
|
|
4
|
+
import { type Outcome } from '../core/result.js';
|
|
5
|
+
export type { SetupDependencies as NewDependencies } from '../core/pipeline.js';
|
|
6
|
+
export declare function runNew(arguments_: readonly string[], flags: CliFlags, tools: ToolProbe, dependencies?: SetupDependencies): Promise<Outcome>;
|