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.
- package/CLAUDE.md +98 -0
- package/README.md +243 -0
- package/examples/README.md +117 -0
- package/examples/setup.sh +102 -0
- package/examples/taskapi/.switchman/switchman.db +0 -0
- package/examples/taskapi/package-lock.json +4736 -0
- package/examples/taskapi/package.json +18 -0
- package/examples/taskapi/src/db.js +179 -0
- package/examples/taskapi/src/middleware/auth.js +96 -0
- package/examples/taskapi/src/middleware/validate.js +133 -0
- package/examples/taskapi/src/routes/tasks.js +65 -0
- package/examples/taskapi/src/routes/users.js +38 -0
- package/examples/taskapi/src/server.js +7 -0
- package/examples/taskapi/tests/api.test.js +112 -0
- package/examples/teardown.sh +37 -0
- package/examples/walkthrough.sh +172 -0
- package/examples/worktrees/agent-rate-limiting/package-lock.json +4736 -0
- package/examples/worktrees/agent-rate-limiting/package.json +18 -0
- package/examples/worktrees/agent-rate-limiting/src/db.js +179 -0
- package/examples/worktrees/agent-rate-limiting/src/middleware/auth.js +96 -0
- package/examples/worktrees/agent-rate-limiting/src/middleware/validate.js +133 -0
- package/examples/worktrees/agent-rate-limiting/src/routes/tasks.js +65 -0
- package/examples/worktrees/agent-rate-limiting/src/routes/users.js +38 -0
- package/examples/worktrees/agent-rate-limiting/src/server.js +7 -0
- package/examples/worktrees/agent-rate-limiting/tests/api.test.js +112 -0
- package/examples/worktrees/agent-tests/package-lock.json +4736 -0
- package/examples/worktrees/agent-tests/package.json +18 -0
- package/examples/worktrees/agent-tests/src/db.js +179 -0
- package/examples/worktrees/agent-tests/src/middleware/auth.js +96 -0
- package/examples/worktrees/agent-tests/src/middleware/validate.js +133 -0
- package/examples/worktrees/agent-tests/src/routes/tasks.js +65 -0
- package/examples/worktrees/agent-tests/src/routes/users.js +38 -0
- package/examples/worktrees/agent-tests/src/server.js +7 -0
- package/examples/worktrees/agent-tests/tests/api.test.js +112 -0
- package/examples/worktrees/agent-validation/package-lock.json +4736 -0
- package/examples/worktrees/agent-validation/package.json +18 -0
- package/examples/worktrees/agent-validation/src/db.js +179 -0
- package/examples/worktrees/agent-validation/src/middleware/auth.js +96 -0
- package/examples/worktrees/agent-validation/src/middleware/validate.js +133 -0
- package/examples/worktrees/agent-validation/src/routes/tasks.js +65 -0
- package/examples/worktrees/agent-validation/src/routes/users.js +38 -0
- package/examples/worktrees/agent-validation/src/server.js +7 -0
- package/examples/worktrees/agent-validation/tests/api.test.js +112 -0
- package/package.json +29 -0
- package/src/cli/index.js +602 -0
- package/src/core/db.js +240 -0
- package/src/core/detector.js +172 -0
- package/src/core/git.js +265 -0
- package/src/mcp/server.js +555 -0
- 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
|
+
}
|
package/src/core/git.js
ADDED
|
@@ -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
|
+
}
|