@paleo/worktree-env 0.6.1 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +7 -0
- package/dist/cli.js +4 -4
- package/dist/dev-server.js +2 -1
- package/dist/helpers.d.ts +8 -1
- package/dist/helpers.js +24 -4
- package/dist/index.d.ts +2 -0
- package/dist/index.js +1 -0
- package/dist/server-descriptor.d.ts +5 -1
- package/dist/setup-worktree.d.ts +44 -10
- package/dist/setup-worktree.js +15 -5
- package/dist/worktree.d.ts +14 -3
- package/dist/worktree.js +37 -7
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -58,6 +58,11 @@ await runSetupWorktree({
|
|
|
58
58
|
}),
|
|
59
59
|
},
|
|
60
60
|
],
|
|
61
|
+
preSetup: ({ currentWorktree, isMainWorktree, log }) => {
|
|
62
|
+
// Idempotent. Bootstrap source files the kernel expects to find (e.g. seed `.env` from
|
|
63
|
+
// `.env.example` on the main worktree). MUST NOT mutate the main worktree from a linked
|
|
64
|
+
// worktree setup — bootstrap the main first via `setup-worktree --here`.
|
|
65
|
+
},
|
|
61
66
|
finalizeWorktree: async ({ currentWorktree }) => {
|
|
62
67
|
// MUST be idempotent. Install deps, start containers, seed a database, etc.
|
|
63
68
|
},
|
|
@@ -68,6 +73,8 @@ await runSetupWorktree({
|
|
|
68
73
|
|
|
69
74
|
Setup runs in two phases: a fast foreground Part 1 creates the worktree and config, then a detached Part 2 runs `finalizeWorktree` and writes progress to `<runtimeDir>/wt-setup.log`. If Part 2 fails, `cd` into the worktree and run `setup-worktree --here` — it is idempotent and retries the finalize step. To block until Part 2 finishes (CI, agent orchestration), run `setup-worktree --wait` from inside the worktree (or `setup-worktree --wait --slot 8110` from anywhere) — exits 0 on `READY`, 1 on `FAILED`.
|
|
70
75
|
|
|
76
|
+
**Bootstrap the main worktree first.** Linked-worktree setup copies config sources from the main worktree, so the main must already have those files. Run `setup-worktree --here` once on the main checkout. Use `preSetup` (with `isMainWorktree === true`) to seed sources from examples or templates. `configFiles` entries are required by default; mark `optional: true` for sources that may legitimately be missing.
|
|
77
|
+
|
|
71
78
|
`--evict` is best-effort: the cap check and the subsequent register are not atomic, so two concurrent `dev:up --evict` from different worktrees can both pass the check and end up at `devLimit + 1` live servers. The window is narrow; if it matters, `dev:list` + `dev:down` deterministically.
|
|
72
79
|
|
|
73
80
|
```ts
|
package/dist/cli.js
CHANGED
|
@@ -2,15 +2,15 @@ import { parseArgs } from "node:util";
|
|
|
2
2
|
import { ConfigError } from "./errors.js";
|
|
3
3
|
const SETUP_OPTIONS = {
|
|
4
4
|
help: { type: "boolean", short: "h", description: "Show this help message" },
|
|
5
|
-
|
|
5
|
+
create: {
|
|
6
6
|
type: "string",
|
|
7
7
|
arg: "branch",
|
|
8
|
-
description: "Create a
|
|
8
|
+
description: "Create a new branch + worktree, then set up the local environment. If the branch already exists, appends a numeric suffix (-2, -3, ...)",
|
|
9
9
|
},
|
|
10
|
-
|
|
10
|
+
use: {
|
|
11
11
|
type: "string",
|
|
12
12
|
arg: "branch",
|
|
13
|
-
description: "Create a
|
|
13
|
+
description: "Create a worktree for an existing branch, then set up the local environment",
|
|
14
14
|
},
|
|
15
15
|
here: {
|
|
16
16
|
type: "boolean",
|
package/dist/dev-server.js
CHANGED
|
@@ -5,6 +5,7 @@ import { dirname, join } from "node:path";
|
|
|
5
5
|
import { parseDevServerArgs, printDevServerHelp, validateDevServerFlags, } from "./cli.js";
|
|
6
6
|
import { evictOldest, findOwnEntry, listDevServers, printActiveServers, pruneAndPersist, registerDevServer, removeDevServerEntryByWorktree, stopAllRegistered, unregisterDevServer, } from "./dev-servers-registry.js";
|
|
7
7
|
import { ConfigError, StartupError } from "./errors.js";
|
|
8
|
+
import { detectCommonJsError } from "./helpers.js";
|
|
8
9
|
import { awaitAllReady, handleStartupFailure } from "./log-polling.js";
|
|
9
10
|
import { isProcessAlive, stopProcessGroup } from "./process-control.js";
|
|
10
11
|
import { resolveCurrentSlot } from "./slots.js";
|
|
@@ -80,7 +81,7 @@ async function start(config, mainWorktree, { evict }) {
|
|
|
80
81
|
name: s.name,
|
|
81
82
|
logFile: logFileFor(config.runtimeDir, s.name),
|
|
82
83
|
detectSuccess: s.detectSuccess,
|
|
83
|
-
detectError: s.detectError,
|
|
84
|
+
detectError: s.detectError ?? detectCommonJsError,
|
|
84
85
|
}));
|
|
85
86
|
const pollPids = spawnEntries.map((s) => spawnPids[s.name]);
|
|
86
87
|
await awaitAllReady(pollables, pollPids);
|
package/dist/helpers.d.ts
CHANGED
|
@@ -15,4 +15,11 @@ export interface CopyAndPatchCtx {
|
|
|
15
15
|
mainWorktree: string;
|
|
16
16
|
log: (msg: string) => void;
|
|
17
17
|
}
|
|
18
|
-
export declare function copyAndPatchFile(ctx: CopyAndPatchCtx, relPath: string, patchFn: (content: string) => string, label: string, force: boolean,
|
|
18
|
+
export declare function copyAndPatchFile(ctx: CopyAndPatchCtx, relPath: string, patchFn: (content: string) => string, label: string, force: boolean, optional?: boolean): void;
|
|
19
|
+
/**
|
|
20
|
+
* Detects common fatal JS startup failures in a log buffer. Returns a short marker string
|
|
21
|
+
* naming the matched pattern, or `false` when none match. Used as the default `detectError`
|
|
22
|
+
* for spawn servers that don't supply one. A custom `detectError` can compose with this:
|
|
23
|
+
* `detectError: (log) => myDetector(log) || helpers.detectCommonJsError(log)`.
|
|
24
|
+
*/
|
|
25
|
+
export declare function detectCommonJsError(log: string): string | false;
|
package/dist/helpers.js
CHANGED
|
@@ -59,7 +59,7 @@ export function readPortFromJsonFile(file, jsonPath) {
|
|
|
59
59
|
}
|
|
60
60
|
return toPort(String(cur), file);
|
|
61
61
|
}
|
|
62
|
-
export function copyAndPatchFile(ctx, relPath, patchFn, label, force,
|
|
62
|
+
export function copyAndPatchFile(ctx, relPath, patchFn, label, force, optional = false) {
|
|
63
63
|
const targetPath = join(ctx.currentWorktree, relPath);
|
|
64
64
|
const sourcePath = join(ctx.mainWorktree, relPath);
|
|
65
65
|
const alreadyExists = existsSync(targetPath);
|
|
@@ -68,11 +68,12 @@ export function copyAndPatchFile(ctx, relPath, patchFn, label, force, required =
|
|
|
68
68
|
return;
|
|
69
69
|
}
|
|
70
70
|
if (!existsSync(sourcePath)) {
|
|
71
|
-
if (
|
|
72
|
-
console.error(`Error: ${relPath} not found in main worktree
|
|
71
|
+
if (!optional) {
|
|
72
|
+
console.error(`Error: ${relPath} not found in main worktree. Bootstrap the main worktree first ` +
|
|
73
|
+
"(setup-worktree --here), or mark the entry as optional.");
|
|
73
74
|
process.exit(1);
|
|
74
75
|
}
|
|
75
|
-
ctx.log(`Warning: ${relPath} not found in main worktree, skipping.`);
|
|
76
|
+
ctx.log(`Warning: ${relPath} not found in main worktree, skipping (optional).`);
|
|
76
77
|
return;
|
|
77
78
|
}
|
|
78
79
|
const content = readFileSync(sourcePath, "utf-8");
|
|
@@ -81,6 +82,25 @@ export function copyAndPatchFile(ctx, relPath, patchFn, label, force, required =
|
|
|
81
82
|
writeFileSync(targetPath, patched);
|
|
82
83
|
ctx.log(`${alreadyExists ? "Overwritten" : "Created"} ${label}.`);
|
|
83
84
|
}
|
|
85
|
+
/**
|
|
86
|
+
* Detects common fatal JS startup failures in a log buffer. Returns a short marker string
|
|
87
|
+
* naming the matched pattern, or `false` when none match. Used as the default `detectError`
|
|
88
|
+
* for spawn servers that don't supply one. A custom `detectError` can compose with this:
|
|
89
|
+
* `detectError: (log) => myDetector(log) || helpers.detectCommonJsError(log)`.
|
|
90
|
+
*/
|
|
91
|
+
export function detectCommonJsError(log) {
|
|
92
|
+
if (log.includes("[nodemon] app crashed"))
|
|
93
|
+
return "[nodemon] app crashed";
|
|
94
|
+
if (/^Node\.js v/m.test(log))
|
|
95
|
+
return "Node.js v";
|
|
96
|
+
if (log.includes("Error: Cannot find module "))
|
|
97
|
+
return "Error: Cannot find module";
|
|
98
|
+
if (/^SyntaxError: /m.test(log))
|
|
99
|
+
return "SyntaxError";
|
|
100
|
+
if (log.includes("UnhandledPromiseRejection"))
|
|
101
|
+
return "UnhandledPromiseRejection";
|
|
102
|
+
return false;
|
|
103
|
+
}
|
|
84
104
|
function toPort(raw, file) {
|
|
85
105
|
const port = Number(raw);
|
|
86
106
|
if (!Number.isFinite(port)) {
|
package/dist/index.d.ts
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
export { runSetupWorktree } from "./setup-worktree.js";
|
|
2
|
+
export { defaultWorktreeDirName } from "./worktree.js";
|
|
3
|
+
export type { WorktreeDirNameFn } from "./worktree.js";
|
|
2
4
|
export type { SetupWorktreeConfig, SetupContext, SummaryContext, PatchContext, ConfigFileEntry, PurgeContext, } from "./setup-worktree.js";
|
|
3
5
|
export { runDevServer } from "./dev-server.js";
|
|
4
6
|
export type { DevServerConfig, DevServerSummaryContext, ServerDescriptor, ServerContext, SpawnServer, CallbackServer, } from "./dev-server.js";
|
package/dist/index.js
CHANGED
|
@@ -22,7 +22,11 @@ export interface SpawnServer {
|
|
|
22
22
|
port: number;
|
|
23
23
|
/** Returns `true` once the log content indicates the server is ready. */
|
|
24
24
|
detectSuccess: (logContent: string) => boolean;
|
|
25
|
-
/**
|
|
25
|
+
/**
|
|
26
|
+
* Returns a non-empty marker string when the log content indicates a fatal error, or `false`.
|
|
27
|
+
* When omitted, `helpers.detectCommonJsError` is used as a default. To disable detection,
|
|
28
|
+
* pass `() => false`.
|
|
29
|
+
*/
|
|
26
30
|
detectError?: (logContent: string) => string | false;
|
|
27
31
|
}
|
|
28
32
|
/**
|
package/dist/setup-worktree.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { type WorktreeDirNameFn } from "./worktree.js";
|
|
1
2
|
/** Configuration accepted by {@link runSetupWorktree}. */
|
|
2
3
|
export interface SetupWorktreeConfig {
|
|
3
4
|
/**
|
|
@@ -6,6 +7,13 @@ export interface SetupWorktreeConfig {
|
|
|
6
7
|
* — typically `fileURLToPath(import.meta.url)` from your `setup-worktree.mjs`.
|
|
7
8
|
*/
|
|
8
9
|
scriptPath: string;
|
|
10
|
+
/**
|
|
11
|
+
* Absolute path to your dev-server script (the file that calls `runDevServer`). On `--remove`,
|
|
12
|
+
* the kernel shells out to `node <devServerScript> --stop` with `cwd: <target worktree>`.
|
|
13
|
+
* Typically `fileURLToPath(new URL('./dev-server.mjs', import.meta.url))` from your
|
|
14
|
+
* `setup-worktree.mjs`.
|
|
15
|
+
*/
|
|
16
|
+
devServerScript: string;
|
|
9
17
|
/** Anchor port for the slot range. Slots are derived from this value. */
|
|
10
18
|
basePort: number;
|
|
11
19
|
/** Distance between consecutive slots. Defaults to `10`. */
|
|
@@ -31,6 +39,13 @@ export interface SetupWorktreeConfig {
|
|
|
31
39
|
registryDir: string;
|
|
32
40
|
/** Config files copied from the main worktree and patched per slot. */
|
|
33
41
|
configFiles: ConfigFileEntry[];
|
|
42
|
+
/**
|
|
43
|
+
* Runs before `configFiles` are copied. Use this to bootstrap source files the kernel expects
|
|
44
|
+
* to find (e.g. seed `config.json` from `config.example.json` on the main worktree, decrypt
|
|
45
|
+
* an env file). MUST be idempotent. On a linked-worktree setup, MUST NOT mutate the main
|
|
46
|
+
* worktree — bootstrap the main worktree first via `setup-worktree --here`.
|
|
47
|
+
*/
|
|
48
|
+
preSetup?: (ctx: PreSetupContext) => Promise<void> | void;
|
|
34
49
|
/**
|
|
35
50
|
* MUST be idempotent. After a failure, the user re-runs `setup-worktree --here` from inside
|
|
36
51
|
* the worktree — this callback will be invoked again with the same context. Re-runs must not
|
|
@@ -38,13 +53,6 @@ export interface SetupWorktreeConfig {
|
|
|
38
53
|
* installed deps, etc.).
|
|
39
54
|
*/
|
|
40
55
|
finalizeWorktree: (ctx: SetupContext) => Promise<void> | void;
|
|
41
|
-
/**
|
|
42
|
-
* Absolute path to your dev-server script (the file that calls `runDevServer`). On `--remove`,
|
|
43
|
-
* the kernel shells out to `node <devServerScript> --stop` with `cwd: <target worktree>`.
|
|
44
|
-
* Typically `fileURLToPath(new URL('./dev-server.mjs', import.meta.url))` from your
|
|
45
|
-
* `setup-worktree.mjs`.
|
|
46
|
-
*/
|
|
47
|
-
devServerScript: string;
|
|
48
56
|
/**
|
|
49
57
|
* Destructive infrastructure teardown on `--remove` (e.g. `docker compose down -v` to wipe
|
|
50
58
|
* volumes). Runs after the dev-server stop. Best-effort; errors should be swallowed.
|
|
@@ -52,6 +60,24 @@ export interface SetupWorktreeConfig {
|
|
|
52
60
|
purgeInfrastructure?: (ctx: PurgeContext) => Promise<void> | void;
|
|
53
61
|
/** Builds the post-setup summary printed to stdout. */
|
|
54
62
|
printSummary: (ctx: SummaryContext) => string;
|
|
63
|
+
/**
|
|
64
|
+
* Optional override for the worktree directory basename. Receives `{ branch, repoName }` and
|
|
65
|
+
* returns the basename (e.g. `myrepo-feat-ABC-123`). Defaults to {@link defaultWorktreeDirName},
|
|
66
|
+
* which strips a recognizable ticket suffix and caps the slug at 22 chars. The kernel handles
|
|
67
|
+
* deduplication (`-2`, `-3`…) when the resulting directory already exists.
|
|
68
|
+
*/
|
|
69
|
+
worktreeDirName?: WorktreeDirNameFn;
|
|
70
|
+
}
|
|
71
|
+
/** Context passed to {@link SetupWorktreeConfig.preSetup}. */
|
|
72
|
+
export interface PreSetupContext {
|
|
73
|
+
currentWorktree: string;
|
|
74
|
+
mainWorktree: string;
|
|
75
|
+
/** `true` when running on the main worktree (i.e. `--here` from the main checkout). */
|
|
76
|
+
isMainWorktree: boolean;
|
|
77
|
+
/** Mirrors `--force`. Hooks may use it to overwrite previously bootstrapped files. */
|
|
78
|
+
force: boolean;
|
|
79
|
+
/** Writes to stdout and the setup log. */
|
|
80
|
+
log: (msg: string) => void;
|
|
55
81
|
}
|
|
56
82
|
/** Context passed to {@link SetupWorktreeConfig.finalizeWorktree}. */
|
|
57
83
|
export interface SetupContext {
|
|
@@ -64,7 +90,11 @@ export interface SetupContext {
|
|
|
64
90
|
force: boolean;
|
|
65
91
|
verbose: boolean;
|
|
66
92
|
}
|
|
67
|
-
/**
|
|
93
|
+
/**
|
|
94
|
+
* Context passed to {@link SetupWorktreeConfig.printSummary}.
|
|
95
|
+
*
|
|
96
|
+
* Called after worktree creation; the dev-server is not running yet.
|
|
97
|
+
*/
|
|
68
98
|
export interface SummaryContext {
|
|
69
99
|
slot: number;
|
|
70
100
|
branch: string;
|
|
@@ -85,8 +115,12 @@ export interface ConfigFileEntry {
|
|
|
85
115
|
path: string;
|
|
86
116
|
/** Returns the patched content given the source content and the slot's ports. */
|
|
87
117
|
patch: (content: string, ctx: PatchContext) => string;
|
|
88
|
-
/**
|
|
89
|
-
|
|
118
|
+
/**
|
|
119
|
+
* When `true`, a missing source on the main worktree logs a warning and skips the entry.
|
|
120
|
+
* Default: required (missing source aborts setup). Bootstrap the main worktree first via
|
|
121
|
+
* `setup-worktree --here`, or seed sources in `preSetup`.
|
|
122
|
+
*/
|
|
123
|
+
optional?: boolean;
|
|
90
124
|
}
|
|
91
125
|
/** Context passed to {@link ConfigFileEntry.patch}. */
|
|
92
126
|
export interface PatchContext {
|
package/dist/setup-worktree.js
CHANGED
|
@@ -78,7 +78,7 @@ async function runSetup(args, ctx, run, config) {
|
|
|
78
78
|
registryDir: config.registryDir,
|
|
79
79
|
scheme,
|
|
80
80
|
});
|
|
81
|
-
const setupCtx = ensureWorktree(args, ctx, run);
|
|
81
|
+
const setupCtx = ensureWorktree(args, ctx, run, config.worktreeDirName);
|
|
82
82
|
const branch = getCurrentBranch(setupCtx.currentWorktree);
|
|
83
83
|
const { port: slot, owner } = resolveAndRegisterSlot({
|
|
84
84
|
slot: args.slot,
|
|
@@ -111,6 +111,15 @@ async function runSetup(args, ctx, run, config) {
|
|
|
111
111
|
verboseLog(`Using slot ${slot} (${Object.entries(ports)
|
|
112
112
|
.map(([k, v]) => `${k}: ${v}`)
|
|
113
113
|
.join(", ")})`);
|
|
114
|
+
if (config.preSetup) {
|
|
115
|
+
await config.preSetup({
|
|
116
|
+
currentWorktree: setupCtx.currentWorktree,
|
|
117
|
+
mainWorktree: setupCtx.mainWorktree,
|
|
118
|
+
isMainWorktree: setupCtx.isMainWorktree,
|
|
119
|
+
force: args.force ?? false,
|
|
120
|
+
log: teeLog,
|
|
121
|
+
});
|
|
122
|
+
}
|
|
114
123
|
linkSharedDirectories(setupCtx, config.sharedDirs, verboseLog);
|
|
115
124
|
generateConfigFiles(setupCtx, config.configFiles, slot, ports, args.force ?? false, verboseLog);
|
|
116
125
|
teeLog(config.printSummary({
|
|
@@ -123,6 +132,7 @@ async function runSetup(args, ctx, run, config) {
|
|
|
123
132
|
}));
|
|
124
133
|
teeLog(`WORKTREE_CREATED path=${setupCtx.currentWorktree} branch=${branch} slot=${slot}`);
|
|
125
134
|
teeLog(`Setup continuing in background. Tail: ${logPath}`);
|
|
135
|
+
teeLog(`Block until ready: setup-worktree --wait --slot ${slot}`);
|
|
126
136
|
const child = spawn(process.execPath, [config.scriptPath, "--__finalize", String(slot)], {
|
|
127
137
|
detached: true,
|
|
128
138
|
stdio: ["ignore", logFd, logFd],
|
|
@@ -345,11 +355,11 @@ function handleSetOwnerMode(args, ctx, config) {
|
|
|
345
355
|
}
|
|
346
356
|
console.log(`Owner for slot ${slotPort}: ${newOwner ?? "(none)"}`);
|
|
347
357
|
}
|
|
348
|
-
function ensureWorktree(args, ctx, run) {
|
|
358
|
+
function ensureWorktree(args, ctx, run, dirNameFn) {
|
|
349
359
|
if (args.use)
|
|
350
|
-
return useExistingBranch(args.use, ctx, run);
|
|
360
|
+
return useExistingBranch(args.use, ctx, run, dirNameFn);
|
|
351
361
|
if (args.create)
|
|
352
|
-
return createBranch(args.create, ctx, run);
|
|
362
|
+
return createBranch(args.create, ctx, run, dirNameFn);
|
|
353
363
|
return ctx;
|
|
354
364
|
}
|
|
355
365
|
function linkSharedDirectories(ctx, dirs, log) {
|
|
@@ -376,7 +386,7 @@ function generateConfigFiles(ctx, entries, slot, ports, force, log) {
|
|
|
376
386
|
ports,
|
|
377
387
|
mainWorktree: ctx.mainWorktree,
|
|
378
388
|
currentWorktree: ctx.currentWorktree,
|
|
379
|
-
}), entry.path, force, entry.
|
|
389
|
+
}), entry.path, force, entry.optional ?? false);
|
|
380
390
|
}
|
|
381
391
|
}
|
|
382
392
|
function resolveRemoveTarget(args, ctx, registry, removeHere) {
|
package/dist/worktree.d.ts
CHANGED
|
@@ -12,9 +12,20 @@ export declare function enforceWorktreeMode(args: {
|
|
|
12
12
|
create?: string;
|
|
13
13
|
here?: boolean;
|
|
14
14
|
}, ctx: WorktreeContext): void;
|
|
15
|
-
export declare function useExistingBranch(branch: string, ctx: WorktreeContext, run: RunCtx): WorktreeContext;
|
|
16
|
-
export declare function createBranch(requestedBranch: string, ctx: WorktreeContext, run: RunCtx): WorktreeContext;
|
|
15
|
+
export declare function useExistingBranch(branch: string, ctx: WorktreeContext, run: RunCtx, dirNameFn?: WorktreeDirNameFn): WorktreeContext;
|
|
16
|
+
export declare function createBranch(requestedBranch: string, ctx: WorktreeContext, run: RunCtx, dirNameFn?: WorktreeDirNameFn): WorktreeContext;
|
|
17
17
|
export declare function verifyBranchAbsentFromRemote(branch: string, run: RunCtx): void;
|
|
18
18
|
export declare function getCurrentBranch(worktreePath: string): string;
|
|
19
19
|
export declare function removeWorktree(worktreePath: string, run: RunCtx): void;
|
|
20
|
-
|
|
20
|
+
/** Pure function that produces the basename of a worktree directory from a branch. */
|
|
21
|
+
export type WorktreeDirNameFn = (opts: {
|
|
22
|
+
branch: string;
|
|
23
|
+
repoName: string;
|
|
24
|
+
}) => string;
|
|
25
|
+
/**
|
|
26
|
+
* Default {@link WorktreeDirNameFn}. Strips a recognizable ticket suffix from the last branch
|
|
27
|
+
* segment (`feat/ABC-123-extra` → `feat-ABC-123`), caps the result at 22 chars, and strips
|
|
28
|
+
* trailing dashes. Falls back to the full sanitized branch when no ticket pattern is found.
|
|
29
|
+
*/
|
|
30
|
+
export declare const defaultWorktreeDirName: WorktreeDirNameFn;
|
|
31
|
+
export declare function computeWorktreePath(mainWorktree: string, branch: string, dirNameFn?: WorktreeDirNameFn): string;
|
package/dist/worktree.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { execFileSync } from "node:child_process";
|
|
2
|
+
import { existsSync } from "node:fs";
|
|
2
3
|
import { basename, dirname, join, resolve } from "node:path";
|
|
3
4
|
export function detectWorktree() {
|
|
4
5
|
const currentWorktree = execFileSync("git", ["rev-parse", "--show-toplevel"], {
|
|
@@ -23,16 +24,16 @@ export function enforceWorktreeMode(args, ctx) {
|
|
|
23
24
|
}
|
|
24
25
|
}
|
|
25
26
|
}
|
|
26
|
-
export function useExistingBranch(branch, ctx, run) {
|
|
27
|
+
export function useExistingBranch(branch, ctx, run, dirNameFn = defaultWorktreeDirName) {
|
|
27
28
|
if (!branchExists(branch)) {
|
|
28
29
|
console.error(`Error: Branch "${branch}" does not exist locally or on the remote.`);
|
|
29
30
|
process.exit(1);
|
|
30
31
|
}
|
|
31
|
-
const worktreePath = computeWorktreePath(ctx.mainWorktree, branch);
|
|
32
|
+
const worktreePath = dedupeWorktreePath(computeWorktreePath(ctx.mainWorktree, branch, dirNameFn));
|
|
32
33
|
execFileSync("git", ["worktree", "add", worktreePath, branch], { stdio: stdioFor(run) });
|
|
33
34
|
return { ...ctx, currentWorktree: worktreePath, isMainWorktree: false };
|
|
34
35
|
}
|
|
35
|
-
export function createBranch(requestedBranch, ctx, run) {
|
|
36
|
+
export function createBranch(requestedBranch, ctx, run, dirNameFn = defaultWorktreeDirName) {
|
|
36
37
|
let finalBranch = requestedBranch;
|
|
37
38
|
if (branchExists(finalBranch)) {
|
|
38
39
|
let suffix = 2;
|
|
@@ -42,7 +43,7 @@ export function createBranch(requestedBranch, ctx, run) {
|
|
|
42
43
|
finalBranch = `${requestedBranch}-${suffix}`;
|
|
43
44
|
console.warn(`Warning: Branch "${requestedBranch}" already exists; using "${finalBranch}" instead.`);
|
|
44
45
|
}
|
|
45
|
-
const worktreePath = computeWorktreePath(ctx.mainWorktree, finalBranch);
|
|
46
|
+
const worktreePath = dedupeWorktreePath(computeWorktreePath(ctx.mainWorktree, finalBranch, dirNameFn));
|
|
46
47
|
execFileSync("git", ["worktree", "add", "-b", finalBranch, worktreePath], {
|
|
47
48
|
stdio: stdioFor(run),
|
|
48
49
|
});
|
|
@@ -67,10 +68,39 @@ export function getCurrentBranch(worktreePath) {
|
|
|
67
68
|
export function removeWorktree(worktreePath, run) {
|
|
68
69
|
execFileSync("git", ["worktree", "remove", "--force", worktreePath], { stdio: stdioFor(run) });
|
|
69
70
|
}
|
|
70
|
-
|
|
71
|
+
/**
|
|
72
|
+
* Default {@link WorktreeDirNameFn}. Strips a recognizable ticket suffix from the last branch
|
|
73
|
+
* segment (`feat/ABC-123-extra` → `feat-ABC-123`), caps the result at 22 chars, and strips
|
|
74
|
+
* trailing dashes. Falls back to the full sanitized branch when no ticket pattern is found.
|
|
75
|
+
*/
|
|
76
|
+
export const defaultWorktreeDirName = ({ branch, repoName }) => {
|
|
77
|
+
return `${repoName}-${shortenBranchSegment(branch)}`;
|
|
78
|
+
};
|
|
79
|
+
function shortenBranchSegment(branch) {
|
|
80
|
+
const parts = branch.split("/");
|
|
81
|
+
const last = parts[parts.length - 1] ?? "";
|
|
82
|
+
const match = last.match(/^([A-Za-z]+-\d+|\d+)/);
|
|
83
|
+
if (match) {
|
|
84
|
+
parts[parts.length - 1] = match[1];
|
|
85
|
+
}
|
|
86
|
+
let result = parts.join("-");
|
|
87
|
+
if (result.length > 22) {
|
|
88
|
+
result = result.slice(0, 22);
|
|
89
|
+
}
|
|
90
|
+
return result.replace(/-+$/, "");
|
|
91
|
+
}
|
|
92
|
+
export function computeWorktreePath(mainWorktree, branch, dirNameFn = defaultWorktreeDirName) {
|
|
71
93
|
const repoName = basename(mainWorktree);
|
|
72
|
-
|
|
73
|
-
|
|
94
|
+
return join(dirname(mainWorktree), dirNameFn({ branch, repoName }));
|
|
95
|
+
}
|
|
96
|
+
function dedupeWorktreePath(candidate) {
|
|
97
|
+
if (!existsSync(candidate))
|
|
98
|
+
return candidate;
|
|
99
|
+
let suffix = 2;
|
|
100
|
+
while (existsSync(`${candidate}-${suffix}`)) {
|
|
101
|
+
++suffix;
|
|
102
|
+
}
|
|
103
|
+
return `${candidate}-${suffix}`;
|
|
74
104
|
}
|
|
75
105
|
function branchExists(branch) {
|
|
76
106
|
try {
|