@simonfestl/husky-cli 0.5.1 → 0.6.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.
Files changed (37) hide show
  1. package/dist/commands/config.js +4 -3
  2. package/dist/commands/idea.js +9 -7
  3. package/dist/commands/interactive/changelog.d.ts +1 -0
  4. package/dist/commands/interactive/changelog.js +398 -0
  5. package/dist/commands/interactive/departments.d.ts +1 -0
  6. package/dist/commands/interactive/departments.js +242 -0
  7. package/dist/commands/interactive/ideas.d.ts +1 -0
  8. package/dist/commands/interactive/ideas.js +311 -0
  9. package/dist/commands/interactive/jules-sessions.d.ts +1 -0
  10. package/dist/commands/interactive/jules-sessions.js +460 -0
  11. package/dist/commands/interactive/processes.d.ts +1 -0
  12. package/dist/commands/interactive/processes.js +271 -0
  13. package/dist/commands/interactive/projects.d.ts +1 -0
  14. package/dist/commands/interactive/projects.js +297 -0
  15. package/dist/commands/interactive/roadmaps.d.ts +1 -0
  16. package/dist/commands/interactive/roadmaps.js +650 -0
  17. package/dist/commands/interactive/strategy.d.ts +1 -0
  18. package/dist/commands/interactive/strategy.js +790 -0
  19. package/dist/commands/interactive/tasks.d.ts +1 -0
  20. package/dist/commands/interactive/tasks.js +415 -0
  21. package/dist/commands/interactive/utils.d.ts +15 -0
  22. package/dist/commands/interactive/utils.js +54 -0
  23. package/dist/commands/interactive/vm-sessions.d.ts +1 -0
  24. package/dist/commands/interactive/vm-sessions.js +319 -0
  25. package/dist/commands/interactive/workflows.d.ts +1 -0
  26. package/dist/commands/interactive/workflows.js +442 -0
  27. package/dist/commands/interactive/worktrees.d.ts +6 -0
  28. package/dist/commands/interactive/worktrees.js +354 -0
  29. package/dist/commands/interactive.js +118 -1208
  30. package/dist/commands/worktree.d.ts +2 -0
  31. package/dist/commands/worktree.js +404 -0
  32. package/dist/index.js +3 -1
  33. package/dist/lib/merge-lock.d.ts +83 -0
  34. package/dist/lib/merge-lock.js +242 -0
  35. package/dist/lib/worktree.d.ts +133 -0
  36. package/dist/lib/worktree.js +473 -0
  37. package/package.json +1 -1
@@ -0,0 +1,242 @@
1
+ /**
2
+ * Merge Lock Mechanism for Husky Worktrees
3
+ *
4
+ * Provides file-based locking to prevent concurrent merges on the same session.
5
+ * Uses atomic file creation with PID tracking for stale lock detection.
6
+ *
7
+ * Based on Auto-Claude's lock mechanism.
8
+ */
9
+ import * as fs from "fs";
10
+ import * as path from "path";
11
+ export class MergeLockError extends Error {
12
+ constructor(message) {
13
+ super(message);
14
+ this.name = "MergeLockError";
15
+ }
16
+ }
17
+ export class MergeLock {
18
+ projectDir;
19
+ sessionName;
20
+ lockFile;
21
+ lockDir;
22
+ isHeldByMe = false;
23
+ static DEFAULT_TIMEOUT = 30000; // 30 seconds
24
+ static POLL_INTERVAL = 500; // 500ms
25
+ constructor(projectDir, sessionName) {
26
+ this.projectDir = path.resolve(projectDir);
27
+ this.sessionName = sessionName;
28
+ this.lockDir = path.join(this.projectDir, ".husky", ".locks");
29
+ this.lockFile = path.join(this.lockDir, `merge-${sessionName}.lock`);
30
+ }
31
+ /**
32
+ * Acquire the merge lock.
33
+ * Returns true if lock acquired, false if timeout.
34
+ */
35
+ async acquire(timeout = MergeLock.DEFAULT_TIMEOUT) {
36
+ // Ensure lock directory exists
37
+ fs.mkdirSync(this.lockDir, { recursive: true });
38
+ const startTime = Date.now();
39
+ while (Date.now() - startTime < timeout) {
40
+ if (this.tryAcquire()) {
41
+ this.isHeldByMe = true;
42
+ return true;
43
+ }
44
+ // Check for stale lock
45
+ if (this.isStale()) {
46
+ console.log(`Removing stale lock for ${this.sessionName}`);
47
+ this.forceRelease();
48
+ continue;
49
+ }
50
+ // Wait before retrying
51
+ await this.sleep(MergeLock.POLL_INTERVAL);
52
+ }
53
+ return false;
54
+ }
55
+ /**
56
+ * Try to acquire the lock atomically.
57
+ */
58
+ tryAcquire() {
59
+ const lockInfo = {
60
+ pid: process.pid,
61
+ timestamp: Date.now(),
62
+ sessionName: this.sessionName,
63
+ };
64
+ try {
65
+ // Use exclusive flag for atomic creation
66
+ const fd = fs.openSync(this.lockFile, fs.constants.O_CREAT | fs.constants.O_EXCL | fs.constants.O_WRONLY);
67
+ fs.writeSync(fd, JSON.stringify(lockInfo));
68
+ fs.closeSync(fd);
69
+ return true;
70
+ }
71
+ catch (error) {
72
+ // File already exists (lock held by another process)
73
+ if (error.code === "EEXIST") {
74
+ return false;
75
+ }
76
+ throw error;
77
+ }
78
+ }
79
+ /**
80
+ * Check if the lock is stale (held by a dead process).
81
+ */
82
+ isStale() {
83
+ if (!fs.existsSync(this.lockFile)) {
84
+ return false;
85
+ }
86
+ try {
87
+ const content = fs.readFileSync(this.lockFile, "utf-8");
88
+ const lockInfo = JSON.parse(content);
89
+ // Check if process is still running
90
+ if (!this.processExists(lockInfo.pid)) {
91
+ return true;
92
+ }
93
+ // Check if lock is too old (more than 10 minutes)
94
+ const lockAge = Date.now() - lockInfo.timestamp;
95
+ if (lockAge > 10 * 60 * 1000) {
96
+ console.warn(`Lock for ${this.sessionName} is ${Math.round(lockAge / 1000)}s old`);
97
+ return true;
98
+ }
99
+ return false;
100
+ }
101
+ catch {
102
+ // If we can't read the lock file, assume it's stale
103
+ return true;
104
+ }
105
+ }
106
+ /**
107
+ * Check if a process exists.
108
+ */
109
+ processExists(pid) {
110
+ try {
111
+ // Sending signal 0 doesn't kill the process, just checks if it exists
112
+ process.kill(pid, 0);
113
+ return true;
114
+ }
115
+ catch {
116
+ return false;
117
+ }
118
+ }
119
+ /**
120
+ * Release the lock.
121
+ */
122
+ release() {
123
+ if (!this.isHeldByMe) {
124
+ return;
125
+ }
126
+ try {
127
+ fs.unlinkSync(this.lockFile);
128
+ }
129
+ catch {
130
+ // Ignore errors during cleanup
131
+ }
132
+ this.isHeldByMe = false;
133
+ }
134
+ /**
135
+ * Force release the lock (for stale lock cleanup).
136
+ */
137
+ forceRelease() {
138
+ try {
139
+ fs.unlinkSync(this.lockFile);
140
+ }
141
+ catch {
142
+ // Ignore errors
143
+ }
144
+ }
145
+ /**
146
+ * Check if this lock instance holds the lock.
147
+ */
148
+ isHeld() {
149
+ return this.isHeldByMe;
150
+ }
151
+ /**
152
+ * Check if the lock file exists (held by any process).
153
+ */
154
+ isLocked() {
155
+ return fs.existsSync(this.lockFile);
156
+ }
157
+ /**
158
+ * Get information about the current lock holder.
159
+ */
160
+ getLockInfo() {
161
+ if (!fs.existsSync(this.lockFile)) {
162
+ return null;
163
+ }
164
+ try {
165
+ const content = fs.readFileSync(this.lockFile, "utf-8");
166
+ return JSON.parse(content);
167
+ }
168
+ catch {
169
+ return null;
170
+ }
171
+ }
172
+ /**
173
+ * Sleep helper.
174
+ */
175
+ sleep(ms) {
176
+ return new Promise((resolve) => setTimeout(resolve, ms));
177
+ }
178
+ /**
179
+ * Cleanup all stale locks in a project.
180
+ */
181
+ static cleanupStale(projectDir) {
182
+ const lockDir = path.join(projectDir, ".husky", ".locks");
183
+ if (!fs.existsSync(lockDir)) {
184
+ return 0;
185
+ }
186
+ let cleaned = 0;
187
+ const entries = fs.readdirSync(lockDir);
188
+ for (const entry of entries) {
189
+ if (!entry.startsWith("merge-") || !entry.endsWith(".lock")) {
190
+ continue;
191
+ }
192
+ const lockFile = path.join(lockDir, entry);
193
+ const sessionName = entry.replace("merge-", "").replace(".lock", "");
194
+ const lock = new MergeLock(projectDir, sessionName);
195
+ if (lock.isStale()) {
196
+ console.log(`Cleaning up stale lock: ${sessionName}`);
197
+ lock.forceRelease();
198
+ cleaned++;
199
+ }
200
+ }
201
+ return cleaned;
202
+ }
203
+ /**
204
+ * List all active locks in a project.
205
+ */
206
+ static listLocks(projectDir) {
207
+ const lockDir = path.join(projectDir, ".husky", ".locks");
208
+ if (!fs.existsSync(lockDir)) {
209
+ return [];
210
+ }
211
+ const locks = [];
212
+ const entries = fs.readdirSync(lockDir);
213
+ for (const entry of entries) {
214
+ if (!entry.startsWith("merge-") || !entry.endsWith(".lock")) {
215
+ continue;
216
+ }
217
+ const sessionName = entry.replace("merge-", "").replace(".lock", "");
218
+ const lock = new MergeLock(projectDir, sessionName);
219
+ const info = lock.getLockInfo();
220
+ if (info) {
221
+ locks.push({ sessionName, info });
222
+ }
223
+ }
224
+ return locks;
225
+ }
226
+ }
227
+ /**
228
+ * Helper function to use MergeLock with async/await cleanup.
229
+ */
230
+ export async function withMergeLock(projectDir, sessionName, fn, timeout) {
231
+ const lock = new MergeLock(projectDir, sessionName);
232
+ const acquired = await lock.acquire(timeout);
233
+ if (!acquired) {
234
+ throw new MergeLockError(`Could not acquire merge lock for ${sessionName} within timeout`);
235
+ }
236
+ try {
237
+ return await fn();
238
+ }
239
+ finally {
240
+ lock.release();
241
+ }
242
+ }
@@ -0,0 +1,133 @@
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
+ export declare class WorktreeError extends Error {
11
+ constructor(message: string);
12
+ }
13
+ export interface WorktreeStats {
14
+ commitCount: number;
15
+ filesChanged: number;
16
+ additions: number;
17
+ deletions: number;
18
+ }
19
+ export interface WorktreeInfo {
20
+ path: string;
21
+ branch: string;
22
+ sessionName: string;
23
+ baseBranch: string;
24
+ isActive: boolean;
25
+ stats: WorktreeStats;
26
+ }
27
+ export interface ChangedFile {
28
+ status: string;
29
+ file: string;
30
+ }
31
+ export interface MergeOptions {
32
+ noCommit?: boolean;
33
+ deleteAfter?: boolean;
34
+ message?: string;
35
+ }
36
+ export declare class WorktreeManager {
37
+ private projectDir;
38
+ private baseBranch;
39
+ private worktreesDir;
40
+ constructor(projectDir: string, baseBranch?: string);
41
+ /**
42
+ * Detect the base branch for worktree creation.
43
+ * Priority: DEFAULT_BRANCH env var > main > master > current branch
44
+ */
45
+ private detectBaseBranch;
46
+ private branchExists;
47
+ private getCurrentBranch;
48
+ private runGit;
49
+ /**
50
+ * Create worktrees directory if needed.
51
+ */
52
+ setup(): void;
53
+ getWorktreePath(sessionName: string): string;
54
+ getBranchName(sessionName: string): string;
55
+ worktreeExists(sessionName: string): boolean;
56
+ /**
57
+ * Get info about a session's worktree.
58
+ */
59
+ getWorktree(sessionName: string): WorktreeInfo | null;
60
+ /**
61
+ * Get diff statistics for a worktree.
62
+ */
63
+ private getWorktreeStats;
64
+ /**
65
+ * Check for branch namespace conflict.
66
+ * Git stores branch refs as files, so a branch named 'husky' blocks 'husky/*'.
67
+ */
68
+ private checkBranchNamespaceConflict;
69
+ /**
70
+ * Create a worktree for a session.
71
+ */
72
+ createWorktree(sessionName: string): WorktreeInfo;
73
+ /**
74
+ * Get existing worktree or create a new one.
75
+ */
76
+ getOrCreateWorktree(sessionName: string): WorktreeInfo;
77
+ /**
78
+ * Remove a session's worktree.
79
+ */
80
+ removeWorktree(sessionName: string, deleteBranch?: boolean): void;
81
+ /**
82
+ * Commit all changes in a session's worktree.
83
+ */
84
+ commitInWorktree(sessionName: string, message: string): boolean;
85
+ /**
86
+ * Merge a session's worktree branch back to base branch.
87
+ */
88
+ mergeWorktree(sessionName: string, options?: MergeOptions): boolean;
89
+ /**
90
+ * Check if there are uncommitted changes.
91
+ */
92
+ hasUncommittedChanges(sessionName?: string): boolean;
93
+ /**
94
+ * List all session worktrees.
95
+ */
96
+ listWorktrees(): WorktreeInfo[];
97
+ /**
98
+ * List all husky branches (even if worktree removed).
99
+ */
100
+ listBranches(): string[];
101
+ /**
102
+ * Get list of changed files in a session's worktree.
103
+ */
104
+ getChangedFiles(sessionName: string): ChangedFile[];
105
+ /**
106
+ * Get a summary of changes in a worktree.
107
+ */
108
+ getChangeSummary(sessionName: string): {
109
+ newFiles: number;
110
+ modifiedFiles: number;
111
+ deletedFiles: number;
112
+ };
113
+ /**
114
+ * Remove all worktrees and their branches.
115
+ */
116
+ cleanupAll(): void;
117
+ /**
118
+ * Remove worktrees that aren't registered with git.
119
+ */
120
+ cleanupStale(): void;
121
+ /**
122
+ * Get the project directory.
123
+ */
124
+ getProjectDir(): string;
125
+ /**
126
+ * Get the base branch.
127
+ */
128
+ getBaseBranch(): string;
129
+ /**
130
+ * Get the worktrees directory.
131
+ */
132
+ getWorktreesDir(): string;
133
+ }