@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/git.js DELETED
@@ -1,416 +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
- }
398
-
399
- /**
400
- * Get information about the current worktree if user is inside one.
401
- * Returns the worktree object with name, path, and branch if inside a worktree.
402
- * Returns null if in main repository or error occurs.
403
- * @param {string} repoRoot - Git repository root
404
- * @param {object} config - Configuration object from resolveConfig()
405
- * @returns {Promise<object|null>} Worktree info object or null
406
- */
407
- export async function getCurrentWorktreeInfo(repoRoot, config) {
408
- const currentPath = process.cwd();
409
- const worktrees = await getWorktreesInBase(repoRoot, config);
410
-
411
- const currentWt = worktrees.find(wt =>
412
- currentPath === wt.path || currentPath.startsWith(wt.path + '/')
413
- );
414
-
415
- return currentWt || null;
416
- }
package/src/setup.js DELETED
@@ -1,267 +0,0 @@
1
- import { select, confirm } from '@inquirer/prompts';
2
- import { ExitPromptError } from '@inquirer/core';
3
- import { homedir } from 'os';
4
- import { existsSync, readFileSync, appendFileSync, writeFileSync } from 'fs';
5
- import { join } from 'path';
6
- import {
7
- showMiniLogo,
8
- success,
9
- error,
10
- warning,
11
- info,
12
- heading,
13
- spacer,
14
- colors,
15
- icons,
16
- divider,
17
- } from './ui.js';
18
-
19
- // Shell detection
20
- export function detectShell() {
21
- const shell = process.env.SHELL || '';
22
-
23
- if (shell.includes('zsh')) return 'zsh';
24
- if (shell.includes('bash')) return 'bash';
25
- if (shell.includes('fish')) return 'fish';
26
- if (process.env.FISH_VERSION) return 'fish';
27
- if (process.env.ZSH_VERSION) return 'zsh';
28
- if (process.env.BASH_VERSION) return 'bash';
29
-
30
- return 'unknown';
31
- }
32
-
33
- export function getShellConfig() {
34
- const shell = detectShell();
35
- const home = homedir();
36
-
37
- const configs = {
38
- zsh: {
39
- name: 'Zsh',
40
- rcFile: join(home, '.zshrc'),
41
- wrapper: `
42
- # wt-cli: Git worktree manager shell integration
43
- wt() {
44
- local wt_cd_file="/tmp/wt_cd_$$"
45
- rm -f "$wt_cd_file"
46
- WT_WRAPPER=1 WT_CD_FILE="$wt_cd_file" command wt "$@"
47
- local exit_code=$?
48
- if [[ -f "$wt_cd_file" ]]; then
49
- local dir=$(cat "$wt_cd_file")
50
- rm -f "$wt_cd_file"
51
- [[ -d "$dir" ]] && cd "$dir"
52
- fi
53
- return $exit_code
54
- }`,
55
- },
56
- bash: {
57
- name: 'Bash',
58
- rcFile: join(home, '.bashrc'),
59
- wrapper: `
60
- # wt-cli: Git worktree manager shell integration
61
- wt() {
62
- local wt_cd_file="/tmp/wt_cd_$$"
63
- rm -f "$wt_cd_file"
64
- WT_WRAPPER=1 WT_CD_FILE="$wt_cd_file" command wt "$@"
65
- local exit_code=$?
66
- if [[ -f "$wt_cd_file" ]]; then
67
- local dir=$(cat "$wt_cd_file")
68
- rm -f "$wt_cd_file"
69
- [[ -d "$dir" ]] && cd "$dir"
70
- fi
71
- return $exit_code
72
- }`,
73
- },
74
- fish: {
75
- name: 'Fish',
76
- rcFile: join(home, '.config/fish/config.fish'),
77
- wrapper: `
78
- # wt-cli: Git worktree manager shell integration
79
- function wt
80
- set -l wt_cd_file "/tmp/wt_cd_fish_$fish_pid"
81
- rm -f "$wt_cd_file"
82
- env WT_WRAPPER=1 WT_CD_FILE="$wt_cd_file" command wt $argv
83
- set -l exit_code $status
84
- if test -f "$wt_cd_file"
85
- set -l dir (cat "$wt_cd_file")
86
- rm -f "$wt_cd_file"
87
- test -d "$dir"; and cd "$dir"
88
- end
89
- return $exit_code
90
- end`,
91
- },
92
- };
93
-
94
- return configs[shell] || null;
95
- }
96
-
97
- export function isWrapperInstalled() {
98
- // Check if we're running through the wrapper
99
- // The wrapper would need to set this env var
100
- return process.env.WT_WRAPPER === '1';
101
- }
102
-
103
- export function checkWrapperInRcFile() {
104
- const config = getShellConfig();
105
- if (!config) return { installed: false, reason: 'unknown-shell' };
106
-
107
- if (!existsSync(config.rcFile)) {
108
- return { installed: false, reason: 'no-rc-file', rcFile: config.rcFile };
109
- }
110
-
111
- try {
112
- const content = readFileSync(config.rcFile, 'utf-8');
113
- if (content.includes('wt-cli') || content.includes('__WT_CD__')) {
114
- return { installed: true, rcFile: config.rcFile };
115
- }
116
- return { installed: false, reason: 'not-configured', rcFile: config.rcFile };
117
- } catch {
118
- return { installed: false, reason: 'read-error', rcFile: config.rcFile };
119
- }
120
- }
121
-
122
- export async function setupCommand() {
123
- showMiniLogo();
124
- heading(`${icons.sparkles} Shell Setup`);
125
-
126
- const shell = detectShell();
127
- const config = getShellConfig();
128
-
129
- if (shell === 'unknown' || !config) {
130
- warning(`Could not detect your shell type`);
131
- info(`SHELL environment variable: ${process.env.SHELL || 'not set'}`);
132
- spacer();
133
- console.log(` ${colors.muted('Please manually add the shell wrapper to your shell config.')}`);
134
- console.log(` ${colors.muted('See:')} ${colors.path('https://github.com/jhorst11/wt#shell-integration')}`);
135
- spacer();
136
- return;
137
- }
138
-
139
- success(`Detected shell: ${colors.primary(config.name)}`);
140
- info(`Config file: ${colors.path(config.rcFile)}`);
141
- spacer();
142
-
143
- const status = checkWrapperInRcFile();
144
-
145
- if (status.installed) {
146
- success(`Shell integration is already installed! ${icons.check}`);
147
- spacer();
148
- info(`If directory jumping isn't working, try restarting your terminal`);
149
- info(`or run: ${colors.primary(`source ${config.rcFile}`)}`);
150
- spacer();
151
- return;
152
- }
153
-
154
- // Not installed - offer to install
155
- divider();
156
- spacer();
157
-
158
- console.log(` ${colors.muted('To enable directory jumping (wt go, wt home), we need to')}`);
159
- console.log(` ${colors.muted('add a small shell function to your')} ${colors.path(config.rcFile)}`);
160
- spacer();
161
-
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
- });
183
-
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;
200
- }
201
- }
202
-
203
- async function autoInstall(config) {
204
- spacer();
205
-
206
- try {
207
- appendFileSync(config.rcFile, '\n' + config.wrapper + '\n');
208
- success(`Added shell integration to ${colors.path(config.rcFile)}`);
209
- spacer();
210
-
211
- console.log(` ${icons.rocket} ${colors.primary('Almost done!')} Run this to activate:`);
212
- spacer();
213
- console.log(` ${colors.secondary(`source ${config.rcFile}`)}`);
214
- spacer();
215
- console.log(` ${colors.muted('Or just restart your terminal.')}`);
216
- spacer();
217
- } catch (err) {
218
- error(`Failed to write to ${config.rcFile}`);
219
- error(err.message);
220
- spacer();
221
- showManualInstructions(config);
222
- }
223
- }
224
-
225
- function showManualInstructions(config) {
226
- spacer();
227
- console.log(` ${colors.muted('Add this to')} ${colors.path(config.rcFile)}${colors.muted(':')}`);
228
- spacer();
229
- divider();
230
- console.log(colors.secondary(config.wrapper));
231
- divider();
232
- spacer();
233
- console.log(` ${colors.muted('Then run:')} ${colors.primary(`source ${config.rcFile}`)}`);
234
- spacer();
235
- }
236
-
237
- // Helper to show a gentle nudge if wrapper isn't set up
238
- export function showCdHint(path) {
239
- // Check if we're running through the shell wrapper with a cd file
240
- const cdFile = process.env.WT_CD_FILE;
241
- if (cdFile && isWrapperInstalled()) {
242
- // Write path to temp file for shell wrapper to read
243
- try {
244
- writeFileSync(cdFile, path);
245
- } catch {
246
- // Fall through to show manual instructions
247
- }
248
- return;
249
- }
250
-
251
- // Fall back to checking rc file
252
- const status = checkWrapperInRcFile();
253
-
254
- if (status.installed) {
255
- // Wrapper is in rc file but not active - show path
256
- spacer();
257
- console.log(` ${icons.rocket} ${colors.muted('Switching to:')} ${colors.path(path)}`);
258
- spacer();
259
- } else {
260
- // No wrapper - show a friendly message instead
261
- spacer();
262
- console.log(` ${icons.rocket} ${colors.muted('Run:')} ${colors.primary(`cd "${path}"`)}`);
263
- spacer();
264
- console.log(` ${colors.muted(`Tip: Run`)} ${colors.secondary('wt setup')} ${colors.muted('to enable auto-navigation')}`);
265
- spacer();
266
- }
267
- }