@jhorst11/wt 2.0.2 → 2.2.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/src/config.js DELETED
@@ -1,426 +0,0 @@
1
- import { readFileSync, writeFileSync, mkdirSync } from 'fs';
2
- import { join } from 'path';
3
- import { spawn } from 'child_process';
4
- import { homedir } from 'os';
5
- import { once } from 'events';
6
-
7
- const CONFIG_DIR = '.wt';
8
- const CONFIG_FILE = 'config.json';
9
- const WORKTREE_COLORS_FILE = 'worktree-colors.json';
10
-
11
- /** Distinct hex colors (with #) for worktree tab/UI; cycle through for unique assignment. */
12
- export const WORKTREE_COLORS_PALETTE = [
13
- '#E53935', '#D81B60', '#8E24AA', '#5E35B1', '#3949AB', '#1E88E5', '#039BE5', '#00ACC1',
14
- '#00897B', '#43A047', '#7CB342', '#C0CA33', '#FDD835', '#FFB300', '#FB8C00', '#F4511E',
15
- ];
16
-
17
- function getWorktreeColorsPath(repoRoot) {
18
- return join(repoRoot, CONFIG_DIR, WORKTREE_COLORS_FILE);
19
- }
20
-
21
- /**
22
- * Load worktree name → hex color map from repo's .wt/worktree-colors.json.
23
- * @returns {Record<string, string>}
24
- */
25
- export function loadWorktreeColors(repoRoot) {
26
- const path = getWorktreeColorsPath(repoRoot);
27
- try {
28
- const raw = readFileSync(path, 'utf8');
29
- const data = JSON.parse(raw);
30
- if (typeof data === 'object' && data !== null && !Array.isArray(data)) {
31
- const out = {};
32
- for (const [name, hex] of Object.entries(data)) {
33
- if (typeof name === 'string' && typeof hex === 'string' && /^#[0-9A-Fa-f]{6}$/.test(hex)) {
34
- out[name] = hex;
35
- }
36
- }
37
- return out;
38
- }
39
- } catch {
40
- // file missing or invalid
41
- }
42
- return {};
43
- }
44
-
45
- /**
46
- * Save worktree name → hex color map to repo's .wt/worktree-colors.json.
47
- */
48
- export function saveWorktreeColors(repoRoot, mapping) {
49
- const dir = join(repoRoot, CONFIG_DIR);
50
- const path = getWorktreeColorsPath(repoRoot);
51
- try {
52
- mkdirSync(dir, { recursive: true });
53
- writeFileSync(path, JSON.stringify(mapping, null, 2) + '\n', 'utf8');
54
- } catch {
55
- // ignore write errors (e.g. read-only repo)
56
- }
57
- }
58
-
59
- /**
60
- * Assign a unique color to a new worktree. Checks config overrides first, then
61
- * uses first palette color not used by existing worktrees.
62
- * Persists and returns the hex color (e.g. "#E53935").
63
- */
64
- export function assignWorktreeColor(repoRoot, worktreeName) {
65
- const current = loadWorktreeColors(repoRoot);
66
-
67
- // Check if already assigned
68
- let hex = current[worktreeName];
69
- if (hex) return hex;
70
-
71
- // Check config override
72
- const config = resolveConfig(process.cwd(), repoRoot);
73
- if (config.worktreeColors?.[worktreeName]) {
74
- hex = config.worktreeColors[worktreeName];
75
- current[worktreeName] = hex;
76
- saveWorktreeColors(repoRoot, current);
77
- return hex;
78
- }
79
-
80
- // Auto-assign from palette (prefer custom palette if configured)
81
- const palette = config.colorPalette || WORKTREE_COLORS_PALETTE;
82
- const usedColors = new Set(Object.values(current));
83
-
84
- for (const c of palette) {
85
- if (!usedColors.has(c)) {
86
- hex = c;
87
- break;
88
- }
89
- }
90
-
91
- hex = hex || palette[usedColors.size % palette.length];
92
- current[worktreeName] = hex;
93
- saveWorktreeColors(repoRoot, current);
94
- return hex;
95
- }
96
-
97
- /**
98
- * Get the assigned hex color for a worktree, or null if none.
99
- */
100
- export function getWorktreeColor(repoRoot, worktreeName) {
101
- const current = loadWorktreeColors(repoRoot);
102
- return current[worktreeName] ?? null;
103
- }
104
-
105
- /**
106
- * Remove a worktree's color assignment so the color can be reused.
107
- */
108
- export function removeWorktreeColor(repoRoot, worktreeName) {
109
- const current = loadWorktreeColors(repoRoot);
110
- if (worktreeName in current) {
111
- delete current[worktreeName];
112
- saveWorktreeColors(repoRoot, current);
113
- }
114
- }
115
-
116
- /**
117
- * Load configuration from .wt/config.json or config.json at the given directory.
118
- * Tries .wt/config.json first (for repo/directory configs), then config.json (for global configs).
119
- * Returns an object with defaults for any missing fields.
120
- */
121
- export function loadConfig(dirPath) {
122
- const configPaths = [
123
- join(dirPath, CONFIG_DIR, CONFIG_FILE), // .wt/config.json (for repo/directory)
124
- join(dirPath, CONFIG_FILE), // config.json (for global config dir)
125
- ];
126
- const defaults = {
127
- projectsDir: undefined,
128
- worktreesDir: undefined,
129
- branchPrefix: undefined,
130
- hooks: {},
131
- worktreeColors: {},
132
- colorPalette: undefined,
133
- };
134
-
135
- let raw;
136
- for (const configPath of configPaths) {
137
- try {
138
- raw = readFileSync(configPath, 'utf8');
139
- break;
140
- } catch {
141
- // Try next path
142
- }
143
- }
144
-
145
- if (!raw) {
146
- return defaults;
147
- }
148
-
149
- let parsed;
150
- try {
151
- parsed = JSON.parse(raw);
152
- } catch {
153
- return defaults;
154
- }
155
-
156
- if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
157
- return defaults;
158
- }
159
-
160
- const result = { ...defaults };
161
-
162
- if (typeof parsed.projectsDir === 'string') {
163
- result.projectsDir = parsed.projectsDir;
164
- }
165
-
166
- if (typeof parsed.worktreesDir === 'string') {
167
- result.worktreesDir = parsed.worktreesDir;
168
- }
169
-
170
- if (typeof parsed.branchPrefix === 'string') {
171
- result.branchPrefix = parsed.branchPrefix;
172
- }
173
-
174
- if (typeof parsed.hooks === 'object' && parsed.hooks !== null && !Array.isArray(parsed.hooks)) {
175
- for (const [hookName, commands] of Object.entries(parsed.hooks)) {
176
- if (Array.isArray(commands) && commands.every((c) => typeof c === 'string')) {
177
- result.hooks[hookName] = commands;
178
- }
179
- }
180
- }
181
-
182
- if (typeof parsed.worktreeColors === 'object' && parsed.worktreeColors !== null && !Array.isArray(parsed.worktreeColors)) {
183
- for (const [name, hex] of Object.entries(parsed.worktreeColors)) {
184
- if (typeof name === 'string' && typeof hex === 'string' && /^#[0-9A-Fa-f]{6}$/.test(hex)) {
185
- result.worktreeColors[name] = hex;
186
- }
187
- }
188
- }
189
-
190
- if (Array.isArray(parsed.colorPalette)) {
191
- const validColors = parsed.colorPalette.filter(hex =>
192
- typeof hex === 'string' && /^#[0-9A-Fa-f]{6}$/.test(hex)
193
- );
194
- if (validColors.length > 0) {
195
- result.colorPalette = validColors;
196
- }
197
- }
198
-
199
- return result;
200
- }
201
-
202
- /**
203
- * Find all config files from global to cwd within repo boundaries.
204
- * Walks up from cwd to repoRoot, collecting all .wt/config.json paths.
205
- *
206
- * @param {string} cwd - Current working directory
207
- * @param {string} repoRoot - Git repository root
208
- * @param {string} [globalConfigPath] - Path to global config (default: ~/.wt/config.json)
209
- * @returns {string[]} Array of config file paths (global first)
210
- */
211
- function findConfigFiles(cwd, repoRoot, globalConfigPath = join(homedir(), '.wt', CONFIG_FILE)) {
212
- const paths = [];
213
-
214
- // Add global config if it exists
215
- try {
216
- readFileSync(globalConfigPath, 'utf8');
217
- paths.push(globalConfigPath);
218
- } catch {
219
- // Global config doesn't exist, that's fine
220
- }
221
-
222
- // Build list of directories from repoRoot down to cwd
223
- // Normalize paths for comparison
224
- const normalizedCwd = cwd.replace(/\/$/, '');
225
- const normalizedRepoRoot = repoRoot.replace(/\/$/, '');
226
-
227
- if (!normalizedCwd.startsWith(normalizedRepoRoot)) {
228
- // cwd is outside repo root, skip walking
229
- return paths;
230
- }
231
-
232
- // Collect config paths from repoRoot up to cwd
233
- const configPaths = [];
234
-
235
- // Start at repoRoot
236
- let current = normalizedRepoRoot;
237
- const parts = normalizedCwd.slice(normalizedRepoRoot.length).split('/').filter(Boolean);
238
-
239
- // Add repo root config
240
- const repoConfigPath = join(normalizedRepoRoot, CONFIG_DIR, CONFIG_FILE);
241
- try {
242
- readFileSync(repoConfigPath, 'utf8');
243
- configPaths.push(repoConfigPath);
244
- } catch {
245
- // No config at repo root
246
- }
247
-
248
- // Add configs for each directory down to cwd
249
- for (const part of parts) {
250
- current = join(current, part);
251
- const configPath = join(current, CONFIG_DIR, CONFIG_FILE);
252
- try {
253
- readFileSync(configPath, 'utf8');
254
- configPaths.push(configPath);
255
- } catch {
256
- // No config at this directory
257
- }
258
- }
259
-
260
- return paths.concat(configPaths);
261
- }
262
-
263
- /**
264
- * Merge multiple config objects with last-wins strategy.
265
- * For scalar fields, last defined value wins.
266
- * For hooks object, merge all hook definitions.
267
- *
268
- * @param {Object[]} configs - Array of config objects (least specific first)
269
- * @returns {Object} Merged config
270
- */
271
- function mergeConfigs(configs) {
272
- const result = {
273
- projectsDir: undefined,
274
- worktreesDir: undefined,
275
- branchPrefix: undefined,
276
- hooks: {},
277
- };
278
-
279
- for (const config of configs) {
280
- if (config.projectsDir !== undefined) {
281
- result.projectsDir = config.projectsDir;
282
- }
283
- if (config.worktreesDir !== undefined) {
284
- result.worktreesDir = config.worktreesDir;
285
- }
286
- if (config.branchPrefix !== undefined) {
287
- result.branchPrefix = config.branchPrefix;
288
- }
289
- if (config.hooks && typeof config.hooks === 'object') {
290
- for (const [hookName, commands] of Object.entries(config.hooks)) {
291
- result.hooks[hookName] = commands;
292
- }
293
- }
294
- }
295
-
296
- return result;
297
- }
298
-
299
- /**
300
- * Resolve hierarchical config by walking up from cwd to repoRoot.
301
- * Returns merged config with defaults for any missing fields.
302
- *
303
- * @param {string} [cwd] - Current working directory (default: process.cwd())
304
- * @param {string} repoRoot - Git repository root
305
- * @param {string} [globalConfigPath] - Override global config path (for testing)
306
- * @returns {Object} Resolved config with all fields
307
- */
308
- export function resolveConfig(cwd = process.cwd(), repoRoot, globalConfigPath) {
309
- const defaults = {
310
- projectsDir: join(homedir(), 'projects'),
311
- worktreesDir: join(homedir(), 'projects', 'worktrees'),
312
- branchPrefix: '',
313
- hooks: {},
314
- };
315
-
316
- // Determine the effective global config path
317
- const effectiveGlobalConfigPath = globalConfigPath || join(homedir(), '.wt', CONFIG_FILE);
318
-
319
- // Find all config files from global to cwd
320
- const configPaths = findConfigFiles(cwd, repoRoot, effectiveGlobalConfigPath);
321
-
322
- // Load each config file by extracting the directory path
323
- const configs = configPaths.map((path) => {
324
- // For global config, extract the directory containing it
325
- if (path === effectiveGlobalConfigPath) {
326
- const globalConfigDir = path.endsWith(CONFIG_FILE)
327
- ? path.slice(0, path.lastIndexOf('/'))
328
- : path;
329
- return loadConfig(globalConfigDir);
330
- }
331
- // For other configs at /path/.wt/config.json, directory is /path
332
- const dirPath = path.slice(0, path.lastIndexOf('/.wt'));
333
- return loadConfig(dirPath);
334
- });
335
-
336
- // Merge configs
337
- const merged = mergeConfigs(configs);
338
-
339
- // Apply defaults for missing fields
340
- return {
341
- projectsDir: merged.projectsDir ?? defaults.projectsDir,
342
- worktreesDir: merged.worktreesDir ?? defaults.worktreesDir,
343
- branchPrefix: merged.branchPrefix ?? defaults.branchPrefix,
344
- hooks: merged.hooks,
345
- };
346
- }
347
-
348
- const HOOK_TIMEOUT_MS = 300_000; // 5 minutes per command
349
-
350
- /**
351
- * Run hook commands sequentially. Each command runs with cwd set to `wtPath`
352
- * and receives WT_SOURCE, WT_BRANCH, and WT_PATH as environment variables.
353
- *
354
- * Options:
355
- * - verbose: if true, stream stdout/stderr to the terminal; if false, suppress output and only report results.
356
- * - onCommandStart(cmd, index, total): called before each command (e.g. to update a spinner).
357
- *
358
- * Returns an array of { command, success, error? } results.
359
- * Hook failures are non-fatal — they produce warnings but don't throw.
360
- */
361
- export async function runHooks(hookName, config, { source, path: wtPath, branch, name: wtName, color: wtColor }, options = {}) {
362
- const commands = config.hooks?.[hookName];
363
- if (!commands || commands.length === 0) return [];
364
-
365
- const { verbose = false, onCommandStart } = options;
366
- const total = commands.length;
367
-
368
- const env = {
369
- ...process.env,
370
- WT_SOURCE: source,
371
- WT_BRANCH: branch,
372
- WT_PATH: wtPath,
373
- ...(wtName !== undefined && { WT_NAME: wtName }),
374
- ...(wtColor !== undefined && wtColor !== null && { WT_COLOR: wtColor }),
375
- };
376
-
377
- const results = [];
378
-
379
- for (let i = 0; i < commands.length; i++) {
380
- const cmd = commands[i];
381
- if (typeof onCommandStart === 'function') {
382
- onCommandStart(cmd, i + 1, total);
383
- }
384
-
385
- const child = spawn(cmd, [], {
386
- shell: true,
387
- cwd: wtPath,
388
- env,
389
- stdio: ['inherit', 'pipe', 'pipe'],
390
- });
391
-
392
- const stderrChunks = [];
393
- if (verbose) {
394
- child.stdout.pipe(process.stdout);
395
- child.stderr.on('data', (chunk) => {
396
- process.stderr.write(chunk);
397
- stderrChunks.push(chunk);
398
- });
399
- } else {
400
- child.stdout.on('data', () => {}); // consume to avoid blocking the child
401
- child.stderr.on('data', (chunk) => stderrChunks.push(chunk));
402
- }
403
-
404
- const timeoutId = setTimeout(() => {
405
- child.kill('SIGTERM');
406
- }, HOOK_TIMEOUT_MS);
407
-
408
- try {
409
- const [code, signal] = await once(child, 'exit');
410
- clearTimeout(timeoutId);
411
- if (code === 0 && !signal) {
412
- results.push({ command: cmd, success: true });
413
- } else {
414
- const stderr = Buffer.concat(stderrChunks).toString().trim();
415
- const detail = stderr || (signal ? `Killed by ${signal}` : `Exited with code ${code}`);
416
- results.push({ command: cmd, success: false, error: detail });
417
- }
418
- } catch (err) {
419
- clearTimeout(timeoutId);
420
- const stderr = Buffer.concat(stderrChunks).toString().trim();
421
- results.push({ command: cmd, success: false, error: stderr || err.message });
422
- }
423
- }
424
-
425
- return results;
426
- }