cluttry 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/.vwt.json +12 -0
- package/LICENSE +21 -0
- package/README.md +444 -0
- package/dist/commands/doctor.d.ts +7 -0
- package/dist/commands/doctor.d.ts.map +1 -0
- package/dist/commands/doctor.js +198 -0
- package/dist/commands/doctor.js.map +1 -0
- package/dist/commands/init.d.ts +11 -0
- package/dist/commands/init.d.ts.map +1 -0
- package/dist/commands/init.js +90 -0
- package/dist/commands/init.js.map +1 -0
- package/dist/commands/list.d.ts +11 -0
- package/dist/commands/list.d.ts.map +1 -0
- package/dist/commands/list.js +106 -0
- package/dist/commands/list.js.map +1 -0
- package/dist/commands/open.d.ts +11 -0
- package/dist/commands/open.d.ts.map +1 -0
- package/dist/commands/open.js +52 -0
- package/dist/commands/open.js.map +1 -0
- package/dist/commands/prune.d.ts +7 -0
- package/dist/commands/prune.d.ts.map +1 -0
- package/dist/commands/prune.js +33 -0
- package/dist/commands/prune.js.map +1 -0
- package/dist/commands/rm.d.ts +13 -0
- package/dist/commands/rm.d.ts.map +1 -0
- package/dist/commands/rm.js +99 -0
- package/dist/commands/rm.js.map +1 -0
- package/dist/commands/spawn.d.ts +17 -0
- package/dist/commands/spawn.d.ts.map +1 -0
- package/dist/commands/spawn.js +127 -0
- package/dist/commands/spawn.js.map +1 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +101 -0
- package/dist/index.js.map +1 -0
- package/dist/lib/config.d.ts +44 -0
- package/dist/lib/config.d.ts.map +1 -0
- package/dist/lib/config.js +109 -0
- package/dist/lib/config.js.map +1 -0
- package/dist/lib/git.d.ts +73 -0
- package/dist/lib/git.d.ts.map +1 -0
- package/dist/lib/git.js +225 -0
- package/dist/lib/git.js.map +1 -0
- package/dist/lib/output.d.ts +33 -0
- package/dist/lib/output.d.ts.map +1 -0
- package/dist/lib/output.js +83 -0
- package/dist/lib/output.js.map +1 -0
- package/dist/lib/paths.d.ts +36 -0
- package/dist/lib/paths.d.ts.map +1 -0
- package/dist/lib/paths.js +84 -0
- package/dist/lib/paths.js.map +1 -0
- package/dist/lib/secrets.d.ts +50 -0
- package/dist/lib/secrets.d.ts.map +1 -0
- package/dist/lib/secrets.js +146 -0
- package/dist/lib/secrets.js.map +1 -0
- package/dist/lib/types.d.ts +63 -0
- package/dist/lib/types.d.ts.map +1 -0
- package/dist/lib/types.js +5 -0
- package/dist/lib/types.js.map +1 -0
- package/package.json +41 -0
- package/src/commands/doctor.ts +222 -0
- package/src/commands/init.ts +120 -0
- package/src/commands/list.ts +133 -0
- package/src/commands/open.ts +70 -0
- package/src/commands/prune.ts +36 -0
- package/src/commands/rm.ts +125 -0
- package/src/commands/spawn.ts +169 -0
- package/src/index.ts +112 -0
- package/src/lib/config.ts +120 -0
- package/src/lib/git.ts +243 -0
- package/src/lib/output.ts +102 -0
- package/src/lib/paths.ts +108 -0
- package/src/lib/secrets.ts +193 -0
- package/src/lib/types.ts +69 -0
- package/tests/config.test.ts +102 -0
- package/tests/paths.test.ts +155 -0
- package/tests/secrets.test.ts +150 -0
- package/tsconfig.json +20 -0
- package/vitest.config.ts +15 -0
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* cry spawn command
|
|
3
|
+
*
|
|
4
|
+
* Create a worktree for a branch with optional secrets handling and hooks.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { existsSync } from 'node:fs';
|
|
8
|
+
import path from 'node:path';
|
|
9
|
+
import {
|
|
10
|
+
isGitRepo,
|
|
11
|
+
getRepoRoot,
|
|
12
|
+
getRepoName,
|
|
13
|
+
branchExists,
|
|
14
|
+
addWorktree,
|
|
15
|
+
listWorktrees,
|
|
16
|
+
runCommand,
|
|
17
|
+
commandExists,
|
|
18
|
+
} from '../lib/git.js';
|
|
19
|
+
import { getMergedConfig, configExists } from '../lib/config.js';
|
|
20
|
+
import { getDefaultWorktreePath } from '../lib/paths.js';
|
|
21
|
+
import { processSecrets } from '../lib/secrets.js';
|
|
22
|
+
import * as out from '../lib/output.js';
|
|
23
|
+
import type { SecretMode } from '../lib/types.js';
|
|
24
|
+
|
|
25
|
+
interface SpawnOptions {
|
|
26
|
+
new?: boolean;
|
|
27
|
+
path?: string;
|
|
28
|
+
base?: string;
|
|
29
|
+
mode?: SecretMode;
|
|
30
|
+
run?: string;
|
|
31
|
+
agent?: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export async function spawn(branch: string, options: SpawnOptions): Promise<void> {
|
|
35
|
+
// Check if we're in a git repo
|
|
36
|
+
if (!isGitRepo()) {
|
|
37
|
+
out.error('Not a git repository. Run this command from within a git repo.');
|
|
38
|
+
process.exit(1);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const repoRoot = getRepoRoot();
|
|
42
|
+
const repoName = getRepoName();
|
|
43
|
+
|
|
44
|
+
// Load config
|
|
45
|
+
const config = configExists(repoRoot) ? getMergedConfig(repoRoot) : {
|
|
46
|
+
worktreeBaseDir: undefined,
|
|
47
|
+
defaultMode: 'none' as SecretMode,
|
|
48
|
+
include: [],
|
|
49
|
+
hooks: { postCreate: [] },
|
|
50
|
+
agentCommand: 'claude',
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
// Determine mode
|
|
54
|
+
const mode: SecretMode = options.mode ?? config.defaultMode;
|
|
55
|
+
|
|
56
|
+
// Calculate worktree path
|
|
57
|
+
const worktreePath = getDefaultWorktreePath(repoRoot, branch, {
|
|
58
|
+
explicitPath: options.path,
|
|
59
|
+
baseDir: options.base ?? config.worktreeBaseDir,
|
|
60
|
+
repoName,
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
// Check if destination already exists
|
|
64
|
+
if (existsSync(worktreePath)) {
|
|
65
|
+
out.error(`Destination already exists: ${worktreePath}`);
|
|
66
|
+
out.info('Remove it first or choose a different path with --path');
|
|
67
|
+
process.exit(1);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Check if worktree already exists for this branch
|
|
71
|
+
const existingWorktrees = listWorktrees(repoRoot);
|
|
72
|
+
const existingForBranch = existingWorktrees.find((w) => w.branch === branch);
|
|
73
|
+
if (existingForBranch) {
|
|
74
|
+
out.error(`A worktree already exists for branch '${branch}'`);
|
|
75
|
+
out.info(`Path: ${existingForBranch.worktree}`);
|
|
76
|
+
out.info('Remove it first with: cry rm ' + branch);
|
|
77
|
+
process.exit(1);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Determine if we need to create the branch
|
|
81
|
+
const needsNewBranch = options.new || !branchExists(branch, repoRoot);
|
|
82
|
+
|
|
83
|
+
out.header('Creating worktree');
|
|
84
|
+
out.log(` Branch: ${out.fmt.branch(branch)}${needsNewBranch ? out.fmt.gray(' (new)') : ''}`);
|
|
85
|
+
out.log(` Path: ${out.fmt.path(worktreePath)}`);
|
|
86
|
+
out.log(` Mode: ${out.fmt.cyan(mode)}`);
|
|
87
|
+
out.newline();
|
|
88
|
+
|
|
89
|
+
// Create the worktree
|
|
90
|
+
try {
|
|
91
|
+
addWorktree(worktreePath, branch, needsNewBranch, repoRoot);
|
|
92
|
+
out.success('Worktree created');
|
|
93
|
+
} catch (error) {
|
|
94
|
+
out.error(`Failed to create worktree: ${(error as Error).message}`);
|
|
95
|
+
process.exit(1);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Handle secrets
|
|
99
|
+
if (mode !== 'none' && config.include.length > 0) {
|
|
100
|
+
out.newline();
|
|
101
|
+
out.log(`Processing secrets (${mode} mode)...`);
|
|
102
|
+
|
|
103
|
+
const { processed, skipped } = await processSecrets(
|
|
104
|
+
mode,
|
|
105
|
+
config.include,
|
|
106
|
+
repoRoot,
|
|
107
|
+
worktreePath
|
|
108
|
+
);
|
|
109
|
+
|
|
110
|
+
if (processed.length > 0) {
|
|
111
|
+
out.success(`${mode === 'copy' ? 'Copied' : 'Symlinked'} ${processed.length} file(s):`);
|
|
112
|
+
for (const file of processed) {
|
|
113
|
+
out.log(` ${out.fmt.dim('•')} ${file}`);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
if (skipped.length > 0) {
|
|
118
|
+
out.warn(`Skipped ${skipped.length} file(s) for safety:`);
|
|
119
|
+
for (const file of skipped) {
|
|
120
|
+
out.log(` ${out.fmt.dim('•')} ${file.path}: ${file.reason}`);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Run post-create hooks from config
|
|
126
|
+
const hooks = config.hooks.postCreate;
|
|
127
|
+
if (hooks.length > 0) {
|
|
128
|
+
out.newline();
|
|
129
|
+
out.log('Running post-create hooks...');
|
|
130
|
+
for (const hook of hooks) {
|
|
131
|
+
out.log(` ${out.fmt.dim('$')} ${hook}`);
|
|
132
|
+
const code = await runCommand(hook, worktreePath);
|
|
133
|
+
if (code !== 0) {
|
|
134
|
+
out.warn(`Hook exited with code ${code}`);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Run --run command if provided
|
|
140
|
+
if (options.run) {
|
|
141
|
+
out.newline();
|
|
142
|
+
out.log('Running custom command...');
|
|
143
|
+
out.log(` ${out.fmt.dim('$')} ${options.run}`);
|
|
144
|
+
const code = await runCommand(options.run, worktreePath);
|
|
145
|
+
if (code !== 0) {
|
|
146
|
+
out.warn(`Command exited with code ${code}`);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// Handle agent launch
|
|
151
|
+
const agentChoice = options.agent ?? 'none';
|
|
152
|
+
if (agentChoice === 'claude') {
|
|
153
|
+
const agentCmd = config.agentCommand;
|
|
154
|
+
out.newline();
|
|
155
|
+
|
|
156
|
+
if (commandExists(agentCmd)) {
|
|
157
|
+
out.log(`Launching ${agentCmd}...`);
|
|
158
|
+
await runCommand(agentCmd, worktreePath);
|
|
159
|
+
} else {
|
|
160
|
+
out.warn(`Agent command '${agentCmd}' not found.`);
|
|
161
|
+
out.info('Install Claude Code: https://docs.anthropic.com/claude-code');
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// Final summary
|
|
166
|
+
out.newline();
|
|
167
|
+
out.header('Worktree ready');
|
|
168
|
+
out.log(` ${out.fmt.dim('cd')} ${worktreePath}`);
|
|
169
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* cry - Git worktrees made painless for vibecoders
|
|
4
|
+
*
|
|
5
|
+
* A CLI tool for managing git worktrees with parallel AI-agent sessions in mind.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { Command } from 'commander';
|
|
9
|
+
import { init } from './commands/init.js';
|
|
10
|
+
import { spawn } from './commands/spawn.js';
|
|
11
|
+
import { list } from './commands/list.js';
|
|
12
|
+
import { open } from './commands/open.js';
|
|
13
|
+
import { rm } from './commands/rm.js';
|
|
14
|
+
import { prune } from './commands/prune.js';
|
|
15
|
+
import { doctor } from './commands/doctor.js';
|
|
16
|
+
import type { SecretMode } from './lib/types.js';
|
|
17
|
+
|
|
18
|
+
const program = new Command();
|
|
19
|
+
|
|
20
|
+
program
|
|
21
|
+
.name('cry')
|
|
22
|
+
.description('Git worktrees made painless for vibecoders running parallel AI-agent sessions')
|
|
23
|
+
.version('1.0.0');
|
|
24
|
+
|
|
25
|
+
// cry init
|
|
26
|
+
program
|
|
27
|
+
.command('init')
|
|
28
|
+
.description('Initialize cry configuration in the current repository')
|
|
29
|
+
.option('-f, --force', 'Overwrite existing configuration')
|
|
30
|
+
.action(async (options) => {
|
|
31
|
+
await init({ force: options.force });
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
// cry spawn <branch>
|
|
35
|
+
program
|
|
36
|
+
.command('spawn <branch>')
|
|
37
|
+
.description('Create a worktree for a branch')
|
|
38
|
+
.option('-n, --new', 'Create a new branch (equivalent to git worktree add -b)')
|
|
39
|
+
.option('-p, --path <dir>', 'Explicit path for the worktree')
|
|
40
|
+
.option('-b, --base <dir>', 'Base directory for worktrees')
|
|
41
|
+
.option('-m, --mode <mode>', 'Secret handling mode: copy, symlink, or none', 'copy')
|
|
42
|
+
.option('-r, --run <cmd>', 'Command to run after creating worktree')
|
|
43
|
+
.option('-a, --agent <agent>', 'Launch agent after setup: claude or none', 'none')
|
|
44
|
+
.action(async (branch: string, options) => {
|
|
45
|
+
const mode = options.mode as SecretMode;
|
|
46
|
+
if (!['copy', 'symlink', 'none'].includes(mode)) {
|
|
47
|
+
console.error(`Invalid mode: ${mode}. Must be 'copy', 'symlink', or 'none'.`);
|
|
48
|
+
process.exit(1);
|
|
49
|
+
}
|
|
50
|
+
await spawn(branch, {
|
|
51
|
+
new: options.new,
|
|
52
|
+
path: options.path,
|
|
53
|
+
base: options.base,
|
|
54
|
+
mode,
|
|
55
|
+
run: options.run,
|
|
56
|
+
agent: options.agent,
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
// cry list
|
|
61
|
+
program
|
|
62
|
+
.command('list')
|
|
63
|
+
.alias('ls')
|
|
64
|
+
.description('List all worktrees with their status')
|
|
65
|
+
.option('-j, --json', 'Output as JSON')
|
|
66
|
+
.action(async (options) => {
|
|
67
|
+
await list({ json: options.json });
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
// cry open <branch-or-path>
|
|
71
|
+
program
|
|
72
|
+
.command('open <branch-or-path>')
|
|
73
|
+
.description('Open or navigate to a worktree by branch name or path')
|
|
74
|
+
.option('-c, --cmd <cmd>', 'Command to execute in the worktree directory')
|
|
75
|
+
.action(async (branchOrPath: string, options) => {
|
|
76
|
+
await open(branchOrPath, { cmd: options.cmd });
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
// cry rm <branch-or-path>
|
|
80
|
+
program
|
|
81
|
+
.command('rm <branch-or-path>')
|
|
82
|
+
.alias('remove')
|
|
83
|
+
.description('Remove a worktree safely')
|
|
84
|
+
.option('-b, --with-branch', 'Also delete the branch')
|
|
85
|
+
.option('-f, --force', 'Force removal even if dirty')
|
|
86
|
+
.option('-y, --yes', 'Skip confirmation prompts')
|
|
87
|
+
.action(async (branchOrPath: string, options) => {
|
|
88
|
+
await rm(branchOrPath, {
|
|
89
|
+
withBranch: options.withBranch,
|
|
90
|
+
force: options.force,
|
|
91
|
+
yes: options.yes,
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
// cry prune
|
|
96
|
+
program
|
|
97
|
+
.command('prune')
|
|
98
|
+
.description('Clean up stale worktree references')
|
|
99
|
+
.action(async () => {
|
|
100
|
+
await prune();
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
// cry doctor
|
|
104
|
+
program
|
|
105
|
+
.command('doctor')
|
|
106
|
+
.description('Check and diagnose cry configuration and setup')
|
|
107
|
+
.action(async () => {
|
|
108
|
+
await doctor();
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
// Parse and execute
|
|
112
|
+
program.parse();
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Configuration management for VWT
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
import type { MergedConfig, VwtConfig, VwtLocalConfig } from './types.js';
|
|
8
|
+
|
|
9
|
+
export const CONFIG_FILE = '.vwt.json';
|
|
10
|
+
export const LOCAL_CONFIG_FILE = '.vwt.local.json';
|
|
11
|
+
export const WORKTREE_INCLUDE_FILE = '.worktreeinclude';
|
|
12
|
+
|
|
13
|
+
const DEFAULT_CONFIG: VwtConfig = {
|
|
14
|
+
defaultMode: 'copy',
|
|
15
|
+
include: ['.env', '.env.*', '.env.local'],
|
|
16
|
+
hooks: {
|
|
17
|
+
postCreate: [],
|
|
18
|
+
},
|
|
19
|
+
agentCommand: 'claude',
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Load the main config file
|
|
24
|
+
*/
|
|
25
|
+
export function loadConfig(repoRoot: string): VwtConfig | null {
|
|
26
|
+
const configPath = path.join(repoRoot, CONFIG_FILE);
|
|
27
|
+
if (!existsSync(configPath)) {
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
try {
|
|
31
|
+
const content = readFileSync(configPath, 'utf-8');
|
|
32
|
+
return JSON.parse(content) as VwtConfig;
|
|
33
|
+
} catch (error) {
|
|
34
|
+
throw new Error(`Failed to parse ${CONFIG_FILE}: ${(error as Error).message}`);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Load the local config file
|
|
40
|
+
*/
|
|
41
|
+
export function loadLocalConfig(repoRoot: string): VwtLocalConfig | null {
|
|
42
|
+
const configPath = path.join(repoRoot, LOCAL_CONFIG_FILE);
|
|
43
|
+
if (!existsSync(configPath)) {
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
try {
|
|
47
|
+
const content = readFileSync(configPath, 'utf-8');
|
|
48
|
+
return JSON.parse(content) as VwtLocalConfig;
|
|
49
|
+
} catch (error) {
|
|
50
|
+
throw new Error(`Failed to parse ${LOCAL_CONFIG_FILE}: ${(error as Error).message}`);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Merge main config with local overrides
|
|
56
|
+
*/
|
|
57
|
+
export function mergeConfig(config: VwtConfig | null, localConfig: VwtLocalConfig | null): MergedConfig {
|
|
58
|
+
const base = config ?? DEFAULT_CONFIG;
|
|
59
|
+
|
|
60
|
+
return {
|
|
61
|
+
worktreeBaseDir: localConfig?.worktreeBaseDir ?? base.worktreeBaseDir,
|
|
62
|
+
defaultMode: base.defaultMode,
|
|
63
|
+
include: [...(base.include ?? []), ...(localConfig?.include ?? [])],
|
|
64
|
+
hooks: {
|
|
65
|
+
postCreate: [
|
|
66
|
+
...(base.hooks?.postCreate ?? []),
|
|
67
|
+
...(localConfig?.hooks?.postCreate ?? []),
|
|
68
|
+
],
|
|
69
|
+
},
|
|
70
|
+
agentCommand: localConfig?.agentCommand ?? base.agentCommand ?? 'claude',
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Get merged configuration
|
|
76
|
+
*/
|
|
77
|
+
export function getMergedConfig(repoRoot: string): MergedConfig {
|
|
78
|
+
const config = loadConfig(repoRoot);
|
|
79
|
+
const localConfig = loadLocalConfig(repoRoot);
|
|
80
|
+
return mergeConfig(config, localConfig);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Save the main config file
|
|
85
|
+
*/
|
|
86
|
+
export function saveConfig(repoRoot: string, config: VwtConfig): void {
|
|
87
|
+
const configPath = path.join(repoRoot, CONFIG_FILE);
|
|
88
|
+
writeFileSync(configPath, JSON.stringify(config, null, 2) + '\n');
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Save the local config file
|
|
93
|
+
*/
|
|
94
|
+
export function saveLocalConfig(repoRoot: string, config: VwtLocalConfig): void {
|
|
95
|
+
const configPath = path.join(repoRoot, LOCAL_CONFIG_FILE);
|
|
96
|
+
writeFileSync(configPath, JSON.stringify(config, null, 2) + '\n');
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Check if config exists
|
|
101
|
+
*/
|
|
102
|
+
export function configExists(repoRoot: string): boolean {
|
|
103
|
+
return existsSync(path.join(repoRoot, CONFIG_FILE));
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Get default config
|
|
108
|
+
*/
|
|
109
|
+
export function getDefaultConfig(): VwtConfig {
|
|
110
|
+
return { ...DEFAULT_CONFIG };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Create default local config
|
|
115
|
+
*/
|
|
116
|
+
export function getDefaultLocalConfig(): VwtLocalConfig {
|
|
117
|
+
return {
|
|
118
|
+
include: [],
|
|
119
|
+
};
|
|
120
|
+
}
|
package/src/lib/git.ts
ADDED
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Git operations for VWT
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { execSync, spawn } from 'node:child_process';
|
|
6
|
+
import { existsSync } from 'node:fs';
|
|
7
|
+
import path from 'node:path';
|
|
8
|
+
import type { WorktreeInfo } from './types.js';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Execute a git command and return stdout
|
|
12
|
+
*/
|
|
13
|
+
export function git(args: string[], cwd?: string): string {
|
|
14
|
+
const options = cwd ? { cwd, encoding: 'utf-8' as const } : { encoding: 'utf-8' as const };
|
|
15
|
+
try {
|
|
16
|
+
return execSync(`git ${args.join(' ')}`, { ...options, stdio: ['pipe', 'pipe', 'pipe'] }).trim();
|
|
17
|
+
} catch (error: unknown) {
|
|
18
|
+
const execError = error as { stderr?: Buffer; message?: string };
|
|
19
|
+
const stderr = execError.stderr?.toString?.() || execError.message || 'Unknown git error';
|
|
20
|
+
throw new Error(stderr.trim());
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Check if we're in a git repository
|
|
26
|
+
*/
|
|
27
|
+
export function isGitRepo(cwd?: string): boolean {
|
|
28
|
+
try {
|
|
29
|
+
git(['rev-parse', '--git-dir'], cwd);
|
|
30
|
+
return true;
|
|
31
|
+
} catch {
|
|
32
|
+
return false;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Get the root directory of the git repository
|
|
38
|
+
*/
|
|
39
|
+
export function getRepoRoot(cwd?: string): string {
|
|
40
|
+
return git(['rev-parse', '--show-toplevel'], cwd);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Get the repository name from the root path
|
|
45
|
+
*/
|
|
46
|
+
export function getRepoName(cwd?: string): string {
|
|
47
|
+
const root = getRepoRoot(cwd);
|
|
48
|
+
return path.basename(root);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Check if a branch exists
|
|
53
|
+
*/
|
|
54
|
+
export function branchExists(branch: string, cwd?: string): boolean {
|
|
55
|
+
try {
|
|
56
|
+
git(['rev-parse', '--verify', `refs/heads/${branch}`], cwd);
|
|
57
|
+
return true;
|
|
58
|
+
} catch {
|
|
59
|
+
return false;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Get the current branch name
|
|
65
|
+
*/
|
|
66
|
+
export function getCurrentBranch(cwd?: string): string | null {
|
|
67
|
+
try {
|
|
68
|
+
return git(['rev-parse', '--abbrev-ref', 'HEAD'], cwd);
|
|
69
|
+
} catch {
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Check if a file is tracked by git
|
|
76
|
+
*/
|
|
77
|
+
export function isTracked(filePath: string, cwd?: string): boolean {
|
|
78
|
+
try {
|
|
79
|
+
git(['ls-files', '--error-unmatch', filePath], cwd);
|
|
80
|
+
return true;
|
|
81
|
+
} catch {
|
|
82
|
+
return false;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Check if a file is ignored by git
|
|
88
|
+
*/
|
|
89
|
+
export function isIgnored(filePath: string, cwd?: string): boolean {
|
|
90
|
+
try {
|
|
91
|
+
git(['check-ignore', '-q', filePath], cwd);
|
|
92
|
+
return true;
|
|
93
|
+
} catch {
|
|
94
|
+
return false;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* List all worktrees using porcelain format
|
|
100
|
+
*/
|
|
101
|
+
export function listWorktrees(cwd?: string): WorktreeInfo[] {
|
|
102
|
+
const output = git(['worktree', 'list', '--porcelain'], cwd);
|
|
103
|
+
const worktrees: WorktreeInfo[] = [];
|
|
104
|
+
let current: Partial<WorktreeInfo> = {};
|
|
105
|
+
|
|
106
|
+
for (const line of output.split('\n')) {
|
|
107
|
+
if (line === '') {
|
|
108
|
+
if (current.worktree) {
|
|
109
|
+
worktrees.push(current as WorktreeInfo);
|
|
110
|
+
}
|
|
111
|
+
current = {};
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
if (line.startsWith('worktree ')) {
|
|
116
|
+
current.worktree = line.substring(9);
|
|
117
|
+
} else if (line.startsWith('HEAD ')) {
|
|
118
|
+
current.head = line.substring(5);
|
|
119
|
+
} else if (line.startsWith('branch ')) {
|
|
120
|
+
current.branch = line.substring(7).replace('refs/heads/', '');
|
|
121
|
+
} else if (line === 'bare') {
|
|
122
|
+
current.bare = true;
|
|
123
|
+
} else if (line === 'detached') {
|
|
124
|
+
current.detached = true;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Don't forget the last entry
|
|
129
|
+
if (current.worktree) {
|
|
130
|
+
worktrees.push(current as WorktreeInfo);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
return worktrees;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Add a worktree
|
|
138
|
+
*/
|
|
139
|
+
export function addWorktree(
|
|
140
|
+
targetPath: string,
|
|
141
|
+
branch: string,
|
|
142
|
+
createBranch: boolean,
|
|
143
|
+
cwd?: string
|
|
144
|
+
): void {
|
|
145
|
+
const args = ['worktree', 'add'];
|
|
146
|
+
if (createBranch) {
|
|
147
|
+
args.push('-b', branch);
|
|
148
|
+
}
|
|
149
|
+
args.push(targetPath);
|
|
150
|
+
if (!createBranch) {
|
|
151
|
+
args.push(branch);
|
|
152
|
+
}
|
|
153
|
+
git(args, cwd);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Remove a worktree
|
|
158
|
+
*/
|
|
159
|
+
export function removeWorktree(worktreePath: string, force: boolean, cwd?: string): void {
|
|
160
|
+
const args = ['worktree', 'remove'];
|
|
161
|
+
if (force) {
|
|
162
|
+
args.push('--force');
|
|
163
|
+
}
|
|
164
|
+
args.push(worktreePath);
|
|
165
|
+
git(args, cwd);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Prune worktrees
|
|
170
|
+
*/
|
|
171
|
+
export function pruneWorktrees(cwd?: string): string {
|
|
172
|
+
return git(['worktree', 'prune', '--verbose'], cwd);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Delete a branch
|
|
177
|
+
*/
|
|
178
|
+
export function deleteBranch(branch: string, force: boolean, cwd?: string): void {
|
|
179
|
+
const flag = force ? '-D' : '-d';
|
|
180
|
+
git(['branch', flag, branch], cwd);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Check if a worktree is dirty (has uncommitted changes)
|
|
185
|
+
*/
|
|
186
|
+
export function isWorktreeDirty(worktreePath: string): boolean {
|
|
187
|
+
try {
|
|
188
|
+
const status = git(['status', '--porcelain'], worktreePath);
|
|
189
|
+
return status.length > 0;
|
|
190
|
+
} catch {
|
|
191
|
+
return false;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Get short HEAD SHA for a worktree
|
|
197
|
+
*/
|
|
198
|
+
export function getShortHead(worktreePath: string): string {
|
|
199
|
+
try {
|
|
200
|
+
return git(['rev-parse', '--short', 'HEAD'], worktreePath);
|
|
201
|
+
} catch {
|
|
202
|
+
return 'unknown';
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Run a command in a directory
|
|
208
|
+
*/
|
|
209
|
+
export function runCommand(command: string, cwd: string): Promise<number> {
|
|
210
|
+
return new Promise((resolve) => {
|
|
211
|
+
const isWindows = process.platform === 'win32';
|
|
212
|
+
const shell = isWindows ? 'cmd.exe' : '/bin/sh';
|
|
213
|
+
const shellArgs = isWindows ? ['/c', command] : ['-c', command];
|
|
214
|
+
|
|
215
|
+
const child = spawn(shell, shellArgs, {
|
|
216
|
+
cwd,
|
|
217
|
+
stdio: 'inherit',
|
|
218
|
+
env: process.env,
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
child.on('close', (code) => {
|
|
222
|
+
resolve(code ?? 1);
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
child.on('error', () => {
|
|
226
|
+
resolve(1);
|
|
227
|
+
});
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Check if a command exists
|
|
233
|
+
*/
|
|
234
|
+
export function commandExists(cmd: string): boolean {
|
|
235
|
+
try {
|
|
236
|
+
const isWindows = process.platform === 'win32';
|
|
237
|
+
const checkCmd = isWindows ? `where ${cmd}` : `which ${cmd}`;
|
|
238
|
+
execSync(checkCmd, { stdio: 'pipe' });
|
|
239
|
+
return true;
|
|
240
|
+
} catch {
|
|
241
|
+
return false;
|
|
242
|
+
}
|
|
243
|
+
}
|