@jhorst11/wt 2.0.1 → 2.1.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,257 +0,0 @@
1
- import { readFileSync } from 'fs';
2
- import { join } from 'path';
3
- import { execSync } from 'child_process';
4
- import { homedir } from 'os';
5
-
6
- const CONFIG_DIR = '.wt';
7
- const CONFIG_FILE = 'config.json';
8
-
9
- /**
10
- * Load configuration from .wt/config.json or config.json at the given directory.
11
- * Tries .wt/config.json first (for repo/directory configs), then config.json (for global configs).
12
- * Returns an object with defaults for any missing fields.
13
- */
14
- export function loadConfig(dirPath) {
15
- const configPaths = [
16
- join(dirPath, CONFIG_DIR, CONFIG_FILE), // .wt/config.json (for repo/directory)
17
- join(dirPath, CONFIG_FILE), // config.json (for global config dir)
18
- ];
19
- const defaults = {
20
- projectsDir: undefined,
21
- worktreesDir: undefined,
22
- branchPrefix: undefined,
23
- hooks: {},
24
- };
25
-
26
- let raw;
27
- for (const configPath of configPaths) {
28
- try {
29
- raw = readFileSync(configPath, 'utf8');
30
- break;
31
- } catch {
32
- // Try next path
33
- }
34
- }
35
-
36
- if (!raw) {
37
- return defaults;
38
- }
39
-
40
- let parsed;
41
- try {
42
- parsed = JSON.parse(raw);
43
- } catch {
44
- return defaults;
45
- }
46
-
47
- if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
48
- return defaults;
49
- }
50
-
51
- const result = { ...defaults };
52
-
53
- if (typeof parsed.projectsDir === 'string') {
54
- result.projectsDir = parsed.projectsDir;
55
- }
56
-
57
- if (typeof parsed.worktreesDir === 'string') {
58
- result.worktreesDir = parsed.worktreesDir;
59
- }
60
-
61
- if (typeof parsed.branchPrefix === 'string') {
62
- result.branchPrefix = parsed.branchPrefix;
63
- }
64
-
65
- if (typeof parsed.hooks === 'object' && parsed.hooks !== null && !Array.isArray(parsed.hooks)) {
66
- for (const [hookName, commands] of Object.entries(parsed.hooks)) {
67
- if (Array.isArray(commands) && commands.every((c) => typeof c === 'string')) {
68
- result.hooks[hookName] = commands;
69
- }
70
- }
71
- }
72
-
73
- return result;
74
- }
75
-
76
- /**
77
- * Find all config files from global to cwd within repo boundaries.
78
- * Walks up from cwd to repoRoot, collecting all .wt/config.json paths.
79
- *
80
- * @param {string} cwd - Current working directory
81
- * @param {string} repoRoot - Git repository root
82
- * @param {string} [globalConfigPath] - Path to global config (default: ~/.wt/config.json)
83
- * @returns {string[]} Array of config file paths (global first)
84
- */
85
- function findConfigFiles(cwd, repoRoot, globalConfigPath = join(homedir(), '.wt', CONFIG_FILE)) {
86
- const paths = [];
87
-
88
- // Add global config if it exists
89
- try {
90
- readFileSync(globalConfigPath, 'utf8');
91
- paths.push(globalConfigPath);
92
- } catch {
93
- // Global config doesn't exist, that's fine
94
- }
95
-
96
- // Build list of directories from repoRoot down to cwd
97
- // Normalize paths for comparison
98
- const normalizedCwd = cwd.replace(/\/$/, '');
99
- const normalizedRepoRoot = repoRoot.replace(/\/$/, '');
100
-
101
- if (!normalizedCwd.startsWith(normalizedRepoRoot)) {
102
- // cwd is outside repo root, skip walking
103
- return paths;
104
- }
105
-
106
- // Collect config paths from repoRoot up to cwd
107
- const configPaths = [];
108
-
109
- // Start at repoRoot
110
- let current = normalizedRepoRoot;
111
- const parts = normalizedCwd.slice(normalizedRepoRoot.length).split('/').filter(Boolean);
112
-
113
- // Add repo root config
114
- const repoConfigPath = join(normalizedRepoRoot, CONFIG_DIR, CONFIG_FILE);
115
- try {
116
- readFileSync(repoConfigPath, 'utf8');
117
- configPaths.push(repoConfigPath);
118
- } catch {
119
- // No config at repo root
120
- }
121
-
122
- // Add configs for each directory down to cwd
123
- for (const part of parts) {
124
- current = join(current, part);
125
- const configPath = join(current, CONFIG_DIR, CONFIG_FILE);
126
- try {
127
- readFileSync(configPath, 'utf8');
128
- configPaths.push(configPath);
129
- } catch {
130
- // No config at this directory
131
- }
132
- }
133
-
134
- return paths.concat(configPaths);
135
- }
136
-
137
- /**
138
- * Merge multiple config objects with last-wins strategy.
139
- * For scalar fields, last defined value wins.
140
- * For hooks object, merge all hook definitions.
141
- *
142
- * @param {Object[]} configs - Array of config objects (least specific first)
143
- * @returns {Object} Merged config
144
- */
145
- function mergeConfigs(configs) {
146
- const result = {
147
- projectsDir: undefined,
148
- worktreesDir: undefined,
149
- branchPrefix: undefined,
150
- hooks: {},
151
- };
152
-
153
- for (const config of configs) {
154
- if (config.projectsDir !== undefined) {
155
- result.projectsDir = config.projectsDir;
156
- }
157
- if (config.worktreesDir !== undefined) {
158
- result.worktreesDir = config.worktreesDir;
159
- }
160
- if (config.branchPrefix !== undefined) {
161
- result.branchPrefix = config.branchPrefix;
162
- }
163
- if (config.hooks && typeof config.hooks === 'object') {
164
- for (const [hookName, commands] of Object.entries(config.hooks)) {
165
- result.hooks[hookName] = commands;
166
- }
167
- }
168
- }
169
-
170
- return result;
171
- }
172
-
173
- /**
174
- * Resolve hierarchical config by walking up from cwd to repoRoot.
175
- * Returns merged config with defaults for any missing fields.
176
- *
177
- * @param {string} [cwd] - Current working directory (default: process.cwd())
178
- * @param {string} repoRoot - Git repository root
179
- * @param {string} [globalConfigPath] - Override global config path (for testing)
180
- * @returns {Object} Resolved config with all fields
181
- */
182
- export function resolveConfig(cwd = process.cwd(), repoRoot, globalConfigPath) {
183
- const defaults = {
184
- projectsDir: join(homedir(), 'projects'),
185
- worktreesDir: join(homedir(), 'projects', 'worktrees'),
186
- branchPrefix: '',
187
- hooks: {},
188
- };
189
-
190
- // Determine the effective global config path
191
- const effectiveGlobalConfigPath = globalConfigPath || join(homedir(), '.wt', CONFIG_FILE);
192
-
193
- // Find all config files from global to cwd
194
- const configPaths = findConfigFiles(cwd, repoRoot, effectiveGlobalConfigPath);
195
-
196
- // Load each config file by extracting the directory path
197
- const configs = configPaths.map((path) => {
198
- // For global config, extract the directory containing it
199
- if (path === effectiveGlobalConfigPath) {
200
- const globalConfigDir = path.endsWith(CONFIG_FILE)
201
- ? path.slice(0, path.lastIndexOf('/'))
202
- : path;
203
- return loadConfig(globalConfigDir);
204
- }
205
- // For other configs at /path/.wt/config.json, directory is /path
206
- const dirPath = path.slice(0, path.lastIndexOf('/.wt'));
207
- return loadConfig(dirPath);
208
- });
209
-
210
- // Merge configs
211
- const merged = mergeConfigs(configs);
212
-
213
- // Apply defaults for missing fields
214
- return {
215
- projectsDir: merged.projectsDir ?? defaults.projectsDir,
216
- worktreesDir: merged.worktreesDir ?? defaults.worktreesDir,
217
- branchPrefix: merged.branchPrefix ?? defaults.branchPrefix,
218
- hooks: merged.hooks,
219
- };
220
- }
221
-
222
- /**
223
- * Run hook commands sequentially. Each command runs with cwd set to `wtPath`
224
- * and receives WT_SOURCE, WT_BRANCH, and WT_PATH as environment variables.
225
- *
226
- * Returns an array of { command, success, error? } results.
227
- * Hook failures are non-fatal — they produce warnings but don't throw.
228
- */
229
- export function runHooks(hookName, config, { source, path: wtPath, branch }) {
230
- const commands = config.hooks?.[hookName];
231
- if (!commands || commands.length === 0) return [];
232
-
233
- const env = {
234
- ...process.env,
235
- WT_SOURCE: source,
236
- WT_BRANCH: branch,
237
- WT_PATH: wtPath,
238
- };
239
-
240
- const results = [];
241
-
242
- for (const cmd of commands) {
243
- try {
244
- execSync(cmd, {
245
- cwd: wtPath,
246
- env,
247
- stdio: 'pipe',
248
- timeout: 300_000, // 5 minute timeout per command
249
- });
250
- results.push({ command: cmd, success: true });
251
- } catch (err) {
252
- results.push({ command: cmd, success: false, error: err.message });
253
- }
254
- }
255
-
256
- return results;
257
- }
package/src/git.js DELETED
@@ -1,397 +0,0 @@
1
- import { simpleGit } from 'simple-git';
2
- import { join, basename, relative } from 'path';
3
- import { existsSync, mkdirSync, readdirSync, statSync } from 'fs';
4
- import { resolveConfig } from './config.js';
5
-
6
- export async function getGit(cwd = process.cwd()) {
7
- return simpleGit(cwd);
8
- }
9
-
10
- export async function isGitRepo(cwd = process.cwd()) {
11
- try {
12
- const git = await getGit(cwd);
13
- await git.revparse(['--git-dir']);
14
- return true;
15
- } catch {
16
- return false;
17
- }
18
- }
19
-
20
- export async function getRepoRoot(cwd = process.cwd()) {
21
- try {
22
- const git = await getGit(cwd);
23
- const root = await git.revparse(['--show-toplevel']);
24
- return root.trim();
25
- } catch {
26
- return null;
27
- }
28
- }
29
-
30
- export async function getCurrentBranch(cwd = process.cwd()) {
31
- try {
32
- const git = await getGit(cwd);
33
- const branch = await git.revparse(['--abbrev-ref', 'HEAD']);
34
- return branch.trim();
35
- } catch {
36
- return null;
37
- }
38
- }
39
-
40
- export async function getLocalBranches(cwd = process.cwd()) {
41
- try {
42
- const git = await getGit(cwd);
43
- const result = await git.branchLocal();
44
- return result.all.map((name) => ({
45
- name,
46
- isCurrent: name === result.current,
47
- }));
48
- } catch {
49
- return [];
50
- }
51
- }
52
-
53
- export async function getRemoteBranches(cwd = process.cwd()) {
54
- try {
55
- const git = await getGit(cwd);
56
- // Fetch latest from remote first
57
- await git.fetch(['--all', '--prune']).catch(() => {});
58
- const result = await git.branch(['-r']);
59
- return result.all
60
- .filter((name) => !name.includes('HEAD'))
61
- .map((name) => ({
62
- name: name.replace(/^origin\//, ''),
63
- fullName: name,
64
- isRemote: true,
65
- }));
66
- } catch {
67
- return [];
68
- }
69
- }
70
-
71
- export async function getAllBranches(cwd = process.cwd()) {
72
- const [local, remote] = await Promise.all([
73
- getLocalBranches(cwd),
74
- getRemoteBranches(cwd),
75
- ]);
76
-
77
- // Merge and dedupe, preferring local branches
78
- const localNames = new Set(local.map((b) => b.name));
79
- const uniqueRemote = remote.filter((b) => !localNames.has(b.name));
80
-
81
- return {
82
- local,
83
- remote: uniqueRemote,
84
- all: [...local.map((b) => ({ ...b, type: 'local' })), ...uniqueRemote.map((b) => ({ ...b, type: 'remote' }))],
85
- };
86
- }
87
-
88
- export function getWorktreesBase(repoRoot, config) {
89
- const projectsDir = config.projectsDir.replace(/\/$/, '');
90
- let repoRel;
91
-
92
- if (repoRoot.startsWith(projectsDir + '/')) {
93
- repoRel = repoRoot.slice(projectsDir.length + 1);
94
- } else {
95
- repoRel = basename(repoRoot);
96
- }
97
-
98
- return join(config.worktreesDir, repoRel);
99
- }
100
-
101
- export async function getWorktrees(cwd = process.cwd()) {
102
- try {
103
- const git = await getGit(cwd);
104
- const result = await git.raw(['worktree', 'list', '--porcelain']);
105
- const worktrees = [];
106
- let current = {};
107
-
108
- for (const line of result.split('\n')) {
109
- if (line.startsWith('worktree ')) {
110
- if (current.path) worktrees.push(current);
111
- current = { path: line.slice(9) };
112
- } else if (line.startsWith('branch ')) {
113
- current.branch = line.slice(7).replace('refs/heads/', '');
114
- } else if (line === 'bare') {
115
- current.bare = true;
116
- } else if (line === 'detached') {
117
- current.detached = true;
118
- }
119
- }
120
- if (current.path) worktrees.push(current);
121
-
122
- // Add name (last part of path) and identify main vs worktrees
123
- return worktrees.map((wt, index) => ({
124
- ...wt,
125
- name: basename(wt.path),
126
- isMain: index === 0,
127
- }));
128
- } catch {
129
- return [];
130
- }
131
- }
132
-
133
- export async function getWorktreesInBase(repoRoot, config) {
134
- const base = getWorktreesBase(repoRoot, config);
135
- if (!existsSync(base)) return [];
136
-
137
- try {
138
- const entries = readdirSync(base);
139
- const worktrees = [];
140
-
141
- for (const entry of entries) {
142
- const entryPath = join(base, entry);
143
- if (statSync(entryPath).isDirectory()) {
144
- // Check if it's a valid git worktree
145
- const gitFile = join(entryPath, '.git');
146
- if (existsSync(gitFile)) {
147
- const branch = await getCurrentBranch(entryPath);
148
- worktrees.push({
149
- name: entry,
150
- path: entryPath,
151
- branch: branch || 'unknown',
152
- });
153
- }
154
- }
155
- }
156
-
157
- return worktrees;
158
- } catch {
159
- return [];
160
- }
161
- }
162
-
163
- export async function getMainRepoPath(cwd = process.cwd()) {
164
- try {
165
- const git = await getGit(cwd);
166
- const result = await git.raw(['worktree', 'list', '--porcelain']);
167
- const firstLine = result.split('\n').find((l) => l.startsWith('worktree '));
168
- return firstLine ? firstLine.slice(9) : null;
169
- } catch {
170
- return null;
171
- }
172
- }
173
-
174
- export function buildBranchName(leaf, config) {
175
- const cleanLeaf = leaf.replace(/^\//, '').replace(/ /g, '-');
176
- const prefix = config.branchPrefix || '';
177
- if (prefix) {
178
- return `${prefix.replace(/\/$/, '')}/${cleanLeaf}`;
179
- }
180
- return cleanLeaf;
181
- }
182
-
183
- export async function branchExistsLocal(branchName, cwd = process.cwd()) {
184
- try {
185
- const git = await getGit(cwd);
186
- // Do not use --quiet: simple-git swallows non-zero exit codes when output is
187
- // empty, so `--quiet` (which suppresses output) causes the try-block to
188
- // succeed even when the ref does not exist. Without --quiet the command
189
- // prints the SHA on success (keeping the try path) and writes to stderr on
190
- // failure (causing simple-git to throw into the catch path).
191
- const result = await git.raw(['show-ref', '--verify', `refs/heads/${branchName}`]);
192
- return result.trim().length > 0;
193
- } catch {
194
- return false;
195
- }
196
- }
197
-
198
- export async function branchExistsRemote(branchName, cwd = process.cwd()) {
199
- try {
200
- const git = await getGit(cwd);
201
- const result = await git.raw(['ls-remote', '--exit-code', '--heads', 'origin', branchName]);
202
- return result.length > 0;
203
- } catch {
204
- return false;
205
- }
206
- }
207
-
208
- export async function ensureBranch(branchName, baseBranch = null, cwd = process.cwd()) {
209
- const git = await getGit(cwd);
210
-
211
- // Resolve detached HEAD to the actual commit SHA so it's usable as a base
212
- if (baseBranch === 'HEAD') {
213
- try {
214
- baseBranch = (await git.revparse(['HEAD'])).trim();
215
- } catch {
216
- throw new Error('HEAD does not point to a valid commit. Is this a new repository with no commits?');
217
- }
218
- }
219
-
220
- // If baseBranch is a remote ref, fetch it first to ensure it's up to date
221
- if (baseBranch && baseBranch.startsWith('origin/')) {
222
- const remoteBranchName = baseBranch.replace('origin/', '');
223
- try {
224
- await git.fetch(['origin', `${remoteBranchName}:refs/remotes/origin/${remoteBranchName}`]);
225
- } catch (fetchErr) {
226
- // Fetch failed - verify the remote ref still exists locally from a previous fetch
227
- try {
228
- await git.revparse(['--verify', baseBranch]);
229
- } catch {
230
- throw new Error(`Failed to fetch remote branch '${remoteBranchName}' and no local copy exists. The remote branch may have been deleted.`);
231
- }
232
- }
233
- }
234
-
235
- // If baseBranch is specified, verify it resolves to a valid ref
236
- if (baseBranch) {
237
- try {
238
- await git.revparse(['--verify', baseBranch]);
239
- } catch {
240
- throw new Error(`Base branch '${baseBranch}' does not exist or is not a valid reference.`);
241
- }
242
- }
243
-
244
- // Check if branch exists locally
245
- if (await branchExistsLocal(branchName, cwd)) {
246
- // If we have a remote baseBranch, update local branch to match it
247
- if (baseBranch && baseBranch.startsWith('origin/')) {
248
- try {
249
- const localSha = (await git.revparse([branchName])).trim();
250
- const remoteSha = (await git.revparse([baseBranch])).trim();
251
-
252
- if (localSha !== remoteSha) {
253
- await git.raw(['branch', '-f', branchName, baseBranch]);
254
- return { created: false, source: 'updated-from-remote' };
255
- }
256
- } catch {
257
- // If we can't compare, fall through to use local as-is
258
- }
259
- }
260
- return { created: false, source: 'local' };
261
- }
262
-
263
- // Check if branch exists on remote (with same name as branchName)
264
- if (await branchExistsRemote(branchName, cwd)) {
265
- await git.fetch(['origin', `${branchName}:${branchName}`]);
266
- return { created: false, source: 'remote' };
267
- }
268
-
269
- // Create new branch from base
270
- if (baseBranch) {
271
- await git.raw(['branch', branchName, baseBranch]);
272
- } else {
273
- await git.raw(['branch', branchName]);
274
- }
275
-
276
- return { created: true, source: 'new' };
277
- }
278
-
279
- export async function createWorktree(name, branchName, baseBranch = null, cwd = process.cwd()) {
280
- const git = await getGit(cwd);
281
- const repoRoot = await getRepoRoot(cwd);
282
- const config = resolveConfig(cwd, repoRoot);
283
- const worktreesBase = getWorktreesBase(repoRoot, config);
284
-
285
- // Prune stale worktree references before creating a new one
286
- await pruneWorktrees(cwd);
287
-
288
- // Ensure worktrees directory exists
289
- if (!existsSync(worktreesBase)) {
290
- mkdirSync(worktreesBase, { recursive: true });
291
- }
292
-
293
- const worktreePath = join(worktreesBase, name);
294
-
295
- // Check if worktree already exists
296
- if (existsSync(worktreePath)) {
297
- return { success: false, error: 'Worktree directory already exists', path: worktreePath };
298
- }
299
-
300
- // Fetch all remotes
301
- await git.fetch(['--all', '--prune']).catch(() => {});
302
-
303
- // Ensure branch exists (or determine if we need to create it)
304
- const branchResult = await ensureBranch(branchName, baseBranch, cwd);
305
-
306
- // Create worktree — ensureBranch guarantees the branch already exists, so we
307
- // just attach the worktree to it.
308
- await git.raw(['worktree', 'add', worktreePath, branchName]);
309
-
310
- return {
311
- success: true,
312
- path: worktreePath,
313
- branch: branchName,
314
- branchCreated: branchResult.created,
315
- branchSource: branchResult.source,
316
- };
317
- }
318
-
319
- export async function removeWorktree(path, force = false, cwd = process.cwd()) {
320
- const git = await getGit(cwd);
321
-
322
- // Prune stale worktree references before removing
323
- await pruneWorktrees(cwd);
324
-
325
- const args = ['worktree', 'remove'];
326
- if (force) args.push('--force');
327
- args.push(path);
328
-
329
- await git.raw(args);
330
- return { success: true };
331
- }
332
-
333
- export async function pruneWorktrees(cwd = process.cwd()) {
334
- const git = await getGit(cwd);
335
- await git.raw(['worktree', 'prune']);
336
- return { success: true };
337
- }
338
-
339
- export function isValidBranchName(name) {
340
- // Basic validation - git allows most characters but not some special ones
341
- if (!name || name.length === 0) return false;
342
- if (name.startsWith('-') || name.startsWith('.')) return false;
343
- if (name.endsWith('/') || name.endsWith('.')) return false;
344
- if (name.includes('..') || name.includes('//')) return false;
345
- if (/[\s~^:?*\[\]\\]/.test(name)) return false;
346
- return true;
347
- }
348
-
349
- export async function mergeBranch(sourceBranch, targetBranch = null, cwd = process.cwd()) {
350
- const git = await getGit(cwd);
351
-
352
- // If target specified, checkout to it first
353
- if (targetBranch) {
354
- await git.checkout(targetBranch);
355
- }
356
-
357
- // Merge the source branch
358
- const result = await git.merge([sourceBranch, '--no-edit']);
359
-
360
- return {
361
- success: true,
362
- merged: sourceBranch,
363
- into: targetBranch || await getCurrentBranch(cwd),
364
- result,
365
- };
366
- }
367
-
368
- export async function getMainBranch(cwd = process.cwd()) {
369
- const git = await getGit(cwd);
370
-
371
- // Try common main branch names
372
- const candidates = ['main', 'master', 'develop'];
373
- const branches = await getLocalBranches(cwd);
374
- const branchNames = branches.map(b => b.name);
375
-
376
- for (const candidate of candidates) {
377
- if (branchNames.includes(candidate)) {
378
- return candidate;
379
- }
380
- }
381
-
382
- // Fall back to first branch
383
- return branchNames[0] || 'main';
384
- }
385
-
386
- export async function hasUncommittedChanges(cwd = process.cwd()) {
387
- const git = await getGit(cwd);
388
- const status = await git.status();
389
- return !status.isClean();
390
- }
391
-
392
- export async function deleteBranch(branchName, force = false, cwd = process.cwd()) {
393
- const git = await getGit(cwd);
394
- const flag = force ? '-D' : '-d';
395
- await git.branch([flag, branchName]);
396
- return { success: true };
397
- }