@simonfestl/husky-cli 0.5.2 → 0.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,473 @@
1
+ /**
2
+ * Git Worktree Manager für Husky
3
+ *
4
+ * Manages per-session Git worktrees for isolated agent workspaces.
5
+ * Each session gets its own worktree in .husky/worktrees/sessions/{session-name}/
6
+ * with a corresponding branch husky/{session-name}.
7
+ *
8
+ * Based on Auto-Claude's worktree architecture.
9
+ */
10
+ import { spawnSync } from "child_process";
11
+ import * as fs from "fs";
12
+ import * as path from "path";
13
+ export class WorktreeError extends Error {
14
+ constructor(message) {
15
+ super(message);
16
+ this.name = "WorktreeError";
17
+ }
18
+ }
19
+ export class WorktreeManager {
20
+ projectDir;
21
+ baseBranch;
22
+ worktreesDir;
23
+ constructor(projectDir, baseBranch) {
24
+ this.projectDir = path.resolve(projectDir);
25
+ this.baseBranch = baseBranch || this.detectBaseBranch();
26
+ this.worktreesDir = path.join(this.projectDir, ".husky", "worktrees", "sessions");
27
+ }
28
+ /**
29
+ * Detect the base branch for worktree creation.
30
+ * Priority: DEFAULT_BRANCH env var > main > master > current branch
31
+ */
32
+ detectBaseBranch() {
33
+ // Check DEFAULT_BRANCH env var
34
+ const envBranch = process.env.DEFAULT_BRANCH;
35
+ if (envBranch && this.branchExists(envBranch)) {
36
+ return envBranch;
37
+ }
38
+ // Auto-detect main/master
39
+ for (const branch of ["main", "master"]) {
40
+ if (this.branchExists(branch)) {
41
+ return branch;
42
+ }
43
+ }
44
+ // Fall back to current branch
45
+ const current = this.getCurrentBranch();
46
+ console.warn(`Warning: Could not find 'main' or 'master' branch.`);
47
+ console.warn(`Using current branch '${current}' as base for worktree.`);
48
+ return current;
49
+ }
50
+ branchExists(branch) {
51
+ const result = this.runGit(["rev-parse", "--verify", branch]);
52
+ return result.status === 0;
53
+ }
54
+ getCurrentBranch() {
55
+ const result = this.runGit(["rev-parse", "--abbrev-ref", "HEAD"]);
56
+ if (result.status !== 0) {
57
+ throw new WorktreeError(`Failed to get current branch: ${result.stderr}`);
58
+ }
59
+ return result.stdout.trim();
60
+ }
61
+ runGit(args, options = {}) {
62
+ const cwd = options.cwd || this.projectDir;
63
+ const timeout = options.timeout || 60000;
64
+ try {
65
+ const result = spawnSync("git", args, {
66
+ cwd,
67
+ timeout,
68
+ encoding: "utf-8",
69
+ maxBuffer: 10 * 1024 * 1024, // 10MB
70
+ });
71
+ return {
72
+ status: result.status ?? -1,
73
+ stdout: result.stdout || "",
74
+ stderr: result.stderr || "",
75
+ };
76
+ }
77
+ catch (error) {
78
+ return {
79
+ status: -1,
80
+ stdout: "",
81
+ stderr: error instanceof Error ? error.message : "Unknown error",
82
+ };
83
+ }
84
+ }
85
+ /**
86
+ * Create worktrees directory if needed.
87
+ */
88
+ setup() {
89
+ fs.mkdirSync(this.worktreesDir, { recursive: true });
90
+ }
91
+ // ==================== Path Helpers ====================
92
+ getWorktreePath(sessionName) {
93
+ return path.join(this.worktreesDir, sessionName);
94
+ }
95
+ getBranchName(sessionName) {
96
+ return `husky/${sessionName}`;
97
+ }
98
+ worktreeExists(sessionName) {
99
+ return fs.existsSync(this.getWorktreePath(sessionName));
100
+ }
101
+ // ==================== CRUD Operations ====================
102
+ /**
103
+ * Get info about a session's worktree.
104
+ */
105
+ getWorktree(sessionName) {
106
+ const worktreePath = this.getWorktreePath(sessionName);
107
+ if (!fs.existsSync(worktreePath)) {
108
+ return null;
109
+ }
110
+ // Verify the branch exists in the worktree
111
+ const result = this.runGit(["rev-parse", "--abbrev-ref", "HEAD"], { cwd: worktreePath });
112
+ if (result.status !== 0) {
113
+ return null;
114
+ }
115
+ const actualBranch = result.stdout.trim();
116
+ const stats = this.getWorktreeStats(sessionName);
117
+ return {
118
+ path: worktreePath,
119
+ branch: actualBranch,
120
+ sessionName,
121
+ baseBranch: this.baseBranch,
122
+ isActive: true,
123
+ stats,
124
+ };
125
+ }
126
+ /**
127
+ * Get diff statistics for a worktree.
128
+ */
129
+ getWorktreeStats(sessionName) {
130
+ const worktreePath = this.getWorktreePath(sessionName);
131
+ const stats = {
132
+ commitCount: 0,
133
+ filesChanged: 0,
134
+ additions: 0,
135
+ deletions: 0,
136
+ };
137
+ if (!fs.existsSync(worktreePath)) {
138
+ return stats;
139
+ }
140
+ // Commit count
141
+ const commitResult = this.runGit(["rev-list", "--count", `${this.baseBranch}..HEAD`], { cwd: worktreePath });
142
+ if (commitResult.status === 0) {
143
+ stats.commitCount = parseInt(commitResult.stdout.trim() || "0", 10);
144
+ }
145
+ // Diff stats
146
+ const diffResult = this.runGit(["diff", "--shortstat", `${this.baseBranch}...HEAD`], { cwd: worktreePath });
147
+ if (diffResult.status === 0 && diffResult.stdout.trim()) {
148
+ // Parse: "3 files changed, 50 insertions(+), 10 deletions(-)"
149
+ const filesMatch = diffResult.stdout.match(/(\d+) files? changed/);
150
+ if (filesMatch)
151
+ stats.filesChanged = parseInt(filesMatch[1], 10);
152
+ const insertionsMatch = diffResult.stdout.match(/(\d+) insertions?/);
153
+ if (insertionsMatch)
154
+ stats.additions = parseInt(insertionsMatch[1], 10);
155
+ const deletionsMatch = diffResult.stdout.match(/(\d+) deletions?/);
156
+ if (deletionsMatch)
157
+ stats.deletions = parseInt(deletionsMatch[1], 10);
158
+ }
159
+ return stats;
160
+ }
161
+ /**
162
+ * Check for branch namespace conflict.
163
+ * Git stores branch refs as files, so a branch named 'husky' blocks 'husky/*'.
164
+ */
165
+ checkBranchNamespaceConflict() {
166
+ const result = this.runGit(["rev-parse", "--verify", "husky"]);
167
+ if (result.status === 0) {
168
+ return "husky";
169
+ }
170
+ return null;
171
+ }
172
+ /**
173
+ * Create a worktree for a session.
174
+ */
175
+ createWorktree(sessionName) {
176
+ this.setup();
177
+ const worktreePath = this.getWorktreePath(sessionName);
178
+ const branchName = this.getBranchName(sessionName);
179
+ // Check for branch namespace conflict
180
+ const conflictingBranch = this.checkBranchNamespaceConflict();
181
+ if (conflictingBranch) {
182
+ throw new WorktreeError(`Branch '${conflictingBranch}' exists and blocks creating '${branchName}'.\n` +
183
+ `\n` +
184
+ `Git branch names work like file paths - a branch named 'husky' prevents\n` +
185
+ `creating branches under 'husky/' (like 'husky/${sessionName}').\n` +
186
+ `\n` +
187
+ `Fix: Rename the conflicting branch:\n` +
188
+ ` git branch -m ${conflictingBranch} ${conflictingBranch}-backup`);
189
+ }
190
+ // Remove existing if present (from crashed previous run)
191
+ if (fs.existsSync(worktreePath)) {
192
+ this.runGit(["worktree", "remove", "--force", worktreePath]);
193
+ }
194
+ // Delete branch if it exists (from previous attempt)
195
+ this.runGit(["branch", "-D", branchName]);
196
+ // Fetch latest from remote
197
+ const fetchResult = this.runGit(["fetch", "origin", this.baseBranch]);
198
+ if (fetchResult.status !== 0) {
199
+ console.warn(`Warning: Could not fetch ${this.baseBranch} from origin`);
200
+ }
201
+ // Determine start point (prefer remote)
202
+ const remoteRef = `origin/${this.baseBranch}`;
203
+ let startPoint = this.baseBranch;
204
+ const checkRemote = this.runGit(["rev-parse", "--verify", remoteRef]);
205
+ if (checkRemote.status === 0) {
206
+ startPoint = remoteRef;
207
+ console.log(`Creating worktree from remote: ${remoteRef}`);
208
+ }
209
+ else {
210
+ console.log(`Using local branch: ${this.baseBranch}`);
211
+ }
212
+ // Create worktree with new branch
213
+ const result = this.runGit([
214
+ "worktree",
215
+ "add",
216
+ "-b",
217
+ branchName,
218
+ worktreePath,
219
+ startPoint,
220
+ ]);
221
+ if (result.status !== 0) {
222
+ throw new WorktreeError(`Failed to create worktree for ${sessionName}: ${result.stderr}`);
223
+ }
224
+ console.log(`Created worktree: ${sessionName} on branch ${branchName}`);
225
+ return {
226
+ path: worktreePath,
227
+ branch: branchName,
228
+ sessionName,
229
+ baseBranch: this.baseBranch,
230
+ isActive: true,
231
+ stats: { commitCount: 0, filesChanged: 0, additions: 0, deletions: 0 },
232
+ };
233
+ }
234
+ /**
235
+ * Get existing worktree or create a new one.
236
+ */
237
+ getOrCreateWorktree(sessionName) {
238
+ const existing = this.getWorktree(sessionName);
239
+ if (existing) {
240
+ console.log(`Using existing worktree: ${existing.path}`);
241
+ return existing;
242
+ }
243
+ return this.createWorktree(sessionName);
244
+ }
245
+ /**
246
+ * Remove a session's worktree.
247
+ */
248
+ removeWorktree(sessionName, deleteBranch = false) {
249
+ const worktreePath = this.getWorktreePath(sessionName);
250
+ const branchName = this.getBranchName(sessionName);
251
+ if (fs.existsSync(worktreePath)) {
252
+ const result = this.runGit(["worktree", "remove", "--force", worktreePath]);
253
+ if (result.status === 0) {
254
+ console.log(`Removed worktree: ${sessionName}`);
255
+ }
256
+ else {
257
+ console.warn(`Warning: Could not remove worktree: ${result.stderr}`);
258
+ // Force remove directory
259
+ fs.rmSync(worktreePath, { recursive: true, force: true });
260
+ }
261
+ }
262
+ if (deleteBranch) {
263
+ this.runGit(["branch", "-D", branchName]);
264
+ console.log(`Deleted branch: ${branchName}`);
265
+ }
266
+ this.runGit(["worktree", "prune"]);
267
+ }
268
+ // ==================== Git Operations ====================
269
+ /**
270
+ * Commit all changes in a session's worktree.
271
+ */
272
+ commitInWorktree(sessionName, message) {
273
+ const worktreePath = this.getWorktreePath(sessionName);
274
+ if (!fs.existsSync(worktreePath)) {
275
+ return false;
276
+ }
277
+ this.runGit(["add", "."], { cwd: worktreePath });
278
+ const result = this.runGit(["commit", "-m", message], { cwd: worktreePath });
279
+ if (result.status === 0) {
280
+ return true;
281
+ }
282
+ else if (result.stdout.includes("nothing to commit") ||
283
+ result.stderr.includes("nothing to commit")) {
284
+ return true;
285
+ }
286
+ else {
287
+ console.error(`Commit failed: ${result.stderr}`);
288
+ return false;
289
+ }
290
+ }
291
+ /**
292
+ * Merge a session's worktree branch back to base branch.
293
+ */
294
+ mergeWorktree(sessionName, options = {}) {
295
+ const info = this.getWorktree(sessionName);
296
+ if (!info) {
297
+ console.error(`No worktree found for session: ${sessionName}`);
298
+ return false;
299
+ }
300
+ const { noCommit = false, deleteAfter = false, message } = options;
301
+ if (noCommit) {
302
+ console.log(`Merging ${info.branch} into ${this.baseBranch} (staged, not committed)...`);
303
+ }
304
+ else {
305
+ console.log(`Merging ${info.branch} into ${this.baseBranch}...`);
306
+ }
307
+ // Switch to base branch in main project
308
+ const checkoutResult = this.runGit(["checkout", this.baseBranch]);
309
+ if (checkoutResult.status !== 0) {
310
+ console.error(`Error: Could not checkout base branch: ${checkoutResult.stderr}`);
311
+ return false;
312
+ }
313
+ // Merge the session branch
314
+ const mergeArgs = ["merge", "--no-ff", info.branch];
315
+ if (noCommit) {
316
+ mergeArgs.push("--no-commit");
317
+ }
318
+ else {
319
+ const mergeMessage = message || `husky: Merge ${info.branch}`;
320
+ mergeArgs.push("-m", mergeMessage);
321
+ }
322
+ const mergeResult = this.runGit(mergeArgs);
323
+ if (mergeResult.status !== 0) {
324
+ console.error("Merge conflict! Aborting merge...");
325
+ this.runGit(["merge", "--abort"]);
326
+ return false;
327
+ }
328
+ if (noCommit) {
329
+ console.log(`Changes from ${info.branch} are now staged.`);
330
+ console.log("Review the changes, then commit when ready:");
331
+ console.log(" git commit -m 'your commit message'");
332
+ }
333
+ else {
334
+ console.log(`Successfully merged ${info.branch}`);
335
+ }
336
+ if (deleteAfter) {
337
+ this.removeWorktree(sessionName, true);
338
+ }
339
+ return true;
340
+ }
341
+ /**
342
+ * Check if there are uncommitted changes.
343
+ */
344
+ hasUncommittedChanges(sessionName) {
345
+ const cwd = sessionName ? this.getWorktreePath(sessionName) : undefined;
346
+ const result = this.runGit(["status", "--porcelain"], { cwd });
347
+ return !!result.stdout.trim();
348
+ }
349
+ // ==================== Listing & Discovery ====================
350
+ /**
351
+ * List all session worktrees.
352
+ */
353
+ listWorktrees() {
354
+ const worktrees = [];
355
+ if (!fs.existsSync(this.worktreesDir)) {
356
+ return worktrees;
357
+ }
358
+ const entries = fs.readdirSync(this.worktreesDir, { withFileTypes: true });
359
+ for (const entry of entries) {
360
+ if (entry.isDirectory()) {
361
+ const info = this.getWorktree(entry.name);
362
+ if (info) {
363
+ worktrees.push(info);
364
+ }
365
+ }
366
+ }
367
+ return worktrees;
368
+ }
369
+ /**
370
+ * List all husky branches (even if worktree removed).
371
+ */
372
+ listBranches() {
373
+ const result = this.runGit(["branch", "--list", "husky/*"]);
374
+ if (result.status !== 0) {
375
+ return [];
376
+ }
377
+ const branches = [];
378
+ for (const line of result.stdout.split("\n")) {
379
+ const branch = line.trim().replace(/^\* /, "");
380
+ if (branch) {
381
+ branches.push(branch);
382
+ }
383
+ }
384
+ return branches;
385
+ }
386
+ /**
387
+ * Get list of changed files in a session's worktree.
388
+ */
389
+ getChangedFiles(sessionName) {
390
+ const worktreePath = this.getWorktreePath(sessionName);
391
+ if (!fs.existsSync(worktreePath)) {
392
+ return [];
393
+ }
394
+ const result = this.runGit(["diff", "--name-status", `${this.baseBranch}...HEAD`], { cwd: worktreePath });
395
+ const files = [];
396
+ for (const line of result.stdout.split("\n")) {
397
+ if (!line.trim())
398
+ continue;
399
+ const parts = line.split("\t");
400
+ if (parts.length >= 2) {
401
+ files.push({ status: parts[0], file: parts[1] });
402
+ }
403
+ }
404
+ return files;
405
+ }
406
+ /**
407
+ * Get a summary of changes in a worktree.
408
+ */
409
+ getChangeSummary(sessionName) {
410
+ const files = this.getChangedFiles(sessionName);
411
+ return {
412
+ newFiles: files.filter((f) => f.status === "A").length,
413
+ modifiedFiles: files.filter((f) => f.status === "M").length,
414
+ deletedFiles: files.filter((f) => f.status === "D").length,
415
+ };
416
+ }
417
+ // ==================== Cleanup ====================
418
+ /**
419
+ * Remove all worktrees and their branches.
420
+ */
421
+ cleanupAll() {
422
+ for (const worktree of this.listWorktrees()) {
423
+ this.removeWorktree(worktree.sessionName, true);
424
+ }
425
+ }
426
+ /**
427
+ * Remove worktrees that aren't registered with git.
428
+ */
429
+ cleanupStale() {
430
+ if (!fs.existsSync(this.worktreesDir)) {
431
+ return;
432
+ }
433
+ // Get list of registered worktrees
434
+ const result = this.runGit(["worktree", "list", "--porcelain"]);
435
+ const registeredPaths = new Set();
436
+ for (const line of result.stdout.split("\n")) {
437
+ if (line.startsWith("worktree ")) {
438
+ registeredPaths.add(line.slice(9));
439
+ }
440
+ }
441
+ // Remove unregistered directories
442
+ const entries = fs.readdirSync(this.worktreesDir, { withFileTypes: true });
443
+ for (const entry of entries) {
444
+ if (entry.isDirectory()) {
445
+ const fullPath = path.join(this.worktreesDir, entry.name);
446
+ if (!registeredPaths.has(fullPath)) {
447
+ console.log(`Removing stale worktree directory: ${entry.name}`);
448
+ fs.rmSync(fullPath, { recursive: true, force: true });
449
+ }
450
+ }
451
+ }
452
+ this.runGit(["worktree", "prune"]);
453
+ }
454
+ // ==================== Utility ====================
455
+ /**
456
+ * Get the project directory.
457
+ */
458
+ getProjectDir() {
459
+ return this.projectDir;
460
+ }
461
+ /**
462
+ * Get the base branch.
463
+ */
464
+ getBaseBranch() {
465
+ return this.baseBranch;
466
+ }
467
+ /**
468
+ * Get the worktrees directory.
469
+ */
470
+ getWorktreesDir() {
471
+ return this.worktreesDir;
472
+ }
473
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@simonfestl/husky-cli",
3
- "version": "0.5.2",
3
+ "version": "0.6.1",
4
4
  "description": "CLI for Huskyv0 Task Orchestration with Claude Agent SDK",
5
5
  "type": "module",
6
6
  "bin": {