@jhorst11/wt 1.0.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 ADDED
@@ -0,0 +1,351 @@
1
+ import { simpleGit } from 'simple-git';
2
+ import { homedir } from 'os';
3
+ import { join, basename, relative } from 'path';
4
+ 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
+ }
16
+
17
+ export async function getGit(cwd = process.cwd()) {
18
+ return simpleGit(cwd);
19
+ }
20
+
21
+ export async function isGitRepo(cwd = process.cwd()) {
22
+ try {
23
+ const git = await getGit(cwd);
24
+ await git.revparse(['--git-dir']);
25
+ return true;
26
+ } catch {
27
+ return false;
28
+ }
29
+ }
30
+
31
+ export async function getRepoRoot(cwd = process.cwd()) {
32
+ try {
33
+ const git = await getGit(cwd);
34
+ const root = await git.revparse(['--show-toplevel']);
35
+ return root.trim();
36
+ } catch {
37
+ return null;
38
+ }
39
+ }
40
+
41
+ export async function getCurrentBranch(cwd = process.cwd()) {
42
+ try {
43
+ const git = await getGit(cwd);
44
+ const branch = await git.revparse(['--abbrev-ref', 'HEAD']);
45
+ return branch.trim();
46
+ } catch {
47
+ return null;
48
+ }
49
+ }
50
+
51
+ export async function getLocalBranches(cwd = process.cwd()) {
52
+ try {
53
+ const git = await getGit(cwd);
54
+ const result = await git.branchLocal();
55
+ return result.all.map((name) => ({
56
+ name,
57
+ isCurrent: name === result.current,
58
+ }));
59
+ } catch {
60
+ return [];
61
+ }
62
+ }
63
+
64
+ export async function getRemoteBranches(cwd = process.cwd()) {
65
+ try {
66
+ const git = await getGit(cwd);
67
+ // Fetch latest from remote first
68
+ await git.fetch(['--all', '--prune']).catch(() => {});
69
+ const result = await git.branch(['-r']);
70
+ return result.all
71
+ .filter((name) => !name.includes('HEAD'))
72
+ .map((name) => ({
73
+ name: name.replace(/^origin\//, ''),
74
+ fullName: name,
75
+ isRemote: true,
76
+ }));
77
+ } catch {
78
+ return [];
79
+ }
80
+ }
81
+
82
+ export async function getAllBranches(cwd = process.cwd()) {
83
+ const [local, remote] = await Promise.all([
84
+ getLocalBranches(cwd),
85
+ getRemoteBranches(cwd),
86
+ ]);
87
+
88
+ // Merge and dedupe, preferring local branches
89
+ const localNames = new Set(local.map((b) => b.name));
90
+ const uniqueRemote = remote.filter((b) => !localNames.has(b.name));
91
+
92
+ return {
93
+ local,
94
+ remote: uniqueRemote,
95
+ all: [...local.map((b) => ({ ...b, type: 'local' })), ...uniqueRemote.map((b) => ({ ...b, type: 'remote' }))],
96
+ };
97
+ }
98
+
99
+ export function getWorktreesBase(repoRoot) {
100
+ const projectsDir = config.projectsDir.replace(/\/$/, '');
101
+ let repoRel;
102
+
103
+ if (repoRoot.startsWith(projectsDir + '/')) {
104
+ repoRel = repoRoot.slice(projectsDir.length + 1);
105
+ } else {
106
+ repoRel = basename(repoRoot);
107
+ }
108
+
109
+ return join(config.worktreesDir, repoRel);
110
+ }
111
+
112
+ export async function getWorktrees(cwd = process.cwd()) {
113
+ try {
114
+ const git = await getGit(cwd);
115
+ const result = await git.raw(['worktree', 'list', '--porcelain']);
116
+ const worktrees = [];
117
+ let current = {};
118
+
119
+ for (const line of result.split('\n')) {
120
+ if (line.startsWith('worktree ')) {
121
+ if (current.path) worktrees.push(current);
122
+ current = { path: line.slice(9) };
123
+ } else if (line.startsWith('branch ')) {
124
+ current.branch = line.slice(7).replace('refs/heads/', '');
125
+ } else if (line === 'bare') {
126
+ current.bare = true;
127
+ } else if (line === 'detached') {
128
+ current.detached = true;
129
+ }
130
+ }
131
+ if (current.path) worktrees.push(current);
132
+
133
+ // Add name (last part of path) and identify main vs worktrees
134
+ return worktrees.map((wt, index) => ({
135
+ ...wt,
136
+ name: basename(wt.path),
137
+ isMain: index === 0,
138
+ }));
139
+ } catch {
140
+ return [];
141
+ }
142
+ }
143
+
144
+ export async function getWorktreesInBase(repoRoot) {
145
+ const base = getWorktreesBase(repoRoot);
146
+ if (!existsSync(base)) return [];
147
+
148
+ try {
149
+ const entries = readdirSync(base);
150
+ const worktrees = [];
151
+
152
+ for (const entry of entries) {
153
+ const entryPath = join(base, entry);
154
+ if (statSync(entryPath).isDirectory()) {
155
+ // Check if it's a valid git worktree
156
+ const gitFile = join(entryPath, '.git');
157
+ if (existsSync(gitFile)) {
158
+ const branch = await getCurrentBranch(entryPath);
159
+ worktrees.push({
160
+ name: entry,
161
+ path: entryPath,
162
+ branch: branch || 'unknown',
163
+ });
164
+ }
165
+ }
166
+ }
167
+
168
+ return worktrees;
169
+ } catch {
170
+ return [];
171
+ }
172
+ }
173
+
174
+ export async function getMainRepoPath(cwd = process.cwd()) {
175
+ try {
176
+ const git = await getGit(cwd);
177
+ const result = await git.raw(['worktree', 'list', '--porcelain']);
178
+ const firstLine = result.split('\n').find((l) => l.startsWith('worktree '));
179
+ return firstLine ? firstLine.slice(9) : null;
180
+ } catch {
181
+ return null;
182
+ }
183
+ }
184
+
185
+ export function buildBranchName(leaf, prefix = config.branchPrefix) {
186
+ const cleanLeaf = leaf.replace(/^\//, '').replace(/ /g, '-');
187
+ if (prefix) {
188
+ return `${prefix.replace(/\/$/, '')}/${cleanLeaf}`;
189
+ }
190
+ return cleanLeaf;
191
+ }
192
+
193
+ export async function branchExistsLocal(branchName, cwd = process.cwd()) {
194
+ try {
195
+ const git = await getGit(cwd);
196
+ await git.raw(['show-ref', '--verify', '--quiet', `refs/heads/${branchName}`]);
197
+ return true;
198
+ } catch {
199
+ return false;
200
+ }
201
+ }
202
+
203
+ export async function branchExistsRemote(branchName, cwd = process.cwd()) {
204
+ try {
205
+ const git = await getGit(cwd);
206
+ const result = await git.raw(['ls-remote', '--exit-code', '--heads', 'origin', branchName]);
207
+ return result.length > 0;
208
+ } catch {
209
+ return false;
210
+ }
211
+ }
212
+
213
+ export async function ensureBranch(branchName, baseBranch = null, cwd = process.cwd()) {
214
+ const git = await getGit(cwd);
215
+
216
+ // Check if branch exists locally
217
+ if (await branchExistsLocal(branchName, cwd)) {
218
+ return { created: false, source: 'local' };
219
+ }
220
+
221
+ // Check if branch exists on remote
222
+ if (await branchExistsRemote(branchName, cwd)) {
223
+ await git.fetch(['origin', `${branchName}:${branchName}`]);
224
+ return { created: false, source: 'remote' };
225
+ }
226
+
227
+ // Create new branch from base
228
+ if (baseBranch) {
229
+ // If baseBranch is remote, make sure we have it locally
230
+ if (baseBranch.startsWith('origin/')) {
231
+ const localName = baseBranch.replace('origin/', '');
232
+ await git.fetch(['origin', localName]).catch(() => {});
233
+ }
234
+ await git.branch([branchName, baseBranch]);
235
+ } else {
236
+ await git.branch([branchName]);
237
+ }
238
+
239
+ return { created: true, source: 'new' };
240
+ }
241
+
242
+ export async function createWorktree(name, branchName, baseBranch = null, cwd = process.cwd()) {
243
+ const git = await getGit(cwd);
244
+ const repoRoot = await getRepoRoot(cwd);
245
+ const worktreesBase = getWorktreesBase(repoRoot);
246
+
247
+ // Ensure worktrees directory exists
248
+ if (!existsSync(worktreesBase)) {
249
+ mkdirSync(worktreesBase, { recursive: true });
250
+ }
251
+
252
+ const worktreePath = join(worktreesBase, name);
253
+
254
+ // Check if worktree already exists
255
+ if (existsSync(worktreePath)) {
256
+ return { success: false, error: 'Worktree directory already exists', path: worktreePath };
257
+ }
258
+
259
+ // Fetch all remotes
260
+ await git.fetch(['--all', '--prune']).catch(() => {});
261
+
262
+ // Ensure branch exists
263
+ const branchResult = await ensureBranch(branchName, baseBranch, cwd);
264
+
265
+ // Create worktree
266
+ await git.raw(['worktree', 'add', worktreePath, branchName]);
267
+
268
+ return {
269
+ success: true,
270
+ path: worktreePath,
271
+ branch: branchName,
272
+ branchCreated: branchResult.created,
273
+ branchSource: branchResult.source,
274
+ };
275
+ }
276
+
277
+ export async function removeWorktree(path, force = false, cwd = process.cwd()) {
278
+ const git = await getGit(cwd);
279
+ const args = ['worktree', 'remove'];
280
+ if (force) args.push('--force');
281
+ args.push(path);
282
+
283
+ await git.raw(args);
284
+ return { success: true };
285
+ }
286
+
287
+ export async function pruneWorktrees(cwd = process.cwd()) {
288
+ const git = await getGit(cwd);
289
+ await git.raw(['worktree', 'prune']);
290
+ return { success: true };
291
+ }
292
+
293
+ export function isValidBranchName(name) {
294
+ // Basic validation - git allows most characters but not some special ones
295
+ if (!name || name.length === 0) return false;
296
+ if (name.startsWith('-') || name.startsWith('.')) return false;
297
+ if (name.endsWith('/') || name.endsWith('.')) return false;
298
+ if (name.includes('..') || name.includes('//')) return false;
299
+ if (/[\s~^:?*\[\]\\]/.test(name)) return false;
300
+ return true;
301
+ }
302
+
303
+ export async function mergeBranch(sourceBranch, targetBranch = null, cwd = process.cwd()) {
304
+ const git = await getGit(cwd);
305
+
306
+ // If target specified, checkout to it first
307
+ if (targetBranch) {
308
+ await git.checkout(targetBranch);
309
+ }
310
+
311
+ // Merge the source branch
312
+ const result = await git.merge([sourceBranch, '--no-edit']);
313
+
314
+ return {
315
+ success: true,
316
+ merged: sourceBranch,
317
+ into: targetBranch || await getCurrentBranch(cwd),
318
+ result,
319
+ };
320
+ }
321
+
322
+ export async function getMainBranch(cwd = process.cwd()) {
323
+ const git = await getGit(cwd);
324
+
325
+ // Try common main branch names
326
+ const candidates = ['main', 'master', 'develop'];
327
+ const branches = await getLocalBranches(cwd);
328
+ const branchNames = branches.map(b => b.name);
329
+
330
+ for (const candidate of candidates) {
331
+ if (branchNames.includes(candidate)) {
332
+ return candidate;
333
+ }
334
+ }
335
+
336
+ // Fall back to first branch
337
+ return branchNames[0] || 'main';
338
+ }
339
+
340
+ export async function hasUncommittedChanges(cwd = process.cwd()) {
341
+ const git = await getGit(cwd);
342
+ const status = await git.status();
343
+ return !status.isClean();
344
+ }
345
+
346
+ export async function deleteBranch(branchName, force = false, cwd = process.cwd()) {
347
+ const git = await getGit(cwd);
348
+ const flag = force ? '-D' : '-d';
349
+ await git.branch([flag, branchName]);
350
+ return { success: true };
351
+ }
package/src/setup.js ADDED
@@ -0,0 +1,256 @@
1
+ import { select, confirm } from '@inquirer/prompts';
2
+ import { homedir } from 'os';
3
+ import { existsSync, readFileSync, appendFileSync, writeFileSync } from 'fs';
4
+ import { join } from 'path';
5
+ import {
6
+ showMiniLogo,
7
+ success,
8
+ error,
9
+ warning,
10
+ info,
11
+ heading,
12
+ spacer,
13
+ colors,
14
+ icons,
15
+ divider,
16
+ } from './ui.js';
17
+
18
+ // Shell detection
19
+ export function detectShell() {
20
+ const shell = process.env.SHELL || '';
21
+
22
+ if (shell.includes('zsh')) return 'zsh';
23
+ if (shell.includes('bash')) return 'bash';
24
+ if (shell.includes('fish')) return 'fish';
25
+ if (process.env.FISH_VERSION) return 'fish';
26
+ if (process.env.ZSH_VERSION) return 'zsh';
27
+ if (process.env.BASH_VERSION) return 'bash';
28
+
29
+ return 'unknown';
30
+ }
31
+
32
+ export function getShellConfig() {
33
+ const shell = detectShell();
34
+ const home = homedir();
35
+
36
+ const configs = {
37
+ zsh: {
38
+ name: 'Zsh',
39
+ rcFile: join(home, '.zshrc'),
40
+ wrapper: `
41
+ # wt-cli: Git worktree manager shell integration
42
+ wt() {
43
+ local wt_cd_file="/tmp/wt_cd_$$"
44
+ rm -f "$wt_cd_file"
45
+ WT_WRAPPER=1 WT_CD_FILE="$wt_cd_file" command wt "$@"
46
+ local exit_code=$?
47
+ if [[ -f "$wt_cd_file" ]]; then
48
+ local dir=$(cat "$wt_cd_file")
49
+ rm -f "$wt_cd_file"
50
+ [[ -d "$dir" ]] && cd "$dir"
51
+ fi
52
+ return $exit_code
53
+ }`,
54
+ },
55
+ bash: {
56
+ name: 'Bash',
57
+ rcFile: join(home, '.bashrc'),
58
+ wrapper: `
59
+ # wt-cli: Git worktree manager shell integration
60
+ wt() {
61
+ local wt_cd_file="/tmp/wt_cd_$$"
62
+ rm -f "$wt_cd_file"
63
+ WT_WRAPPER=1 WT_CD_FILE="$wt_cd_file" command wt "$@"
64
+ local exit_code=$?
65
+ if [[ -f "$wt_cd_file" ]]; then
66
+ local dir=$(cat "$wt_cd_file")
67
+ rm -f "$wt_cd_file"
68
+ [[ -d "$dir" ]] && cd "$dir"
69
+ fi
70
+ return $exit_code
71
+ }`,
72
+ },
73
+ fish: {
74
+ name: 'Fish',
75
+ rcFile: join(home, '.config/fish/config.fish'),
76
+ wrapper: `
77
+ # wt-cli: Git worktree manager shell integration
78
+ function wt
79
+ set -l wt_cd_file "/tmp/wt_cd_fish_$fish_pid"
80
+ rm -f "$wt_cd_file"
81
+ env WT_WRAPPER=1 WT_CD_FILE="$wt_cd_file" command wt $argv
82
+ set -l exit_code $status
83
+ if test -f "$wt_cd_file"
84
+ set -l dir (cat "$wt_cd_file")
85
+ rm -f "$wt_cd_file"
86
+ test -d "$dir"; and cd "$dir"
87
+ end
88
+ return $exit_code
89
+ end`,
90
+ },
91
+ };
92
+
93
+ return configs[shell] || null;
94
+ }
95
+
96
+ export function isWrapperInstalled() {
97
+ // Check if we're running through the wrapper
98
+ // The wrapper would need to set this env var
99
+ return process.env.WT_WRAPPER === '1';
100
+ }
101
+
102
+ export function checkWrapperInRcFile() {
103
+ const config = getShellConfig();
104
+ if (!config) return { installed: false, reason: 'unknown-shell' };
105
+
106
+ if (!existsSync(config.rcFile)) {
107
+ return { installed: false, reason: 'no-rc-file', rcFile: config.rcFile };
108
+ }
109
+
110
+ try {
111
+ const content = readFileSync(config.rcFile, 'utf-8');
112
+ if (content.includes('wt-cli') || content.includes('__WT_CD__')) {
113
+ return { installed: true, rcFile: config.rcFile };
114
+ }
115
+ return { installed: false, reason: 'not-configured', rcFile: config.rcFile };
116
+ } catch {
117
+ return { installed: false, reason: 'read-error', rcFile: config.rcFile };
118
+ }
119
+ }
120
+
121
+ export async function setupCommand() {
122
+ showMiniLogo();
123
+ heading(`${icons.sparkles} Shell Setup`);
124
+
125
+ const shell = detectShell();
126
+ const config = getShellConfig();
127
+
128
+ if (shell === 'unknown' || !config) {
129
+ warning(`Could not detect your shell type`);
130
+ info(`SHELL environment variable: ${process.env.SHELL || 'not set'}`);
131
+ spacer();
132
+ 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
+ spacer();
135
+ return;
136
+ }
137
+
138
+ success(`Detected shell: ${colors.primary(config.name)}`);
139
+ info(`Config file: ${colors.path(config.rcFile)}`);
140
+ spacer();
141
+
142
+ const status = checkWrapperInRcFile();
143
+
144
+ if (status.installed) {
145
+ success(`Shell integration is already installed! ${icons.check}`);
146
+ spacer();
147
+ info(`If directory jumping isn't working, try restarting your terminal`);
148
+ info(`or run: ${colors.primary(`source ${config.rcFile}`)}`);
149
+ spacer();
150
+ return;
151
+ }
152
+
153
+ // Not installed - offer to install
154
+ divider();
155
+ spacer();
156
+
157
+ console.log(` ${colors.muted('To enable directory jumping (wt go, wt home), we need to')}`);
158
+ console.log(` ${colors.muted('add a small shell function to your')} ${colors.path(config.rcFile)}`);
159
+ spacer();
160
+
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
+ });
181
+
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();
189
+ }
190
+ }
191
+
192
+ async function autoInstall(config) {
193
+ spacer();
194
+
195
+ try {
196
+ appendFileSync(config.rcFile, '\n' + config.wrapper + '\n');
197
+ success(`Added shell integration to ${colors.path(config.rcFile)}`);
198
+ spacer();
199
+
200
+ console.log(` ${icons.rocket} ${colors.primary('Almost done!')} Run this to activate:`);
201
+ spacer();
202
+ console.log(` ${colors.secondary(`source ${config.rcFile}`)}`);
203
+ spacer();
204
+ console.log(` ${colors.muted('Or just restart your terminal.')}`);
205
+ spacer();
206
+ } catch (err) {
207
+ error(`Failed to write to ${config.rcFile}`);
208
+ error(err.message);
209
+ spacer();
210
+ showManualInstructions(config);
211
+ }
212
+ }
213
+
214
+ function showManualInstructions(config) {
215
+ spacer();
216
+ console.log(` ${colors.muted('Add this to')} ${colors.path(config.rcFile)}${colors.muted(':')}`);
217
+ spacer();
218
+ divider();
219
+ console.log(colors.secondary(config.wrapper));
220
+ divider();
221
+ spacer();
222
+ console.log(` ${colors.muted('Then run:')} ${colors.primary(`source ${config.rcFile}`)}`);
223
+ spacer();
224
+ }
225
+
226
+ // Helper to show a gentle nudge if wrapper isn't set up
227
+ export function showCdHint(path) {
228
+ // Check if we're running through the shell wrapper with a cd file
229
+ const cdFile = process.env.WT_CD_FILE;
230
+ if (cdFile && isWrapperInstalled()) {
231
+ // Write path to temp file for shell wrapper to read
232
+ try {
233
+ writeFileSync(cdFile, path);
234
+ } catch {
235
+ // Fall through to show manual instructions
236
+ }
237
+ return;
238
+ }
239
+
240
+ // Fall back to checking rc file
241
+ const status = checkWrapperInRcFile();
242
+
243
+ if (status.installed) {
244
+ // Wrapper is in rc file but not active - show path
245
+ spacer();
246
+ console.log(` ${icons.rocket} ${colors.muted('Switching to:')} ${colors.path(path)}`);
247
+ spacer();
248
+ } else {
249
+ // No wrapper - show a friendly message instead
250
+ spacer();
251
+ console.log(` ${icons.rocket} ${colors.muted('Run:')} ${colors.primary(`cd "${path}"`)}`);
252
+ spacer();
253
+ console.log(` ${colors.muted(`Tip: Run`)} ${colors.secondary('wt setup')} ${colors.muted('to enable auto-navigation')}`);
254
+ spacer();
255
+ }
256
+ }