@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/README.md +39 -3
- package/dist/bin/wt.d.ts +3 -0
- package/dist/bin/wt.d.ts.map +1 -0
- package/dist/bin/wt.js +83 -0
- package/dist/bin/wt.js.map +1 -0
- package/dist/src/commands.d.ts +9 -0
- package/dist/src/commands.d.ts.map +1 -0
- package/dist/src/commands.js +924 -0
- package/dist/src/commands.js.map +1 -0
- package/dist/src/config.d.ts +51 -0
- package/dist/src/config.d.ts.map +1 -0
- package/dist/src/config.js +384 -0
- package/dist/src/config.js.map +1 -0
- package/dist/src/git.d.ts +55 -0
- package/dist/src/git.d.ts.map +1 -0
- package/dist/src/git.js +387 -0
- package/dist/src/git.js.map +1 -0
- package/dist/src/setup.d.ts +8 -0
- package/dist/src/setup.d.ts.map +1 -0
- package/dist/src/setup.js +245 -0
- package/dist/src/setup.js.map +1 -0
- package/dist/src/types.d.ts +64 -0
- package/dist/src/types.d.ts.map +1 -0
- package/dist/src/types.js +2 -0
- package/dist/src/types.js.map +1 -0
- package/dist/src/ui.d.ts +93 -0
- package/dist/src/ui.d.ts.map +1 -0
- package/dist/src/ui.js +273 -0
- package/dist/src/ui.js.map +1 -0
- package/package.json +20 -6
- package/bin/wt.js +0 -87
- package/shell/wt.sh +0 -66
- package/src/commands.js +0 -883
- package/src/config.js +0 -257
- package/src/git.js +0 -397
- package/src/setup.js +0 -267
- package/src/ui.js +0 -147
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
|
-
}
|