@jhorst11/wt 1.0.1 → 2.0.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/src/config.js ADDED
@@ -0,0 +1,257 @@
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 CHANGED
@@ -1,18 +1,7 @@
1
1
  import { simpleGit } from 'simple-git';
2
- import { homedir } from 'os';
3
2
  import { join, basename, relative } from 'path';
4
3
  import { existsSync, mkdirSync, readdirSync, statSync } from 'fs';
5
-
6
- // Configuration - can be overridden via environment variables
7
- const config = {
8
- projectsDir: process.env.W_PROJECTS_DIR || join(homedir(), 'projects'),
9
- worktreesDir: process.env.W_WORKTREES_DIR || join(homedir(), 'projects', 'worktrees'),
10
- branchPrefix: process.env.W_DEFAULT_BRANCH_PREFIX || '',
11
- };
12
-
13
- export function getConfig() {
14
- return { ...config };
15
- }
4
+ import { resolveConfig } from './config.js';
16
5
 
17
6
  export async function getGit(cwd = process.cwd()) {
18
7
  return simpleGit(cwd);
@@ -96,7 +85,7 @@ export async function getAllBranches(cwd = process.cwd()) {
96
85
  };
97
86
  }
98
87
 
99
- export function getWorktreesBase(repoRoot) {
88
+ export function getWorktreesBase(repoRoot, config) {
100
89
  const projectsDir = config.projectsDir.replace(/\/$/, '');
101
90
  let repoRel;
102
91
 
@@ -141,8 +130,8 @@ export async function getWorktrees(cwd = process.cwd()) {
141
130
  }
142
131
  }
143
132
 
144
- export async function getWorktreesInBase(repoRoot) {
145
- const base = getWorktreesBase(repoRoot);
133
+ export async function getWorktreesInBase(repoRoot, config) {
134
+ const base = getWorktreesBase(repoRoot, config);
146
135
  if (!existsSync(base)) return [];
147
136
 
148
137
  try {
@@ -182,8 +171,9 @@ export async function getMainRepoPath(cwd = process.cwd()) {
182
171
  }
183
172
  }
184
173
 
185
- export function buildBranchName(leaf, prefix = config.branchPrefix) {
174
+ export function buildBranchName(leaf, config) {
186
175
  const cleanLeaf = leaf.replace(/^\//, '').replace(/ /g, '-');
176
+ const prefix = config.branchPrefix || '';
187
177
  if (prefix) {
188
178
  return `${prefix.replace(/\/$/, '')}/${cleanLeaf}`;
189
179
  }
@@ -193,8 +183,13 @@ export function buildBranchName(leaf, prefix = config.branchPrefix) {
193
183
  export async function branchExistsLocal(branchName, cwd = process.cwd()) {
194
184
  try {
195
185
  const git = await getGit(cwd);
196
- await git.raw(['show-ref', '--verify', '--quiet', `refs/heads/${branchName}`]);
197
- return true;
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;
198
193
  } catch {
199
194
  return false;
200
195
  }
@@ -213,10 +208,37 @@ export async function branchExistsRemote(branchName, cwd = process.cwd()) {
213
208
  export async function ensureBranch(branchName, baseBranch = null, cwd = process.cwd()) {
214
209
  const git = await getGit(cwd);
215
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
+
216
220
  // If baseBranch is a remote ref, fetch it first to ensure it's up to date
217
221
  if (baseBranch && baseBranch.startsWith('origin/')) {
218
222
  const remoteBranchName = baseBranch.replace('origin/', '');
219
- await git.fetch(['origin', `${remoteBranchName}:refs/remotes/origin/${remoteBranchName}`]).catch(() => {});
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
+ }
220
242
  }
221
243
 
222
244
  // Check if branch exists locally
@@ -228,9 +250,7 @@ export async function ensureBranch(branchName, baseBranch = null, cwd = process.
228
250
  const remoteSha = (await git.revparse([baseBranch])).trim();
229
251
 
230
252
  if (localSha !== remoteSha) {
231
- // Local branch exists but points to different commit than remote
232
- // Reset the local branch to match the remote
233
- await git.branch(['-f', branchName, baseBranch]);
253
+ await git.raw(['branch', '-f', branchName, baseBranch]);
234
254
  return { created: false, source: 'updated-from-remote' };
235
255
  }
236
256
  } catch {
@@ -248,9 +268,9 @@ export async function ensureBranch(branchName, baseBranch = null, cwd = process.
248
268
 
249
269
  // Create new branch from base
250
270
  if (baseBranch) {
251
- await git.branch([branchName, baseBranch]);
271
+ await git.raw(['branch', branchName, baseBranch]);
252
272
  } else {
253
- await git.branch([branchName]);
273
+ await git.raw(['branch', branchName]);
254
274
  }
255
275
 
256
276
  return { created: true, source: 'new' };
@@ -259,7 +279,11 @@ export async function ensureBranch(branchName, baseBranch = null, cwd = process.
259
279
  export async function createWorktree(name, branchName, baseBranch = null, cwd = process.cwd()) {
260
280
  const git = await getGit(cwd);
261
281
  const repoRoot = await getRepoRoot(cwd);
262
- const worktreesBase = getWorktreesBase(repoRoot);
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);
263
287
 
264
288
  // Ensure worktrees directory exists
265
289
  if (!existsSync(worktreesBase)) {
@@ -276,10 +300,11 @@ export async function createWorktree(name, branchName, baseBranch = null, cwd =
276
300
  // Fetch all remotes
277
301
  await git.fetch(['--all', '--prune']).catch(() => {});
278
302
 
279
- // Ensure branch exists
303
+ // Ensure branch exists (or determine if we need to create it)
280
304
  const branchResult = await ensureBranch(branchName, baseBranch, cwd);
281
305
 
282
- // Create worktree
306
+ // Create worktree — ensureBranch guarantees the branch already exists, so we
307
+ // just attach the worktree to it.
283
308
  await git.raw(['worktree', 'add', worktreePath, branchName]);
284
309
 
285
310
  return {
@@ -293,6 +318,10 @@ export async function createWorktree(name, branchName, baseBranch = null, cwd =
293
318
 
294
319
  export async function removeWorktree(path, force = false, cwd = process.cwd()) {
295
320
  const git = await getGit(cwd);
321
+
322
+ // Prune stale worktree references before removing
323
+ await pruneWorktrees(cwd);
324
+
296
325
  const args = ['worktree', 'remove'];
297
326
  if (force) args.push('--force');
298
327
  args.push(path);
package/src/setup.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { select, confirm } from '@inquirer/prompts';
2
+ import { ExitPromptError } from '@inquirer/core';
2
3
  import { homedir } from 'os';
3
4
  import { existsSync, readFileSync, appendFileSync, writeFileSync } from 'fs';
4
5
  import { join } from 'path';
@@ -130,7 +131,7 @@ export async function setupCommand() {
130
131
  info(`SHELL environment variable: ${process.env.SHELL || 'not set'}`);
131
132
  spacer();
132
133
  console.log(` ${colors.muted('Please manually add the shell wrapper to your shell config.')}`);
133
- console.log(` ${colors.muted('See:')} ${colors.path('https://github.com/your-repo/wt#shell-integration')}`);
134
+ console.log(` ${colors.muted('See:')} ${colors.path('https://github.com/jhorst11/wt#shell-integration')}`);
134
135
  spacer();
135
136
  return;
136
137
  }
@@ -158,34 +159,44 @@ export async function setupCommand() {
158
159
  console.log(` ${colors.muted('add a small shell function to your')} ${colors.path(config.rcFile)}`);
159
160
  spacer();
160
161
 
161
- const action = await select({
162
- message: 'How would you like to proceed?',
163
- choices: [
164
- {
165
- name: `${icons.sparkles} Auto-install (append to ${config.rcFile})`,
166
- value: 'auto',
167
- description: 'Recommended - automatically adds the integration',
168
- },
169
- {
170
- name: `${icons.info} Show me the code to copy`,
171
- value: 'show',
172
- description: 'Display the code so you can add it manually',
173
- },
174
- {
175
- name: `${colors.muted(icons.cross + ' Skip for now')}`,
176
- value: 'skip',
177
- },
178
- ],
179
- theme: { prefix: icons.tree },
180
- });
162
+ try {
163
+ const action = await select({
164
+ message: 'How would you like to proceed?',
165
+ choices: [
166
+ {
167
+ name: `${icons.sparkles} Auto-install (append to ${config.rcFile})`,
168
+ value: 'auto',
169
+ description: 'Recommended - automatically adds the integration',
170
+ },
171
+ {
172
+ name: `${icons.info} Show me the code to copy`,
173
+ value: 'show',
174
+ description: 'Display the code so you can add it manually',
175
+ },
176
+ {
177
+ name: `${colors.muted(icons.cross + ' Skip for now')}`,
178
+ value: 'skip',
179
+ },
180
+ ],
181
+ theme: { prefix: icons.tree },
182
+ });
181
183
 
182
- if (action === 'auto') {
183
- await autoInstall(config);
184
- } else if (action === 'show') {
185
- showManualInstructions(config);
186
- } else {
187
- info('Skipped. You can run `wt setup` anytime to configure shell integration.');
188
- spacer();
184
+ if (action === 'auto') {
185
+ await autoInstall(config);
186
+ } else if (action === 'show') {
187
+ showManualInstructions(config);
188
+ } else {
189
+ info('Skipped. You can run `wt setup` anytime to configure shell integration.');
190
+ spacer();
191
+ }
192
+ } catch (err) {
193
+ if (err instanceof ExitPromptError) {
194
+ spacer();
195
+ info('Cancelled');
196
+ spacer();
197
+ return;
198
+ }
199
+ throw err;
189
200
  }
190
201
  }
191
202
 
package/src/ui.js CHANGED
@@ -1,6 +1,10 @@
1
1
  import chalk from 'chalk';
2
2
  import gradient from 'gradient-string';
3
3
  import figures from 'figures';
4
+ import { createRequire } from 'module';
5
+
6
+ const require = createRequire(import.meta.url);
7
+ const { version } = require('../package.json');
4
8
 
5
9
  // Custom gradient for the logo
6
10
  const wtGradient = gradient(['#00d4ff', '#7c3aed', '#f472b6']);
@@ -52,7 +56,7 @@ export function showLogo() {
52
56
  }
53
57
 
54
58
  export function showMiniLogo() {
55
- console.log(`\n ${icons.tree} ${wtGradient('worktree')} ${colors.muted('v1.0.0')}\n`);
59
+ console.log(`\n ${icons.tree} ${wtGradient('worktree')} ${colors.muted(`v${version}`)}\n`);
56
60
  }
57
61
 
58
62
  export function success(message) {
@@ -125,10 +129,12 @@ export function showHelp() {
125
129
  const commands = [
126
130
  ['wt', 'Interactive menu to manage worktrees'],
127
131
  ['wt new', 'Create a new worktree interactively'],
128
- ['wt list', 'List all worktrees for current repo'],
129
- ['wt remove', 'Remove a worktree interactively'],
132
+ ['wt list|ls', 'List all worktrees for current repo'],
133
+ ['wt go [name]', 'Jump to a worktree (interactive if no name)'],
134
+ ['wt merge', 'Merge a worktree branch into another branch'],
135
+ ['wt remove|rm', 'Remove a worktree interactively'],
130
136
  ['wt home', 'Jump back to the main repository'],
131
- ['wt go <name>', 'Jump to a specific worktree'],
137
+ ['wt setup', 'Configure shell integration for auto-navigation'],
132
138
  ];
133
139
 
134
140
  commands.forEach(([cmd, desc]) => {