@alecrust/workbox 0.4.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/CHANGELOG.md +1 -0
- package/LICENSE +21 -0
- package/README.md +105 -0
- package/package.json +59 -0
- package/src/bootstrap/runner.ts +89 -0
- package/src/bootstrap/steps/index.ts +6 -0
- package/src/cli/args.ts +144 -0
- package/src/cli/help.ts +42 -0
- package/src/cli.ts +145 -0
- package/src/commands/dev.ts +90 -0
- package/src/commands/exec.ts +67 -0
- package/src/commands/index.ts +23 -0
- package/src/commands/list.ts +42 -0
- package/src/commands/new.ts +73 -0
- package/src/commands/parse.ts +14 -0
- package/src/commands/prune.ts +30 -0
- package/src/commands/rm.ts +64 -0
- package/src/commands/setup.ts +41 -0
- package/src/commands/status.ts +42 -0
- package/src/commands/types.ts +30 -0
- package/src/core/config.ts +317 -0
- package/src/core/git.ts +352 -0
- package/src/core/path.ts +103 -0
- package/src/core/paths.ts +23 -0
- package/src/core/process.ts +52 -0
- package/src/core/repo.ts +44 -0
- package/src/provision/runner.ts +204 -0
- package/src/ui/errors.ts +23 -0
- package/src/ui/log.ts +32 -0
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
|
|
3
|
+
import { ConfigError } from "../ui/errors";
|
|
4
|
+
import { checkPathWithinRoot } from "./path";
|
|
5
|
+
import {
|
|
6
|
+
CONFIG_PRIMARY,
|
|
7
|
+
CONFIG_SECONDARY,
|
|
8
|
+
GLOBAL_CONFIG_FALLBACK,
|
|
9
|
+
GLOBAL_CONFIG_XDG,
|
|
10
|
+
getGlobalConfigPath,
|
|
11
|
+
getProjectConfigCandidatePaths,
|
|
12
|
+
resolveWorktreesDir,
|
|
13
|
+
} from "./paths";
|
|
14
|
+
|
|
15
|
+
const BootstrapStepSchema = z
|
|
16
|
+
.object({
|
|
17
|
+
name: z.string().min(1, "Step name is required."),
|
|
18
|
+
run: z.string().min(1, "Step command is required."),
|
|
19
|
+
cwd: z.string().min(1).optional(),
|
|
20
|
+
env: z.record(z.string(), z.string()).optional(),
|
|
21
|
+
})
|
|
22
|
+
.strict();
|
|
23
|
+
|
|
24
|
+
const BootstrapObjectSchema = z
|
|
25
|
+
.object({
|
|
26
|
+
enabled: z.boolean(),
|
|
27
|
+
steps: z.array(BootstrapStepSchema),
|
|
28
|
+
})
|
|
29
|
+
.strict();
|
|
30
|
+
|
|
31
|
+
const ProvisionCopySchema = z
|
|
32
|
+
.object({
|
|
33
|
+
from: z.string().min(1, "Provision copy source is required."),
|
|
34
|
+
to: z.string().min(1, "Provision copy destination is required."),
|
|
35
|
+
required: z.boolean().default(false),
|
|
36
|
+
})
|
|
37
|
+
.strict();
|
|
38
|
+
|
|
39
|
+
const PartialProvisionCopySchema = ProvisionCopySchema.omit({ required: true })
|
|
40
|
+
.extend({
|
|
41
|
+
required: z.boolean().optional(),
|
|
42
|
+
})
|
|
43
|
+
.strict();
|
|
44
|
+
|
|
45
|
+
const ProvisionObjectSchema = z
|
|
46
|
+
.object({
|
|
47
|
+
enabled: z.boolean(),
|
|
48
|
+
copy: z.array(ProvisionCopySchema).default([]),
|
|
49
|
+
steps: z.array(BootstrapStepSchema).default([]),
|
|
50
|
+
})
|
|
51
|
+
.strict();
|
|
52
|
+
|
|
53
|
+
const validateBootstrapSteps = (
|
|
54
|
+
steps: Array<z.infer<typeof BootstrapStepSchema>>,
|
|
55
|
+
ctx: z.RefinementCtx
|
|
56
|
+
) => {
|
|
57
|
+
const seen = new Set<string>();
|
|
58
|
+
steps.forEach((step, index) => {
|
|
59
|
+
if (seen.has(step.name)) {
|
|
60
|
+
ctx.addIssue({
|
|
61
|
+
code: z.ZodIssueCode.custom,
|
|
62
|
+
path: ["steps", index, "name"],
|
|
63
|
+
message: `Duplicate bootstrap step name "${step.name}".`,
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
seen.add(step.name);
|
|
67
|
+
});
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
const BootstrapSchema = BootstrapObjectSchema.superRefine((value, ctx) => {
|
|
71
|
+
validateBootstrapSteps(value.steps, ctx);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
const validateProvisionSteps = (
|
|
75
|
+
steps: Array<z.infer<typeof BootstrapStepSchema>>,
|
|
76
|
+
ctx: z.RefinementCtx
|
|
77
|
+
) => {
|
|
78
|
+
const seen = new Set<string>();
|
|
79
|
+
steps.forEach((step, index) => {
|
|
80
|
+
if (seen.has(step.name)) {
|
|
81
|
+
ctx.addIssue({
|
|
82
|
+
code: z.ZodIssueCode.custom,
|
|
83
|
+
path: ["steps", index, "name"],
|
|
84
|
+
message: `Duplicate provision step name "${step.name}".`,
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
seen.add(step.name);
|
|
88
|
+
});
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
const ProvisionSchema = ProvisionObjectSchema.superRefine((value, ctx) => {
|
|
92
|
+
validateProvisionSteps(value.steps, ctx);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
const WorktreesSchema = z
|
|
96
|
+
.object({
|
|
97
|
+
directory: z.string().min(1, "Worktree directory is required."),
|
|
98
|
+
branch_prefix: z.string().min(1, "Worktree branch prefix is required."),
|
|
99
|
+
base_ref: z.string().min(1, "Worktree base ref must be non-empty.").optional(),
|
|
100
|
+
})
|
|
101
|
+
.strict();
|
|
102
|
+
|
|
103
|
+
const WorkboxConfigSchema = z
|
|
104
|
+
.object({
|
|
105
|
+
worktrees: WorktreesSchema,
|
|
106
|
+
bootstrap: BootstrapSchema,
|
|
107
|
+
provision: ProvisionSchema.default({ enabled: false, copy: [], steps: [] }),
|
|
108
|
+
dev: z
|
|
109
|
+
.object({
|
|
110
|
+
command: z.string().min(1, "Dev command is required."),
|
|
111
|
+
open: z.string().min(1, "Dev open command must be non-empty.").optional(),
|
|
112
|
+
})
|
|
113
|
+
.strict()
|
|
114
|
+
.optional(),
|
|
115
|
+
})
|
|
116
|
+
.strict();
|
|
117
|
+
|
|
118
|
+
type WorkboxConfig = z.infer<typeof WorkboxConfigSchema>;
|
|
119
|
+
|
|
120
|
+
const PartialWorkboxConfigSchema = z
|
|
121
|
+
.object({
|
|
122
|
+
worktrees: WorktreesSchema.partial().optional(),
|
|
123
|
+
bootstrap: BootstrapObjectSchema.partial()
|
|
124
|
+
.superRefine((value, ctx) => {
|
|
125
|
+
if (value.steps) {
|
|
126
|
+
validateBootstrapSteps(value.steps, ctx);
|
|
127
|
+
}
|
|
128
|
+
})
|
|
129
|
+
.optional(),
|
|
130
|
+
provision: z
|
|
131
|
+
.object({
|
|
132
|
+
enabled: z.boolean().optional(),
|
|
133
|
+
copy: z.array(PartialProvisionCopySchema).optional(),
|
|
134
|
+
steps: z.array(BootstrapStepSchema).optional(),
|
|
135
|
+
})
|
|
136
|
+
.strict()
|
|
137
|
+
.superRefine((value, ctx) => {
|
|
138
|
+
if (value.steps) {
|
|
139
|
+
validateProvisionSteps(value.steps, ctx);
|
|
140
|
+
}
|
|
141
|
+
})
|
|
142
|
+
.optional(),
|
|
143
|
+
dev: z
|
|
144
|
+
.object({
|
|
145
|
+
command: z.string().min(1, "Dev command is required.").optional(),
|
|
146
|
+
open: z.string().min(1, "Dev open command must be non-empty.").optional(),
|
|
147
|
+
})
|
|
148
|
+
.strict()
|
|
149
|
+
.optional(),
|
|
150
|
+
})
|
|
151
|
+
.strict();
|
|
152
|
+
|
|
153
|
+
type PartialWorkboxConfig = z.infer<typeof PartialWorkboxConfigSchema>;
|
|
154
|
+
|
|
155
|
+
export type ResolvedWorkboxConfig = WorkboxConfig & {
|
|
156
|
+
worktrees: WorkboxConfig["worktrees"] & { directory: string };
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
type LoadedConfig = {
|
|
160
|
+
config: ResolvedWorkboxConfig;
|
|
161
|
+
path: string;
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
type LoadConfigOptions = {
|
|
165
|
+
env?: NodeJS.ProcessEnv;
|
|
166
|
+
homeDir?: string;
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
const formatZodError = (error: z.ZodError, filePath: string): string => {
|
|
170
|
+
const issues = error.issues.map((issue) => {
|
|
171
|
+
const path = issue.path.length > 0 ? issue.path.join(".") : "(root)";
|
|
172
|
+
return `${path}: ${issue.message}`;
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
return `Invalid workbox config in ${filePath}:\n${issues.map((item) => `- ${item}`).join("\n")}`;
|
|
176
|
+
};
|
|
177
|
+
|
|
178
|
+
const parseConfig = (source: string, filePath: string): PartialWorkboxConfig => {
|
|
179
|
+
let parsed: unknown;
|
|
180
|
+
try {
|
|
181
|
+
parsed = Bun.TOML.parse(source);
|
|
182
|
+
} catch (error) {
|
|
183
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
184
|
+
throw new ConfigError(`Invalid TOML in ${filePath}: ${message}`, { cause: error });
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const result = PartialWorkboxConfigSchema.safeParse(parsed);
|
|
188
|
+
if (!result.success) {
|
|
189
|
+
throw new ConfigError(formatZodError(result.error, filePath));
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
return result.data;
|
|
193
|
+
};
|
|
194
|
+
|
|
195
|
+
const mergeConfig = (configs: PartialWorkboxConfig[]): PartialWorkboxConfig =>
|
|
196
|
+
configs.reduce<PartialWorkboxConfig>((merged, config) => {
|
|
197
|
+
if (config.worktrees) {
|
|
198
|
+
merged.worktrees = {
|
|
199
|
+
...merged.worktrees,
|
|
200
|
+
...config.worktrees,
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
if (config.bootstrap) {
|
|
205
|
+
merged.bootstrap = {
|
|
206
|
+
...merged.bootstrap,
|
|
207
|
+
...config.bootstrap,
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
if (config.provision) {
|
|
212
|
+
merged.provision = {
|
|
213
|
+
...merged.provision,
|
|
214
|
+
...config.provision,
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
if (config.dev) {
|
|
219
|
+
merged.dev = {
|
|
220
|
+
...merged.dev,
|
|
221
|
+
...config.dev,
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
return merged;
|
|
226
|
+
}, {});
|
|
227
|
+
|
|
228
|
+
const validateMergedConfig = (
|
|
229
|
+
config: PartialWorkboxConfig,
|
|
230
|
+
sourceDescription: string
|
|
231
|
+
): WorkboxConfig => {
|
|
232
|
+
const result = WorkboxConfigSchema.safeParse(config);
|
|
233
|
+
if (!result.success) {
|
|
234
|
+
throw new ConfigError(formatZodError(result.error, sourceDescription));
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
return result.data;
|
|
238
|
+
};
|
|
239
|
+
|
|
240
|
+
const resolveConfig = async (
|
|
241
|
+
config: WorkboxConfig,
|
|
242
|
+
repoRoot: string
|
|
243
|
+
): Promise<ResolvedWorkboxConfig> => {
|
|
244
|
+
const resolved = resolveWorktreesDir(config.worktrees.directory, repoRoot);
|
|
245
|
+
const within = await checkPathWithinRoot({
|
|
246
|
+
rootDir: repoRoot,
|
|
247
|
+
candidatePath: resolved,
|
|
248
|
+
label: "worktrees.directory",
|
|
249
|
+
});
|
|
250
|
+
if (!within.ok) {
|
|
251
|
+
throw new ConfigError(within.reason);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
return {
|
|
255
|
+
...config,
|
|
256
|
+
worktrees: {
|
|
257
|
+
...config.worktrees,
|
|
258
|
+
directory: resolved,
|
|
259
|
+
},
|
|
260
|
+
};
|
|
261
|
+
};
|
|
262
|
+
|
|
263
|
+
const describeConfigSources = (paths: string[]): string =>
|
|
264
|
+
paths.length === 1 ? (paths[0] ?? "workbox config") : `merged config from ${paths.join(" and ")}`;
|
|
265
|
+
|
|
266
|
+
const readConfigIfExists = async (
|
|
267
|
+
configPath: string
|
|
268
|
+
): Promise<{ config: PartialWorkboxConfig; path: string } | undefined> => {
|
|
269
|
+
const file = Bun.file(configPath);
|
|
270
|
+
if (!(await file.exists())) {
|
|
271
|
+
return undefined;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
const contents = await file.text();
|
|
275
|
+
return {
|
|
276
|
+
config: parseConfig(contents, configPath),
|
|
277
|
+
path: configPath,
|
|
278
|
+
};
|
|
279
|
+
};
|
|
280
|
+
|
|
281
|
+
export const loadConfig = async (
|
|
282
|
+
repoRoot: string,
|
|
283
|
+
options: LoadConfigOptions = {}
|
|
284
|
+
): Promise<LoadedConfig> => {
|
|
285
|
+
const globalPath = getGlobalConfigPath(options.env, options.homeDir);
|
|
286
|
+
const projectCandidates = getProjectConfigCandidatePaths(repoRoot);
|
|
287
|
+
const loadedConfigs: Array<{ config: PartialWorkboxConfig; path: string }> = [];
|
|
288
|
+
|
|
289
|
+
const globalConfig = await readConfigIfExists(globalPath);
|
|
290
|
+
if (globalConfig) {
|
|
291
|
+
loadedConfigs.push(globalConfig);
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
for (const configPath of projectCandidates) {
|
|
295
|
+
const projectConfig = await readConfigIfExists(configPath);
|
|
296
|
+
if (projectConfig) {
|
|
297
|
+
loadedConfigs.push(projectConfig);
|
|
298
|
+
break;
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
if (loadedConfigs.length > 0) {
|
|
303
|
+
const paths = loadedConfigs.map((item) => item.path);
|
|
304
|
+
const merged = mergeConfig(loadedConfigs.map((item) => item.config));
|
|
305
|
+
const config = validateMergedConfig(merged, describeConfigSources(paths));
|
|
306
|
+
return {
|
|
307
|
+
config: await resolveConfig(config, repoRoot),
|
|
308
|
+
path: paths.at(-1) ?? globalPath,
|
|
309
|
+
};
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
throw new ConfigError(
|
|
313
|
+
`No workbox config found. Expected ${GLOBAL_CONFIG_XDG} under $XDG_CONFIG_HOME, ` +
|
|
314
|
+
`${GLOBAL_CONFIG_FALLBACK} under your home directory, or ${CONFIG_PRIMARY} or ` +
|
|
315
|
+
`${CONFIG_SECONDARY} in ${repoRoot}.`
|
|
316
|
+
);
|
|
317
|
+
};
|
package/src/core/git.ts
ADDED
|
@@ -0,0 +1,352 @@
|
|
|
1
|
+
import { access, mkdir, realpath } from "node:fs/promises";
|
|
2
|
+
import { join, relative, resolve, sep } from "node:path";
|
|
3
|
+
|
|
4
|
+
import { CliError } from "../ui/errors";
|
|
5
|
+
import { checkPathWithinRoot, isSubpath } from "./path";
|
|
6
|
+
import { runCommand } from "./process";
|
|
7
|
+
|
|
8
|
+
type WorktreeInfo = {
|
|
9
|
+
name: string;
|
|
10
|
+
path: string;
|
|
11
|
+
branch: string | null;
|
|
12
|
+
managedBranch: string;
|
|
13
|
+
managed: boolean;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
type WorktreeStatus = WorktreeInfo & {
|
|
17
|
+
clean: boolean;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
type PorcelainWorktree = {
|
|
21
|
+
path: string;
|
|
22
|
+
branch: string | null;
|
|
23
|
+
detached: boolean;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
type RunResult = {
|
|
27
|
+
exitCode: number;
|
|
28
|
+
stdout: string;
|
|
29
|
+
stderr: string;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
const runCmd = async (cmd: string[], cwd: string): Promise<RunResult> => {
|
|
33
|
+
return runCommand({ cmd, cwd, mode: "capture" });
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
const runGit = async (args: string[], cwd: string): Promise<string> => {
|
|
37
|
+
const result = await runCmd(["git", ...args], cwd);
|
|
38
|
+
if (result.exitCode !== 0) {
|
|
39
|
+
const message = result.stderr || result.stdout || "Unknown git error.";
|
|
40
|
+
throw new CliError(`Git command failed (git ${args.join(" ")}): ${message}`);
|
|
41
|
+
}
|
|
42
|
+
return result.stdout.trim();
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
const gitSucceeds = async (args: string[], cwd: string): Promise<boolean> => {
|
|
46
|
+
const result = await runCmd(["git", ...args], cwd);
|
|
47
|
+
return result.exitCode === 0;
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
const assertValidBranchName = async (branch: string, repoRoot: string): Promise<void> => {
|
|
51
|
+
const valid = await gitSucceeds(["check-ref-format", "--branch", branch], repoRoot);
|
|
52
|
+
if (!valid) {
|
|
53
|
+
throw new CliError(
|
|
54
|
+
`Invalid branch name "${branch}". Check worktrees.branch_prefix and the sandbox name.`
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
const normalizePath = async (path: string): Promise<string> => {
|
|
60
|
+
try {
|
|
61
|
+
return await realpath(path);
|
|
62
|
+
} catch {
|
|
63
|
+
return resolve(path);
|
|
64
|
+
}
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
const pathExists = async (path: string): Promise<boolean> => {
|
|
68
|
+
try {
|
|
69
|
+
await access(path);
|
|
70
|
+
return true;
|
|
71
|
+
} catch {
|
|
72
|
+
return false;
|
|
73
|
+
}
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
const assertWorktreesDirSafe = async (repoRoot: string, worktreesDir: string): Promise<void> => {
|
|
77
|
+
const within = await checkPathWithinRoot({
|
|
78
|
+
rootDir: repoRoot,
|
|
79
|
+
candidatePath: worktreesDir,
|
|
80
|
+
label: "worktrees.directory",
|
|
81
|
+
});
|
|
82
|
+
if (!within.ok) {
|
|
83
|
+
throw new CliError(within.reason);
|
|
84
|
+
}
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
const parseWorktreeListPorcelain = (output: string): PorcelainWorktree[] => {
|
|
88
|
+
const lines = output.split("\n");
|
|
89
|
+
const items: PorcelainWorktree[] = [];
|
|
90
|
+
let current: PorcelainWorktree | null = null;
|
|
91
|
+
|
|
92
|
+
for (const line of lines) {
|
|
93
|
+
if (line.startsWith("worktree ")) {
|
|
94
|
+
if (current) {
|
|
95
|
+
items.push(current);
|
|
96
|
+
}
|
|
97
|
+
current = {
|
|
98
|
+
path: line.slice("worktree ".length).trim(),
|
|
99
|
+
branch: null,
|
|
100
|
+
detached: false,
|
|
101
|
+
};
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if (!current) {
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
if (line === "detached") {
|
|
110
|
+
current.detached = true;
|
|
111
|
+
current.branch = null;
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
if (line.startsWith("branch ")) {
|
|
116
|
+
const ref = line.slice("branch ".length).trim();
|
|
117
|
+
current.branch = ref.startsWith("refs/heads/") ? ref.slice("refs/heads/".length) : ref;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
if (current) {
|
|
122
|
+
items.push(current);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
return items;
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
const validateWorktreeName = (name: string): void => {
|
|
129
|
+
if (!name || name.trim().length === 0) {
|
|
130
|
+
throw new CliError("Worktree name must be non-empty.", { exitCode: 2 });
|
|
131
|
+
}
|
|
132
|
+
if (name === "." || name === "..") {
|
|
133
|
+
throw new CliError(`Invalid worktree name "${name}".`, { exitCode: 2 });
|
|
134
|
+
}
|
|
135
|
+
if (name.includes("/") || name.includes("\\") || name.includes("\0")) {
|
|
136
|
+
throw new CliError(`Invalid worktree name "${name}".`, { exitCode: 2 });
|
|
137
|
+
}
|
|
138
|
+
if (name.includes("..")) {
|
|
139
|
+
throw new CliError(`Invalid worktree name "${name}".`, { exitCode: 2 });
|
|
140
|
+
}
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
const listWorktreesPorcelain = async (repoRoot: string): Promise<PorcelainWorktree[]> => {
|
|
144
|
+
const output = await runGit(["worktree", "list", "--porcelain"], repoRoot);
|
|
145
|
+
return parseWorktreeListPorcelain(output);
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
export const getWorkboxWorktrees = async (input: {
|
|
149
|
+
repoRoot: string;
|
|
150
|
+
worktreesDir: string;
|
|
151
|
+
branchPrefix: string;
|
|
152
|
+
}): Promise<WorktreeInfo[]> => {
|
|
153
|
+
await assertWorktreesDirSafe(input.repoRoot, input.worktreesDir);
|
|
154
|
+
const worktrees = await listWorktreesPorcelain(input.repoRoot);
|
|
155
|
+
const normalizedWorktreesDir = await normalizePath(input.worktreesDir);
|
|
156
|
+
|
|
157
|
+
const items: WorktreeInfo[] = [];
|
|
158
|
+
for (const worktree of worktrees) {
|
|
159
|
+
const resolvedPath = resolve(input.repoRoot, worktree.path);
|
|
160
|
+
const normalizedPath = await normalizePath(resolvedPath);
|
|
161
|
+
|
|
162
|
+
if (!isSubpath(normalizedPath, normalizedWorktreesDir)) {
|
|
163
|
+
continue;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const relativePath = relative(normalizedWorktreesDir, normalizedPath);
|
|
167
|
+
if (!relativePath || relativePath === "." || relativePath.includes(sep)) {
|
|
168
|
+
continue;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
validateWorktreeName(relativePath);
|
|
172
|
+
const name = relativePath;
|
|
173
|
+
const expectedPath = await normalizePath(join(input.worktreesDir, name));
|
|
174
|
+
|
|
175
|
+
if (expectedPath !== normalizedPath) {
|
|
176
|
+
continue;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const managedBranch = `${input.branchPrefix}${name}`;
|
|
180
|
+
items.push({
|
|
181
|
+
name,
|
|
182
|
+
path: normalizedPath,
|
|
183
|
+
branch: worktree.branch,
|
|
184
|
+
managedBranch,
|
|
185
|
+
managed: worktree.branch === managedBranch,
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
items.sort((a, b) => a.name.localeCompare(b.name));
|
|
190
|
+
return items;
|
|
191
|
+
};
|
|
192
|
+
|
|
193
|
+
export const getManagedWorktrees = async (input: {
|
|
194
|
+
repoRoot: string;
|
|
195
|
+
worktreesDir: string;
|
|
196
|
+
branchPrefix: string;
|
|
197
|
+
}): Promise<WorktreeInfo[]> => {
|
|
198
|
+
const items = await getWorkboxWorktrees(input);
|
|
199
|
+
return items.filter((item) => item.managed);
|
|
200
|
+
};
|
|
201
|
+
|
|
202
|
+
export const getWorkboxWorktree = async (input: {
|
|
203
|
+
repoRoot: string;
|
|
204
|
+
worktreesDir: string;
|
|
205
|
+
branchPrefix: string;
|
|
206
|
+
name: string;
|
|
207
|
+
}): Promise<WorktreeInfo> => {
|
|
208
|
+
await assertWorktreesDirSafe(input.repoRoot, input.worktreesDir);
|
|
209
|
+
validateWorktreeName(input.name);
|
|
210
|
+
const expectedPath = await normalizePath(join(input.worktreesDir, input.name));
|
|
211
|
+
|
|
212
|
+
const candidates = await getWorkboxWorktrees({
|
|
213
|
+
repoRoot: input.repoRoot,
|
|
214
|
+
worktreesDir: input.worktreesDir,
|
|
215
|
+
branchPrefix: input.branchPrefix,
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
const match = candidates.find((candidate) => candidate.name === input.name);
|
|
219
|
+
if (!match) {
|
|
220
|
+
throw new CliError(`No workbox worktree found for "${input.name}".`);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
if ((await normalizePath(match.path)) !== expectedPath) {
|
|
224
|
+
throw new CliError(
|
|
225
|
+
`Refusing to operate on "${input.name}": path mismatch (expected ${expectedPath}, got ${match.path}).`
|
|
226
|
+
);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
return match;
|
|
230
|
+
};
|
|
231
|
+
|
|
232
|
+
export const createWorktree = async (input: {
|
|
233
|
+
repoRoot: string;
|
|
234
|
+
worktreesDir: string;
|
|
235
|
+
branchPrefix: string;
|
|
236
|
+
baseRef: string;
|
|
237
|
+
name: string;
|
|
238
|
+
}): Promise<WorktreeInfo> => {
|
|
239
|
+
await assertWorktreesDirSafe(input.repoRoot, input.worktreesDir);
|
|
240
|
+
validateWorktreeName(input.name);
|
|
241
|
+
const managedBranch = `${input.branchPrefix}${input.name}`;
|
|
242
|
+
const worktreePath = join(input.worktreesDir, input.name);
|
|
243
|
+
|
|
244
|
+
await mkdir(input.worktreesDir, { recursive: true });
|
|
245
|
+
await assertValidBranchName(managedBranch, input.repoRoot);
|
|
246
|
+
|
|
247
|
+
const baseExists = await gitSucceeds(["rev-parse", "--verify", input.baseRef], input.repoRoot);
|
|
248
|
+
if (!baseExists) {
|
|
249
|
+
throw new CliError(`Base ref "${input.baseRef}" does not exist.`);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
const branchExists = await gitSucceeds(
|
|
253
|
+
["show-ref", "--verify", `refs/heads/${managedBranch}`],
|
|
254
|
+
input.repoRoot
|
|
255
|
+
);
|
|
256
|
+
if (branchExists) {
|
|
257
|
+
throw new CliError(
|
|
258
|
+
`Branch "${managedBranch}" already exists. Refusing to create a new sandbox.`
|
|
259
|
+
);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
await runGit(
|
|
263
|
+
["worktree", "add", "-b", managedBranch, worktreePath, input.baseRef],
|
|
264
|
+
input.repoRoot
|
|
265
|
+
);
|
|
266
|
+
|
|
267
|
+
if (await pathExists(join(worktreePath, ".gitmodules"))) {
|
|
268
|
+
await runGit(["submodule", "update", "--init", "--recursive"], worktreePath);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
return {
|
|
272
|
+
name: input.name,
|
|
273
|
+
path: await normalizePath(worktreePath),
|
|
274
|
+
branch: managedBranch,
|
|
275
|
+
managedBranch,
|
|
276
|
+
managed: true,
|
|
277
|
+
};
|
|
278
|
+
};
|
|
279
|
+
|
|
280
|
+
export const removeWorktree = async (input: {
|
|
281
|
+
repoRoot: string;
|
|
282
|
+
worktreesDir: string;
|
|
283
|
+
branchPrefix: string;
|
|
284
|
+
name: string;
|
|
285
|
+
force: boolean;
|
|
286
|
+
deleteBranch: boolean;
|
|
287
|
+
}): Promise<WorktreeInfo> => {
|
|
288
|
+
await assertWorktreesDirSafe(input.repoRoot, input.worktreesDir);
|
|
289
|
+
const worktree = await getWorkboxWorktree({
|
|
290
|
+
repoRoot: input.repoRoot,
|
|
291
|
+
worktreesDir: input.worktreesDir,
|
|
292
|
+
branchPrefix: input.branchPrefix,
|
|
293
|
+
name: input.name,
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
if (input.deleteBranch && !worktree.managed) {
|
|
297
|
+
throw new CliError(`Refusing to delete a branch for unmanaged worktree "${input.name}".`);
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
await runGit(
|
|
301
|
+
["worktree", "remove", ...(input.force ? ["--force"] : []), "--", worktree.path],
|
|
302
|
+
input.repoRoot
|
|
303
|
+
);
|
|
304
|
+
|
|
305
|
+
if (input.deleteBranch) {
|
|
306
|
+
await runGit(["branch", input.force ? "-D" : "-d", worktree.managedBranch], input.repoRoot);
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
return worktree;
|
|
310
|
+
};
|
|
311
|
+
|
|
312
|
+
export const pruneWorktrees = async (repoRoot: string): Promise<{ stdout: string }> => {
|
|
313
|
+
const stdout = await runGit(["worktree", "prune"], repoRoot);
|
|
314
|
+
return { stdout };
|
|
315
|
+
};
|
|
316
|
+
|
|
317
|
+
const isCleanWorktree = async (path: string): Promise<boolean> => {
|
|
318
|
+
const status = await runGit(["status", "--porcelain"], path);
|
|
319
|
+
return status.trim().length === 0;
|
|
320
|
+
};
|
|
321
|
+
|
|
322
|
+
export const getWorktreeStatus = async (input: {
|
|
323
|
+
repoRoot: string;
|
|
324
|
+
worktreesDir: string;
|
|
325
|
+
branchPrefix: string;
|
|
326
|
+
name?: string;
|
|
327
|
+
}): Promise<WorktreeStatus[]> => {
|
|
328
|
+
await assertWorktreesDirSafe(input.repoRoot, input.worktreesDir);
|
|
329
|
+
const worktrees = input.name
|
|
330
|
+
? [
|
|
331
|
+
await getWorkboxWorktree({
|
|
332
|
+
repoRoot: input.repoRoot,
|
|
333
|
+
worktreesDir: input.worktreesDir,
|
|
334
|
+
branchPrefix: input.branchPrefix,
|
|
335
|
+
name: input.name,
|
|
336
|
+
}),
|
|
337
|
+
]
|
|
338
|
+
: await getWorkboxWorktrees({
|
|
339
|
+
repoRoot: input.repoRoot,
|
|
340
|
+
worktreesDir: input.worktreesDir,
|
|
341
|
+
branchPrefix: input.branchPrefix,
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
const results: WorktreeStatus[] = [];
|
|
345
|
+
for (const worktree of worktrees) {
|
|
346
|
+
results.push({
|
|
347
|
+
...worktree,
|
|
348
|
+
clean: await isCleanWorktree(worktree.path),
|
|
349
|
+
});
|
|
350
|
+
}
|
|
351
|
+
return results;
|
|
352
|
+
};
|