switchman-dev 0.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.
Files changed (50) hide show
  1. package/CLAUDE.md +98 -0
  2. package/README.md +243 -0
  3. package/examples/README.md +117 -0
  4. package/examples/setup.sh +102 -0
  5. package/examples/taskapi/.switchman/switchman.db +0 -0
  6. package/examples/taskapi/package-lock.json +4736 -0
  7. package/examples/taskapi/package.json +18 -0
  8. package/examples/taskapi/src/db.js +179 -0
  9. package/examples/taskapi/src/middleware/auth.js +96 -0
  10. package/examples/taskapi/src/middleware/validate.js +133 -0
  11. package/examples/taskapi/src/routes/tasks.js +65 -0
  12. package/examples/taskapi/src/routes/users.js +38 -0
  13. package/examples/taskapi/src/server.js +7 -0
  14. package/examples/taskapi/tests/api.test.js +112 -0
  15. package/examples/teardown.sh +37 -0
  16. package/examples/walkthrough.sh +172 -0
  17. package/examples/worktrees/agent-rate-limiting/package-lock.json +4736 -0
  18. package/examples/worktrees/agent-rate-limiting/package.json +18 -0
  19. package/examples/worktrees/agent-rate-limiting/src/db.js +179 -0
  20. package/examples/worktrees/agent-rate-limiting/src/middleware/auth.js +96 -0
  21. package/examples/worktrees/agent-rate-limiting/src/middleware/validate.js +133 -0
  22. package/examples/worktrees/agent-rate-limiting/src/routes/tasks.js +65 -0
  23. package/examples/worktrees/agent-rate-limiting/src/routes/users.js +38 -0
  24. package/examples/worktrees/agent-rate-limiting/src/server.js +7 -0
  25. package/examples/worktrees/agent-rate-limiting/tests/api.test.js +112 -0
  26. package/examples/worktrees/agent-tests/package-lock.json +4736 -0
  27. package/examples/worktrees/agent-tests/package.json +18 -0
  28. package/examples/worktrees/agent-tests/src/db.js +179 -0
  29. package/examples/worktrees/agent-tests/src/middleware/auth.js +96 -0
  30. package/examples/worktrees/agent-tests/src/middleware/validate.js +133 -0
  31. package/examples/worktrees/agent-tests/src/routes/tasks.js +65 -0
  32. package/examples/worktrees/agent-tests/src/routes/users.js +38 -0
  33. package/examples/worktrees/agent-tests/src/server.js +7 -0
  34. package/examples/worktrees/agent-tests/tests/api.test.js +112 -0
  35. package/examples/worktrees/agent-validation/package-lock.json +4736 -0
  36. package/examples/worktrees/agent-validation/package.json +18 -0
  37. package/examples/worktrees/agent-validation/src/db.js +179 -0
  38. package/examples/worktrees/agent-validation/src/middleware/auth.js +96 -0
  39. package/examples/worktrees/agent-validation/src/middleware/validate.js +133 -0
  40. package/examples/worktrees/agent-validation/src/routes/tasks.js +65 -0
  41. package/examples/worktrees/agent-validation/src/routes/users.js +38 -0
  42. package/examples/worktrees/agent-validation/src/server.js +7 -0
  43. package/examples/worktrees/agent-validation/tests/api.test.js +112 -0
  44. package/package.json +29 -0
  45. package/src/cli/index.js +602 -0
  46. package/src/core/db.js +240 -0
  47. package/src/core/detector.js +172 -0
  48. package/src/core/git.js +265 -0
  49. package/src/mcp/server.js +555 -0
  50. package/tests/test.js +259 -0
package/src/core/db.js ADDED
@@ -0,0 +1,240 @@
1
+ /**
2
+ * switchman - Database layer
3
+ * SQLite-backed task queue and file ownership registry
4
+ */
5
+
6
+ import { DatabaseSync } from 'node:sqlite';
7
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs';
8
+ import { join, resolve } from 'path';
9
+
10
+ const SWITCHMAN_DIR = '.switchman';
11
+ const DB_FILE = 'switchman.db';
12
+
13
+ // How long (ms) a writer will wait for a lock before giving up.
14
+ // 5 seconds is generous for a CLI tool with 3-10 concurrent agents.
15
+ const BUSY_TIMEOUT_MS = 5000;
16
+
17
+ export function getSwitchmanDir(repoRoot) {
18
+ return join(repoRoot, SWITCHMAN_DIR);
19
+ }
20
+
21
+ export function getDbPath(repoRoot) {
22
+ return join(repoRoot, SWITCHMAN_DIR, DB_FILE);
23
+ }
24
+
25
+ export function initDb(repoRoot) {
26
+ const dir = getSwitchmanDir(repoRoot);
27
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
28
+
29
+ const db = new DatabaseSync(getDbPath(repoRoot));
30
+
31
+ db.exec(`
32
+ PRAGMA journal_mode=WAL;
33
+ PRAGMA synchronous=NORMAL;
34
+ PRAGMA busy_timeout=${BUSY_TIMEOUT_MS};
35
+
36
+ CREATE TABLE IF NOT EXISTS tasks (
37
+ id TEXT PRIMARY KEY,
38
+ title TEXT NOT NULL,
39
+ description TEXT,
40
+ status TEXT NOT NULL DEFAULT 'pending',
41
+ worktree TEXT,
42
+ agent TEXT,
43
+ priority INTEGER DEFAULT 5,
44
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
45
+ updated_at TEXT NOT NULL DEFAULT (datetime('now')),
46
+ completed_at TEXT
47
+ );
48
+
49
+ CREATE TABLE IF NOT EXISTS file_claims (
50
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
51
+ task_id TEXT NOT NULL,
52
+ file_path TEXT NOT NULL,
53
+ worktree TEXT NOT NULL,
54
+ agent TEXT,
55
+ claimed_at TEXT NOT NULL DEFAULT (datetime('now')),
56
+ released_at TEXT,
57
+ FOREIGN KEY(task_id) REFERENCES tasks(id)
58
+ );
59
+
60
+ CREATE TABLE IF NOT EXISTS worktrees (
61
+ name TEXT PRIMARY KEY,
62
+ path TEXT NOT NULL,
63
+ branch TEXT NOT NULL,
64
+ agent TEXT,
65
+ status TEXT NOT NULL DEFAULT 'idle',
66
+ registered_at TEXT NOT NULL DEFAULT (datetime('now')),
67
+ last_seen TEXT NOT NULL DEFAULT (datetime('now'))
68
+ );
69
+
70
+ CREATE TABLE IF NOT EXISTS conflict_log (
71
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
72
+ detected_at TEXT NOT NULL DEFAULT (datetime('now')),
73
+ worktree_a TEXT NOT NULL,
74
+ worktree_b TEXT NOT NULL,
75
+ conflicting_files TEXT NOT NULL,
76
+ resolved INTEGER DEFAULT 0
77
+ );
78
+
79
+ CREATE INDEX IF NOT EXISTS idx_tasks_status ON tasks(status);
80
+ CREATE INDEX IF NOT EXISTS idx_file_claims_path ON file_claims(file_path);
81
+ CREATE INDEX IF NOT EXISTS idx_file_claims_active ON file_claims(released_at) WHERE released_at IS NULL;
82
+ `);
83
+
84
+ return db;
85
+ }
86
+
87
+ export function openDb(repoRoot) {
88
+ const dbPath = getDbPath(repoRoot);
89
+ if (!existsSync(dbPath)) {
90
+ throw new Error(`No switchman database found. Run 'switchman init' first.`);
91
+ }
92
+ const db = new DatabaseSync(dbPath);
93
+ db.exec(`PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL; PRAGMA busy_timeout=${BUSY_TIMEOUT_MS};`);
94
+ return db;
95
+ }
96
+
97
+ // ─── Tasks ────────────────────────────────────────────────────────────────────
98
+
99
+ export function createTask(db, { id, title, description, priority = 5 }) {
100
+ const taskId = id || `task-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`;
101
+ db.prepare(`
102
+ INSERT INTO tasks (id, title, description, priority)
103
+ VALUES (?, ?, ?, ?)
104
+ `).run(taskId, title, description || null, priority);
105
+ return taskId;
106
+ }
107
+
108
+ export function assignTask(db, taskId, worktree, agent) {
109
+ const result = db.prepare(`
110
+ UPDATE tasks
111
+ SET status='in_progress', worktree=?, agent=?, updated_at=datetime('now')
112
+ WHERE id=? AND status='pending'
113
+ `).run(worktree, agent || null, taskId);
114
+ return result.changes > 0;
115
+ }
116
+
117
+ export function completeTask(db, taskId) {
118
+ db.prepare(`
119
+ UPDATE tasks
120
+ SET status='done', completed_at=datetime('now'), updated_at=datetime('now')
121
+ WHERE id=?
122
+ `).run(taskId);
123
+ }
124
+
125
+ export function failTask(db, taskId, reason) {
126
+ db.prepare(`
127
+ UPDATE tasks
128
+ SET status='failed', description=COALESCE(description,'') || '\nFAILED: ' || ?, updated_at=datetime('now')
129
+ WHERE id=?
130
+ `).run(reason || 'unknown', taskId);
131
+ }
132
+
133
+ export function listTasks(db, statusFilter) {
134
+ if (statusFilter) {
135
+ return db.prepare(`SELECT * FROM tasks WHERE status=? ORDER BY priority DESC, created_at ASC`).all(statusFilter);
136
+ }
137
+ return db.prepare(`SELECT * FROM tasks ORDER BY priority DESC, created_at ASC`).all();
138
+ }
139
+
140
+ export function getTask(db, taskId) {
141
+ return db.prepare(`SELECT * FROM tasks WHERE id=?`).get(taskId);
142
+ }
143
+
144
+ export function getNextPendingTask(db) {
145
+ return db.prepare(`
146
+ SELECT * FROM tasks WHERE status='pending'
147
+ ORDER BY priority DESC, created_at ASC LIMIT 1
148
+ `).get();
149
+ }
150
+
151
+ // ─── File Claims ──────────────────────────────────────────────────────────────
152
+
153
+ export function claimFiles(db, taskId, worktree, filePaths, agent) {
154
+ const insert = db.prepare(`
155
+ INSERT INTO file_claims (task_id, file_path, worktree, agent)
156
+ VALUES (?, ?, ?, ?)
157
+ `);
158
+ // node:sqlite's DatabaseSync doesn't have .transaction() like better-sqlite3.
159
+ // We use explicit BEGIN/COMMIT/ROLLBACK. The key correctness fix vs. the old
160
+ // code: we only ROLLBACK if we're actually inside a transaction (i.e. after
161
+ // BEGIN succeeded), and we re-throw so callers can handle failures.
162
+ db.exec('BEGIN');
163
+ try {
164
+ for (const fp of filePaths) {
165
+ insert.run(taskId, fp, worktree, agent || null);
166
+ }
167
+ db.exec('COMMIT');
168
+ } catch (err) {
169
+ try { db.exec('ROLLBACK'); } catch { /* already rolled back */ }
170
+ throw err;
171
+ }
172
+ }
173
+
174
+ export function releaseFileClaims(db, taskId) {
175
+ db.prepare(`
176
+ UPDATE file_claims SET released_at=datetime('now')
177
+ WHERE task_id=? AND released_at IS NULL
178
+ `).run(taskId);
179
+ }
180
+
181
+ export function getActiveFileClaims(db) {
182
+ return db.prepare(`
183
+ SELECT fc.*, t.title as task_title, t.status as task_status
184
+ FROM file_claims fc
185
+ JOIN tasks t ON fc.task_id = t.id
186
+ WHERE fc.released_at IS NULL
187
+ ORDER BY fc.file_path
188
+ `).all();
189
+ }
190
+
191
+ export function checkFileConflicts(db, filePaths, excludeWorktree) {
192
+ const conflicts = [];
193
+ const stmt = db.prepare(`
194
+ SELECT fc.*, t.title as task_title
195
+ FROM file_claims fc
196
+ JOIN tasks t ON fc.task_id = t.id
197
+ WHERE fc.file_path=?
198
+ AND fc.released_at IS NULL
199
+ AND fc.worktree != ?
200
+ AND t.status NOT IN ('done','failed')
201
+ `);
202
+ for (const fp of filePaths) {
203
+ const existing = stmt.get(fp, excludeWorktree || '');
204
+ if (existing) conflicts.push({ file: fp, claimedBy: existing });
205
+ }
206
+ return conflicts;
207
+ }
208
+
209
+ // ─── Worktrees ────────────────────────────────────────────────────────────────
210
+
211
+ export function registerWorktree(db, { name, path, branch, agent }) {
212
+ db.prepare(`
213
+ INSERT INTO worktrees (name, path, branch, agent)
214
+ VALUES (?, ?, ?, ?)
215
+ ON CONFLICT(name) DO UPDATE SET
216
+ path=excluded.path, branch=excluded.branch,
217
+ agent=excluded.agent, last_seen=datetime('now'), status='idle'
218
+ `).run(name, path, branch, agent || null);
219
+ }
220
+
221
+ export function listWorktrees(db) {
222
+ return db.prepare(`SELECT * FROM worktrees ORDER BY registered_at`).all();
223
+ }
224
+
225
+ export function updateWorktreeStatus(db, name, status) {
226
+ db.prepare(`UPDATE worktrees SET status=?, last_seen=datetime('now') WHERE name=?`).run(status, name);
227
+ }
228
+
229
+ // ─── Conflict Log ─────────────────────────────────────────────────────────────
230
+
231
+ export function logConflict(db, worktreeA, worktreeB, conflictingFiles) {
232
+ db.prepare(`
233
+ INSERT INTO conflict_log (worktree_a, worktree_b, conflicting_files)
234
+ VALUES (?, ?, ?)
235
+ `).run(worktreeA, worktreeB, JSON.stringify(conflictingFiles));
236
+ }
237
+
238
+ export function getConflictLog(db) {
239
+ return db.prepare(`SELECT * FROM conflict_log ORDER BY detected_at DESC LIMIT 50`).all();
240
+ }
@@ -0,0 +1,172 @@
1
+ /**
2
+ * switchman - Conflict Detector
3
+ * Scans all registered worktrees for file-level and branch-level conflicts
4
+ */
5
+
6
+ import { listWorktrees } from './db.js';
7
+ import {
8
+ listGitWorktrees,
9
+ getWorktreeChangedFiles,
10
+ checkMergeConflicts,
11
+ } from './git.js';
12
+
13
+ /**
14
+ * Scan all worktrees for conflicts.
15
+ * Returns a full conflict report.
16
+ */
17
+ export async function scanAllWorktrees(db, repoRoot) {
18
+ const dbWorktrees = listWorktrees(db);
19
+ const gitWorktrees = listGitWorktrees(repoRoot);
20
+
21
+ // Build a unified list, merging db metadata with git reality
22
+ const worktrees = mergeWorktreeInfo(dbWorktrees, gitWorktrees);
23
+
24
+ if (worktrees.length < 2) {
25
+ return {
26
+ worktrees,
27
+ conflicts: [],
28
+ fileConflicts: [],
29
+ summary: 'Less than 2 worktrees. Nothing to compare.',
30
+ };
31
+ }
32
+
33
+ // Step 1: Get changed files per worktree
34
+ const fileMap = {}; // worktree name -> [files]
35
+ for (const wt of worktrees) {
36
+ if (wt.path) {
37
+ fileMap[wt.name] = getWorktreeChangedFiles(wt.path, repoRoot);
38
+ }
39
+ }
40
+
41
+ // Step 2: Detect file-level overlaps (fast, always available)
42
+ const fileConflicts = detectFileOverlaps(fileMap, worktrees);
43
+
44
+ // Step 3: Detect branch-level merge conflicts (slower, uses git merge-tree)
45
+ const branchConflicts = [];
46
+ const pairs = getPairs(worktrees.filter(w => w.branch && !w.isMain));
47
+
48
+ for (const [wtA, wtB] of pairs) {
49
+ if (!wtA.branch || !wtB.branch) continue;
50
+ const result = checkMergeConflicts(repoRoot, wtA.branch, wtB.branch);
51
+ if (result.hasConflicts) {
52
+ branchConflicts.push({
53
+ type: result.isOverlapOnly ? 'file_overlap' : 'merge_conflict',
54
+ worktreeA: wtA.name,
55
+ worktreeB: wtB.name,
56
+ branchA: wtA.branch,
57
+ branchB: wtB.branch,
58
+ conflictingFiles: result.conflictingFiles,
59
+ details: result.details,
60
+ });
61
+ }
62
+ }
63
+
64
+ const allConflicts = [...branchConflicts];
65
+
66
+ return {
67
+ worktrees,
68
+ fileMap,
69
+ conflicts: allConflicts,
70
+ fileConflicts,
71
+ summary: buildSummary(worktrees, allConflicts, fileConflicts),
72
+ scannedAt: new Date().toISOString(),
73
+ };
74
+ }
75
+
76
+ /**
77
+ * Detect which worktrees are touching the same files right now (uncommitted)
78
+ */
79
+ function detectFileOverlaps(fileMap, worktrees) {
80
+ const fileToWorktrees = {}; // file -> [worktree names]
81
+
82
+ for (const [wtName, files] of Object.entries(fileMap)) {
83
+ for (const file of files) {
84
+ if (!fileToWorktrees[file]) fileToWorktrees[file] = [];
85
+ fileToWorktrees[file].push(wtName);
86
+ }
87
+ }
88
+
89
+ const conflicts = [];
90
+ for (const [file, wts] of Object.entries(fileToWorktrees)) {
91
+ if (wts.length > 1) {
92
+ conflicts.push({ file, worktrees: wts });
93
+ }
94
+ }
95
+
96
+ return conflicts;
97
+ }
98
+
99
+ /**
100
+ * Check if claiming a set of files from a worktree would conflict with current activity
101
+ */
102
+ export function preflightCheck(db, repoRoot, proposedFiles, worktreeName) {
103
+ const gitWorktrees = listGitWorktrees(repoRoot);
104
+ const fileMap = {};
105
+
106
+ for (const wt of gitWorktrees) {
107
+ if (wt.name !== worktreeName) {
108
+ fileMap[wt.name] = getWorktreeChangedFiles(wt.path, repoRoot);
109
+ }
110
+ }
111
+
112
+ const conflicts = [];
113
+ for (const file of proposedFiles) {
114
+ for (const [wtName, files] of Object.entries(fileMap)) {
115
+ if (files.includes(file)) {
116
+ conflicts.push({ file, conflictsWith: wtName });
117
+ }
118
+ }
119
+ }
120
+
121
+ return {
122
+ safe: conflicts.length === 0,
123
+ conflicts,
124
+ };
125
+ }
126
+
127
+ // ─── Helpers ──────────────────────────────────────────────────────────────────
128
+
129
+ function mergeWorktreeInfo(dbWorktrees, gitWorktrees) {
130
+ const gitMap = {};
131
+ for (const wt of gitWorktrees) gitMap[wt.path] = wt;
132
+
133
+ // Start with git worktrees as the source of truth
134
+ const result = gitWorktrees.map(gw => {
135
+ const dbMatch = dbWorktrees.find(d => d.path === gw.path || d.name === gw.name);
136
+ return {
137
+ ...gw,
138
+ agent: dbMatch?.agent || null,
139
+ registeredInDb: !!dbMatch,
140
+ };
141
+ });
142
+
143
+ return result;
144
+ }
145
+
146
+ function getPairs(arr) {
147
+ const pairs = [];
148
+ for (let i = 0; i < arr.length; i++) {
149
+ for (let j = i + 1; j < arr.length; j++) {
150
+ pairs.push([arr[i], arr[j]]);
151
+ }
152
+ }
153
+ return pairs;
154
+ }
155
+
156
+ function buildSummary(worktrees, conflicts, fileConflicts) {
157
+ const lines = [];
158
+ lines.push(`Scanned ${worktrees.length} worktree(s)`);
159
+
160
+ if (conflicts.length === 0 && fileConflicts.length === 0) {
161
+ lines.push('✓ No conflicts detected');
162
+ } else {
163
+ if (conflicts.length > 0) {
164
+ lines.push(`⚠ ${conflicts.length} branch conflict(s) detected`);
165
+ }
166
+ if (fileConflicts.length > 0) {
167
+ lines.push(`⚠ ${fileConflicts.length} file(s) being edited in multiple worktrees`);
168
+ }
169
+ }
170
+
171
+ return lines.join('\n');
172
+ }
@@ -0,0 +1,265 @@
1
+ /**
2
+ * switchman - Git utilities
3
+ * Worktree discovery and conflict detection via git merge-tree
4
+ */
5
+
6
+ import { execSync, spawnSync } from 'child_process';
7
+ import { existsSync } from 'fs';
8
+ import { join, relative, resolve, basename } from 'path';
9
+
10
+ /**
11
+ * Find the switchman database root from cwd or a given path.
12
+ *
13
+ * Problem: `git rev-parse --show-toplevel` returns the *current worktree's*
14
+ * root when called from inside a linked worktree — not the main repo where
15
+ * .switchman/ lives. We handle this in two steps:
16
+ *
17
+ * 1. Use `git rev-parse --show-toplevel` to get the current worktree root.
18
+ * 2. Use `git rev-parse --git-common-dir` to find the shared .git directory,
19
+ * then resolve the main repo root from there.
20
+ *
21
+ * This means `switchman scan` works correctly whether you run it from:
22
+ * /projects/myapp/ (main worktree)
23
+ * /projects/myapp-feature-auth/ (linked worktree)
24
+ */
25
+ export function findRepoRoot(startPath = process.cwd()) {
26
+ try {
27
+ // Step 1: confirm we're inside *some* git repo
28
+ execSync('git rev-parse --show-toplevel', {
29
+ cwd: startPath,
30
+ encoding: 'utf8',
31
+ stdio: ['pipe', 'pipe', 'pipe'],
32
+ }).trim();
33
+
34
+ // Step 2: get the shared git dir (e.g. /projects/myapp/.git or
35
+ // /projects/myapp/.git/worktrees/feature-auth). For the main worktree
36
+ // this resolves to the .git dir itself; for linked worktrees it points
37
+ // to .git/worktrees/<name> inside the main repo.
38
+ const commonDir = execSync('git rev-parse --git-common-dir', {
39
+ cwd: startPath,
40
+ encoding: 'utf8',
41
+ stdio: ['pipe', 'pipe', 'pipe'],
42
+ }).trim();
43
+
44
+ // --git-common-dir returns a path relative to cwd (or absolute).
45
+ // We need the directory that *contains* the .git folder.
46
+ const resolvedCommon = resolve(startPath, commonDir);
47
+
48
+ // resolvedCommon is something like /projects/myapp/.git
49
+ // The main repo root is its parent.
50
+ const mainRoot = resolve(resolvedCommon, '..');
51
+
52
+ return mainRoot;
53
+ } catch {
54
+ throw new Error('Not inside a git repository. Run switchman from inside a git repo.');
55
+ }
56
+ }
57
+
58
+ /**
59
+ * List all git worktrees for this repo
60
+ * Returns: [{ name, path, branch, isMain, HEAD }]
61
+ */
62
+ export function listGitWorktrees(repoRoot) {
63
+ try {
64
+ const output = execSync('git worktree list --porcelain', {
65
+ cwd: repoRoot,
66
+ encoding: 'utf8',
67
+ });
68
+
69
+ const worktrees = [];
70
+ const blocks = output.trim().split('\n\n');
71
+
72
+ for (const block of blocks) {
73
+ if (!block.trim()) continue;
74
+ const lines = block.trim().split('\n');
75
+ const wt = {};
76
+
77
+ for (const line of lines) {
78
+ if (line.startsWith('worktree ')) wt.path = line.slice(9).trim();
79
+ else if (line.startsWith('HEAD ')) wt.HEAD = line.slice(5).trim();
80
+ else if (line.startsWith('branch ')) wt.branch = line.slice(7).trim().replace('refs/heads/', '');
81
+ else if (line === 'bare') wt.bare = true;
82
+ else if (line === 'detached') wt.detached = true;
83
+ }
84
+
85
+ if (wt.path) {
86
+ wt.name = wt.path === repoRoot ? 'main' : wt.path.split('/').pop();
87
+ wt.isMain = wt.path === repoRoot;
88
+ worktrees.push(wt);
89
+ }
90
+ }
91
+
92
+ return worktrees;
93
+ } catch {
94
+ return [];
95
+ }
96
+ }
97
+
98
+ /**
99
+ * Get files changed in a worktree relative to its base branch
100
+ * Returns array of file paths (relative to repo root)
101
+ */
102
+ export function getWorktreeChangedFiles(worktreePath, repoRoot) {
103
+ try {
104
+ // Get both staged and unstaged changes
105
+ const staged = execSync('git diff --name-only --cached', {
106
+ cwd: worktreePath,
107
+ encoding: 'utf8',
108
+ stdio: ['pipe', 'pipe', 'pipe'],
109
+ }).trim();
110
+
111
+ const unstaged = execSync('git diff --name-only', {
112
+ cwd: worktreePath,
113
+ encoding: 'utf8',
114
+ stdio: ['pipe', 'pipe', 'pipe'],
115
+ }).trim();
116
+
117
+ const allFiles = [
118
+ ...staged.split('\n'),
119
+ ...unstaged.split('\n'),
120
+ ].filter(Boolean);
121
+
122
+ return [...new Set(allFiles)];
123
+ } catch {
124
+ return [];
125
+ }
126
+ }
127
+
128
+ /**
129
+ * Check for merge conflicts between two branches using git merge-tree
130
+ * Returns: { hasConflicts: bool, conflictingFiles: string[], details: string }
131
+ */
132
+ export function checkMergeConflicts(repoRoot, branchA, branchB) {
133
+ try {
134
+ // Find merge base
135
+ const mergeBase = execSync(`git merge-base ${branchA} ${branchB}`, {
136
+ cwd: repoRoot,
137
+ encoding: 'utf8',
138
+ stdio: ['pipe', 'pipe', 'pipe'],
139
+ }).trim();
140
+
141
+ // Run merge-tree (read-only simulation)
142
+ const result = spawnSync(
143
+ 'git',
144
+ ['merge-tree', '--write-tree', '--name-only', mergeBase, branchA, branchB],
145
+ {
146
+ cwd: repoRoot,
147
+ encoding: 'utf8',
148
+ }
149
+ );
150
+
151
+ const output = (result.stdout || '') + (result.stderr || '');
152
+ if (result.status === 0 || !output.includes('CONFLICT')) {
153
+ return { hasConflicts: false, conflictingFiles: [], details: '' };
154
+ }
155
+ const conflictingFiles = parseConflictingFiles(output);
156
+
157
+ return {
158
+ hasConflicts: true,
159
+ conflictingFiles,
160
+ details: output.slice(0, 500),
161
+ };
162
+ } catch (err) {
163
+ // Fallback: compare changed file sets for overlap
164
+ return checkFileOverlap(repoRoot, branchA, branchB);
165
+ }
166
+ }
167
+
168
+ /**
169
+ * Fallback: check if two branches touch the same files
170
+ */
171
+ function checkFileOverlap(repoRoot, branchA, branchB) {
172
+ try {
173
+ const mergeBase = execSync(`git merge-base ${branchA} ${branchB}`, {
174
+ cwd: repoRoot,
175
+ encoding: 'utf8',
176
+ stdio: ['pipe', 'pipe', 'pipe'],
177
+ }).trim();
178
+
179
+ const filesA = execSync(`git diff --name-only ${mergeBase} ${branchA}`, {
180
+ cwd: repoRoot,
181
+ encoding: 'utf8',
182
+ stdio: ['pipe', 'pipe', 'pipe'],
183
+ }).trim().split('\n').filter(Boolean);
184
+
185
+ const filesB = execSync(`git diff --name-only ${mergeBase} ${branchB}`, {
186
+ cwd: repoRoot,
187
+ encoding: 'utf8',
188
+ stdio: ['pipe', 'pipe', 'pipe'],
189
+ }).trim().split('\n').filter(Boolean);
190
+
191
+ const setB = new Set(filesB);
192
+ const overlap = filesA.filter(f => setB.has(f));
193
+
194
+ return {
195
+ hasConflicts: overlap.length > 0,
196
+ conflictingFiles: overlap,
197
+ details: overlap.length ? `File overlap detected (not necessarily merge conflict)` : '',
198
+ isOverlapOnly: true,
199
+ };
200
+ } catch {
201
+ return { hasConflicts: false, conflictingFiles: [], details: 'Could not compare branches', error: true };
202
+ }
203
+ }
204
+
205
+ function parseConflictingFiles(output) {
206
+ const files = new Set();
207
+ const lines = output.split('\n');
208
+ for (const line of lines) {
209
+ // "CONFLICT (content): Merge conflict in path/to/file.js"
210
+ // This is the only reliable format across git versions.
211
+ const match = line.trim().match(/Merge conflict in (.+)$/);
212
+ if (match) files.add(match[1].trim());
213
+ }
214
+ return [...files];
215
+ }
216
+
217
+ /**
218
+ * Get the current branch of a worktree
219
+ */
220
+ export function getWorktreeBranch(worktreePath) {
221
+ try {
222
+ return execSync('git branch --show-current', {
223
+ cwd: worktreePath,
224
+ encoding: 'utf8',
225
+ stdio: ['pipe', 'pipe', 'pipe'],
226
+ }).trim();
227
+ } catch {
228
+ return null;
229
+ }
230
+ }
231
+
232
+ /**
233
+ * Create a new git worktree
234
+ */
235
+ export function createGitWorktree(repoRoot, name, branch) {
236
+ const repoName = basename(repoRoot);
237
+ const wtPath = join(repoRoot, '..', `${repoName}-${name}`);
238
+ execSync(`git worktree add -b "${branch}" "${wtPath}"`, {
239
+ cwd: repoRoot,
240
+ encoding: 'utf8',
241
+ });
242
+ return wtPath;
243
+ }
244
+
245
+ /**
246
+ * Get summary stats for a worktree
247
+ */
248
+ export function getWorktreeStats(worktreePath) {
249
+ try {
250
+ const status = execSync('git status --short', {
251
+ cwd: worktreePath,
252
+ encoding: 'utf8',
253
+ stdio: ['pipe', 'pipe', 'pipe'],
254
+ }).trim();
255
+
256
+ const lines = status.split('\n').filter(Boolean);
257
+ const modified = lines.filter(l => l.startsWith(' M') || l.startsWith('M ')).length;
258
+ const added = lines.filter(l => l.startsWith('A ') || l.startsWith('??')).length;
259
+ const deleted = lines.filter(l => l.startsWith(' D') || l.startsWith('D ')).length;
260
+
261
+ return { modified, added, deleted, total: lines.length, raw: status };
262
+ } catch {
263
+ return { modified: 0, added: 0, deleted: 0, total: 0, raw: '' };
264
+ }
265
+ }