switchman-dev 0.1.6 → 0.1.8
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/.github/workflows/ci.yml +26 -0
- package/CHANGELOG.md +36 -0
- package/CLAUDE.md +113 -0
- package/README.md +296 -15
- package/examples/README.md +37 -2
- package/package.json +6 -1
- package/src/cli/index.js +3939 -130
- package/src/core/ci.js +205 -1
- package/src/core/db.js +963 -45
- package/src/core/enforcement.js +140 -15
- package/src/core/git.js +286 -1
- package/src/core/ignore.js +1 -0
- package/src/core/licence.js +365 -0
- package/src/core/mcp.js +41 -2
- package/src/core/merge-gate.js +22 -5
- package/src/core/outcome.js +43 -44
- package/src/core/pipeline.js +2459 -88
- package/src/core/planner.js +35 -11
- package/src/core/policy.js +106 -1
- package/src/core/queue.js +654 -29
- package/src/core/semantic.js +71 -5
- package/src/core/sync.js +216 -0
- package/src/mcp/server.js +18 -6
- package/tests.zip +0 -0
package/src/core/enforcement.js
CHANGED
|
@@ -1,18 +1,23 @@
|
|
|
1
|
-
import { appendFileSync, chmodSync, existsSync, mkdirSync, readFileSync, readdirSync, readlinkSync, renameSync, rmSync, statSync, writeFileSync } from 'fs';
|
|
2
|
-
import { dirname, join,
|
|
1
|
+
import { appendFileSync, chmodSync, existsSync, mkdirSync, readFileSync, readdirSync, readlinkSync, realpathSync, renameSync, rmSync, statSync, writeFileSync } from 'fs';
|
|
2
|
+
import { dirname, join, posix, relative, resolve } from 'path';
|
|
3
3
|
import { execFileSync, spawnSync } from 'child_process';
|
|
4
4
|
|
|
5
5
|
import {
|
|
6
6
|
getActiveFileClaims,
|
|
7
|
+
touchBoundaryValidationState,
|
|
7
8
|
getCompletedFileClaims,
|
|
8
9
|
getLease,
|
|
9
|
-
getTaskSpec,
|
|
10
10
|
getWorktree,
|
|
11
|
+
listWorktrees,
|
|
11
12
|
getWorktreeSnapshotState,
|
|
12
13
|
replaceWorktreeSnapshotState,
|
|
13
14
|
getStaleLeases,
|
|
15
|
+
listScopeReservations,
|
|
14
16
|
listAuditEvents,
|
|
15
17
|
listLeases,
|
|
18
|
+
listTasks,
|
|
19
|
+
getTask,
|
|
20
|
+
getTaskSpec,
|
|
16
21
|
logAuditEvent,
|
|
17
22
|
updateWorktreeCompliance,
|
|
18
23
|
} from './db.js';
|
|
@@ -30,6 +35,14 @@ const DEFAULT_ENFORCEMENT_POLICY = {
|
|
|
30
35
|
allowed_generated_paths: [],
|
|
31
36
|
};
|
|
32
37
|
|
|
38
|
+
function normalizeFsPath(filePath) {
|
|
39
|
+
try {
|
|
40
|
+
return realpathSync(filePath);
|
|
41
|
+
} catch {
|
|
42
|
+
return resolve(filePath);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
33
46
|
function getEnforcementPolicyPath(repoRoot) {
|
|
34
47
|
return join(repoRoot, '.switchman', 'enforcement.json');
|
|
35
48
|
}
|
|
@@ -125,7 +138,19 @@ function diffSnapshots(previousSnapshot, currentSnapshot) {
|
|
|
125
138
|
}
|
|
126
139
|
|
|
127
140
|
function getLeaseScopePatterns(db, lease) {
|
|
128
|
-
|
|
141
|
+
const reservations = listScopeReservations(db, { leaseId: lease.id });
|
|
142
|
+
const scopePatterns = reservations
|
|
143
|
+
.filter((reservation) => reservation.ownership_level === 'path_scope' && reservation.scope_pattern)
|
|
144
|
+
.map((reservation) => reservation.scope_pattern);
|
|
145
|
+
return [...new Set(scopePatterns)];
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function getLeaseSubsystemTags(db, lease) {
|
|
149
|
+
const reservations = listScopeReservations(db, { leaseId: lease.id });
|
|
150
|
+
const subsystemTags = reservations
|
|
151
|
+
.filter((reservation) => reservation.ownership_level === 'subsystem' && reservation.subsystem_tag)
|
|
152
|
+
.map((reservation) => reservation.subsystem_tag);
|
|
153
|
+
return [...new Set(subsystemTags)];
|
|
129
154
|
}
|
|
130
155
|
|
|
131
156
|
function findScopedLeaseOwner(db, leases, filePath, excludeLeaseId = null) {
|
|
@@ -139,6 +164,18 @@ function findScopedLeaseOwner(db, leases, filePath, excludeLeaseId = null) {
|
|
|
139
164
|
return null;
|
|
140
165
|
}
|
|
141
166
|
|
|
167
|
+
function findSubsystemLeaseOwner(db, leases, subsystemTags, excludeLeaseId = null) {
|
|
168
|
+
if (!subsystemTags.length) return null;
|
|
169
|
+
for (const lease of leases) {
|
|
170
|
+
if (excludeLeaseId && lease.id === excludeLeaseId) continue;
|
|
171
|
+
const leaseTags = getLeaseSubsystemTags(db, lease);
|
|
172
|
+
if (leaseTags.some((tag) => subsystemTags.includes(tag))) {
|
|
173
|
+
return lease;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
return null;
|
|
177
|
+
}
|
|
178
|
+
|
|
142
179
|
function normalizeDirectoryScopeRoot(pattern) {
|
|
143
180
|
return String(pattern || '').replace(/\\/g, '/').replace(/\/\*\*$/, '').replace(/\/\*$/, '').replace(/\/+$/, '');
|
|
144
181
|
}
|
|
@@ -163,8 +200,13 @@ function resolveLeasePathOwnership(db, lease, filePath, activeClaims, activeLeas
|
|
|
163
200
|
}
|
|
164
201
|
|
|
165
202
|
const ownScopePatterns = getLeaseScopePatterns(db, lease);
|
|
203
|
+
const ownSubsystemTags = getLeaseSubsystemTags(db, lease);
|
|
166
204
|
const ownScopeMatch = ownScopePatterns.length > 0 && matchesPathPatterns(filePath, ownScopePatterns);
|
|
167
205
|
if (ownScopeMatch) {
|
|
206
|
+
const foreignSubsystemOwner = findSubsystemLeaseOwner(db, activeLeases, ownSubsystemTags, lease.id);
|
|
207
|
+
if (foreignSubsystemOwner) {
|
|
208
|
+
return { ok: false, reason_code: 'path_scoped_by_other_lease', claim: null, ownership_type: null };
|
|
209
|
+
}
|
|
168
210
|
const foreignScopeOwner = findScopedLeaseOwner(db, activeLeases, filePath, lease.id);
|
|
169
211
|
if (foreignScopeOwner) {
|
|
170
212
|
return { ok: false, reason_code: 'path_scoped_by_other_lease', claim: null, ownership_type: null };
|
|
@@ -177,9 +219,25 @@ function resolveLeasePathOwnership(db, lease, filePath, activeClaims, activeLeas
|
|
|
177
219
|
return { ok: false, reason_code: 'path_scoped_by_other_lease', claim: null, ownership_type: null };
|
|
178
220
|
}
|
|
179
221
|
|
|
222
|
+
const foreignSubsystemOwner = findSubsystemLeaseOwner(db, activeLeases, ownSubsystemTags, lease.id);
|
|
223
|
+
if (foreignSubsystemOwner) {
|
|
224
|
+
return { ok: false, reason_code: 'path_scoped_by_other_lease', claim: null, ownership_type: null };
|
|
225
|
+
}
|
|
226
|
+
|
|
180
227
|
return { ok: false, reason_code: 'path_not_claimed', claim: null, ownership_type: null };
|
|
181
228
|
}
|
|
182
229
|
|
|
230
|
+
function completedScopedTaskOwnsPath(db, completedTasks, filePath) {
|
|
231
|
+
for (const task of completedTasks) {
|
|
232
|
+
const taskSpec = getTaskSpec(db, task.id);
|
|
233
|
+
const allowedPaths = Array.isArray(taskSpec?.allowed_paths) ? taskSpec.allowed_paths : [];
|
|
234
|
+
if (allowedPaths.length > 0 && matchesPathPatterns(filePath, allowedPaths)) {
|
|
235
|
+
return true;
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
return false;
|
|
239
|
+
}
|
|
240
|
+
|
|
183
241
|
function classifyObservedPath(db, repoRoot, worktree, filePath, options = {}) {
|
|
184
242
|
const activeLeases = options.activeLeases || listLeases(db, 'active');
|
|
185
243
|
const activeClaims = options.activeClaims || getActiveFileClaims(db);
|
|
@@ -188,12 +246,28 @@ function classifyObservedPath(db, repoRoot, worktree, filePath, options = {}) {
|
|
|
188
246
|
|
|
189
247
|
const activeLease = activeLeases.find((lease) => lease.worktree === worktree.name) || null;
|
|
190
248
|
const claim = activeClaims.find((item) => item.file_path === filePath && item.worktree === worktree.name) || null;
|
|
249
|
+
const foreignClaim = activeClaims.find((item) => item.file_path === filePath && item.worktree !== worktree.name) || null;
|
|
250
|
+
const foreignScopeOwner = findScopedLeaseOwner(db, activeLeases, filePath, activeLease?.id || null);
|
|
191
251
|
|
|
192
252
|
if (!activeLease) {
|
|
193
|
-
return {
|
|
253
|
+
return {
|
|
254
|
+
status: 'denied',
|
|
255
|
+
reason_code: 'no_active_lease',
|
|
256
|
+
lease: null,
|
|
257
|
+
claim,
|
|
258
|
+
owner_claim: foreignClaim,
|
|
259
|
+
owner_lease: foreignScopeOwner,
|
|
260
|
+
};
|
|
194
261
|
}
|
|
195
262
|
if (staleLeaseIds.has(activeLease.id)) {
|
|
196
|
-
return {
|
|
263
|
+
return {
|
|
264
|
+
status: 'denied',
|
|
265
|
+
reason_code: 'lease_expired',
|
|
266
|
+
lease: activeLease,
|
|
267
|
+
claim,
|
|
268
|
+
owner_claim: foreignClaim,
|
|
269
|
+
owner_lease: foreignScopeOwner,
|
|
270
|
+
};
|
|
197
271
|
}
|
|
198
272
|
const ownership = resolveLeasePathOwnership(db, activeLease, filePath, activeClaims, activeLeases);
|
|
199
273
|
if (ownership.ok) {
|
|
@@ -203,21 +277,42 @@ function classifyObservedPath(db, repoRoot, worktree, filePath, options = {}) {
|
|
|
203
277
|
lease: activeLease,
|
|
204
278
|
claim: ownership.claim ?? claim,
|
|
205
279
|
ownership_type: ownership.ownership_type,
|
|
280
|
+
owner_claim: null,
|
|
281
|
+
owner_lease: null,
|
|
206
282
|
};
|
|
207
283
|
}
|
|
208
284
|
if (matchesPathPatterns(filePath, policy.allowed_generated_paths || [])) {
|
|
209
|
-
return {
|
|
285
|
+
return {
|
|
286
|
+
status: 'allowed',
|
|
287
|
+
reason_code: 'policy_exception_allowed',
|
|
288
|
+
lease: activeLease,
|
|
289
|
+
claim: null,
|
|
290
|
+
ownership_type: 'policy',
|
|
291
|
+
owner_claim: null,
|
|
292
|
+
owner_lease: null,
|
|
293
|
+
};
|
|
210
294
|
}
|
|
211
|
-
return {
|
|
295
|
+
return {
|
|
296
|
+
status: 'denied',
|
|
297
|
+
reason_code: ownership.reason_code,
|
|
298
|
+
lease: activeLease,
|
|
299
|
+
claim: ownership.claim ?? claim,
|
|
300
|
+
ownership_type: null,
|
|
301
|
+
owner_claim: ownership.claim ?? foreignClaim,
|
|
302
|
+
owner_lease: foreignScopeOwner,
|
|
303
|
+
};
|
|
212
304
|
}
|
|
213
305
|
|
|
214
306
|
function normalizeRepoPath(repoRoot, targetPath) {
|
|
215
|
-
const
|
|
307
|
+
const rawPath = String(targetPath || '').replace(/\\/g, '/').trim();
|
|
308
|
+
const relativePath = posix.normalize(rawPath.replace(/^\.\/+/, ''));
|
|
216
309
|
if (
|
|
217
310
|
relativePath === '' ||
|
|
218
|
-
relativePath.startsWith('..') ||
|
|
219
311
|
relativePath === '.' ||
|
|
220
|
-
relativePath
|
|
312
|
+
relativePath === '..' ||
|
|
313
|
+
relativePath.startsWith('../') ||
|
|
314
|
+
rawPath.startsWith('/') ||
|
|
315
|
+
/^[A-Za-z]:\//.test(rawPath)
|
|
221
316
|
) {
|
|
222
317
|
throw new Error('Target path must point to a file inside the repository.');
|
|
223
318
|
}
|
|
@@ -358,6 +453,9 @@ export function validateLeaseAccess(db, { leaseId, worktree = null }) {
|
|
|
358
453
|
}
|
|
359
454
|
|
|
360
455
|
function logWriteEvent(db, status, reasonCode, validation, eventType, details = null) {
|
|
456
|
+
if (status === 'allowed' && validation.lease?.id) {
|
|
457
|
+
touchBoundaryValidationState(db, validation.lease.id, `write:${details?.operation || 'unknown'}`);
|
|
458
|
+
}
|
|
361
459
|
logAuditEvent(db, {
|
|
362
460
|
eventType,
|
|
363
461
|
status,
|
|
@@ -529,7 +627,17 @@ export function gatewayMovePath(db, repoRoot, { leaseId, sourcePath, destination
|
|
|
529
627
|
}
|
|
530
628
|
|
|
531
629
|
export function gatewayMakeDirectory(db, repoRoot, { leaseId, path: targetPath, worktree = null }) {
|
|
532
|
-
|
|
630
|
+
let normalizedPath;
|
|
631
|
+
try {
|
|
632
|
+
normalizedPath = normalizeRepoPath(repoRoot, targetPath).relativePath.replace(/\/+$/, '');
|
|
633
|
+
} catch {
|
|
634
|
+
return {
|
|
635
|
+
ok: false,
|
|
636
|
+
reason_code: 'policy_exception_required',
|
|
637
|
+
file_path: targetPath,
|
|
638
|
+
lease_id: leaseId,
|
|
639
|
+
};
|
|
640
|
+
}
|
|
533
641
|
const lease = getLease(db, leaseId);
|
|
534
642
|
|
|
535
643
|
if (!lease || lease.status !== 'active') {
|
|
@@ -703,6 +811,9 @@ export function evaluateWorktreeCompliance(db, repoRoot, worktree, options = {})
|
|
|
703
811
|
const activeLeases = options.activeLeases || listLeases(db, 'active');
|
|
704
812
|
const activeClaims = options.activeClaims || getActiveFileClaims(db);
|
|
705
813
|
const completedClaims = options.completedClaims || getCompletedFileClaims(db, worktree.name);
|
|
814
|
+
const completedTasks = options.completedTasks
|
|
815
|
+
|| listTasks(db)
|
|
816
|
+
.filter((task) => task.status === 'done' && task.worktree === worktree.name);
|
|
706
817
|
|
|
707
818
|
const changedFiles = getWorktreeChangedFiles(worktree.path, repoRoot);
|
|
708
819
|
const activeLease = activeLeases.find((lease) => lease.worktree === worktree.name) || null;
|
|
@@ -715,7 +826,7 @@ export function evaluateWorktreeCompliance(db, repoRoot, worktree, options = {})
|
|
|
715
826
|
for (const file of changedFiles) {
|
|
716
827
|
const completedClaim = completedClaimsByPath.get(file);
|
|
717
828
|
if (!activeLease) {
|
|
718
|
-
if (completedClaim) {
|
|
829
|
+
if (completedClaim || completedScopedTaskOwnsPath(db, completedTasks, file)) {
|
|
719
830
|
continue;
|
|
720
831
|
}
|
|
721
832
|
violations.push({ file, reason_code: 'no_active_lease' });
|
|
@@ -825,6 +936,9 @@ export function monitorWorktreesOnce(db, repoRoot, worktrees, options = {}) {
|
|
|
825
936
|
reason_code: classification.reason_code,
|
|
826
937
|
lease_id: classification.lease?.id ?? null,
|
|
827
938
|
task_id: classification.lease?.task_id ?? null,
|
|
939
|
+
owner_worktree: classification.owner_claim?.worktree || classification.owner_lease?.worktree || null,
|
|
940
|
+
owner_task_id: classification.owner_claim?.task_id || classification.owner_lease?.task_id || null,
|
|
941
|
+
owner_task_title: classification.owner_claim?.task_title || getTask(db, classification.owner_lease?.task_id || '')?.title || null,
|
|
828
942
|
enforcement_action: null,
|
|
829
943
|
quarantine_path: null,
|
|
830
944
|
};
|
|
@@ -840,6 +954,10 @@ export function monitorWorktreesOnce(db, repoRoot, worktrees, options = {}) {
|
|
|
840
954
|
details: JSON.stringify({ change_type: change.change_type }),
|
|
841
955
|
});
|
|
842
956
|
|
|
957
|
+
if (classification.status === 'allowed' && classification.lease?.id) {
|
|
958
|
+
touchBoundaryValidationState(db, classification.lease.id, `observed:${change.change_type}`);
|
|
959
|
+
}
|
|
960
|
+
|
|
843
961
|
if (classification.status === 'denied') {
|
|
844
962
|
updateWorktreeCompliance(db, worktree.name, COMPLIANCE_STATES.NON_COMPLIANT);
|
|
845
963
|
if (options.quarantine) {
|
|
@@ -881,9 +999,16 @@ export function runCommitGate(db, repoRoot, { cwd = process.cwd(), worktreeName
|
|
|
881
999
|
const currentWorktree = worktreeName
|
|
882
1000
|
? null
|
|
883
1001
|
: getCurrentWorktree(repoRoot, cwd);
|
|
1002
|
+
const registeredWorktree = worktreeName
|
|
1003
|
+
? getWorktree(db, worktreeName)
|
|
1004
|
+
: listWorktrees(db).find((entry) => normalizeFsPath(entry.path) === normalizeFsPath(cwd))
|
|
1005
|
+
|| (currentWorktree
|
|
1006
|
+
? listWorktrees(db).find((entry) => normalizeFsPath(entry.path) === normalizeFsPath(currentWorktree.path))
|
|
1007
|
+
: null)
|
|
1008
|
+
|| null;
|
|
884
1009
|
const resolvedWorktree = worktreeName
|
|
885
|
-
? { name: worktreeName, path: cwd }
|
|
886
|
-
: currentWorktree;
|
|
1010
|
+
? { name: worktreeName, path: registeredWorktree?.path || cwd }
|
|
1011
|
+
: registeredWorktree || currentWorktree;
|
|
887
1012
|
|
|
888
1013
|
if (!resolvedWorktree) {
|
|
889
1014
|
const result = {
|
package/src/core/git.js
CHANGED
|
@@ -4,8 +4,9 @@
|
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
6
|
import { execFileSync, execSync, spawnSync } from 'child_process';
|
|
7
|
-
import { existsSync, realpathSync } from 'fs';
|
|
7
|
+
import { existsSync, realpathSync, rmSync, statSync } from 'fs';
|
|
8
8
|
import { join, relative, resolve, basename } from 'path';
|
|
9
|
+
import { tmpdir } from 'os';
|
|
9
10
|
import { filterIgnoredPaths } from './ignore.js';
|
|
10
11
|
|
|
11
12
|
function normalizeFsPath(path) {
|
|
@@ -339,6 +340,289 @@ export function gitMergeBranchInto(repoRoot, baseBranch, topicBranch) {
|
|
|
339
340
|
}
|
|
340
341
|
}
|
|
341
342
|
|
|
343
|
+
export function gitAssessBranchFreshness(repoRoot, baseBranch, topicBranch) {
|
|
344
|
+
const baseCommit = gitRevParse(repoRoot, baseBranch);
|
|
345
|
+
const topicCommit = gitRevParse(repoRoot, topicBranch);
|
|
346
|
+
if (!baseCommit || !topicCommit) {
|
|
347
|
+
return {
|
|
348
|
+
state: 'unknown',
|
|
349
|
+
base_commit: baseCommit || null,
|
|
350
|
+
topic_commit: topicCommit || null,
|
|
351
|
+
merge_base: null,
|
|
352
|
+
};
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
try {
|
|
356
|
+
const mergeBase = execFileSync('git', ['merge-base', baseBranch, topicBranch], {
|
|
357
|
+
cwd: repoRoot,
|
|
358
|
+
encoding: 'utf8',
|
|
359
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
360
|
+
}).trim();
|
|
361
|
+
return {
|
|
362
|
+
state: mergeBase === baseCommit ? 'fresh' : 'behind',
|
|
363
|
+
base_commit: baseCommit,
|
|
364
|
+
topic_commit: topicCommit,
|
|
365
|
+
merge_base: mergeBase,
|
|
366
|
+
};
|
|
367
|
+
} catch {
|
|
368
|
+
return {
|
|
369
|
+
state: 'unknown',
|
|
370
|
+
base_commit: baseCommit,
|
|
371
|
+
topic_commit: topicCommit,
|
|
372
|
+
merge_base: null,
|
|
373
|
+
};
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
export function gitMaterializeIntegrationBranch(repoRoot, {
|
|
378
|
+
branch,
|
|
379
|
+
baseBranch = 'main',
|
|
380
|
+
mergeBranches = [],
|
|
381
|
+
tempWorktreePath = null,
|
|
382
|
+
} = {}) {
|
|
383
|
+
const uniqueBranches = [...new Set(mergeBranches.filter(Boolean))].filter((candidate) => candidate !== branch);
|
|
384
|
+
const resolvedTempWorktreePath = tempWorktreePath || join(tmpdir(), `switchman-landing-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`);
|
|
385
|
+
|
|
386
|
+
try {
|
|
387
|
+
execFileSync('git', ['worktree', 'add', '--detach', resolvedTempWorktreePath, baseBranch], {
|
|
388
|
+
cwd: repoRoot,
|
|
389
|
+
encoding: 'utf8',
|
|
390
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
391
|
+
});
|
|
392
|
+
|
|
393
|
+
execFileSync('git', ['checkout', '-B', branch, baseBranch], {
|
|
394
|
+
cwd: resolvedTempWorktreePath,
|
|
395
|
+
encoding: 'utf8',
|
|
396
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
397
|
+
});
|
|
398
|
+
|
|
399
|
+
for (const mergeBranch of uniqueBranches) {
|
|
400
|
+
execFileSync('git', ['merge', '--no-ff', '--no-edit', mergeBranch], {
|
|
401
|
+
cwd: resolvedTempWorktreePath,
|
|
402
|
+
encoding: 'utf8',
|
|
403
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
404
|
+
});
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
return {
|
|
408
|
+
branch,
|
|
409
|
+
base_branch: baseBranch,
|
|
410
|
+
merged_branches: uniqueBranches,
|
|
411
|
+
head_commit: gitRevParse(resolvedTempWorktreePath, 'HEAD'),
|
|
412
|
+
temp_worktree_path: resolvedTempWorktreePath,
|
|
413
|
+
};
|
|
414
|
+
} catch (err) {
|
|
415
|
+
const stderr = String(err?.stderr || '');
|
|
416
|
+
const stdout = String(err?.stdout || '');
|
|
417
|
+
const combinedOutput = `${stdout}${stderr}`.trim();
|
|
418
|
+
const message = String(err?.message || combinedOutput || 'Failed to materialize integration branch.');
|
|
419
|
+
const currentMergeBranch = uniqueBranches.find((candidate) =>
|
|
420
|
+
message.includes(candidate) || combinedOutput.includes(candidate),
|
|
421
|
+
) || null;
|
|
422
|
+
let reasonCode = 'landing_branch_materialization_failed';
|
|
423
|
+
if (/CONFLICT|Automatic merge failed|Merge conflict/i.test(message) || /CONFLICT|Automatic merge failed/i.test(combinedOutput)) {
|
|
424
|
+
reasonCode = 'landing_branch_merge_conflict';
|
|
425
|
+
} else if (/not something we can merge|unknown revision|bad revision|not a valid object name/i.test(message) || /not something we can merge|unknown revision|bad revision|not a valid object name/i.test(combinedOutput)) {
|
|
426
|
+
reasonCode = 'landing_branch_missing_component';
|
|
427
|
+
} else if (message.includes(baseBranch) && /not something we can merge|unknown revision|bad revision|not a valid object name/i.test(message)) {
|
|
428
|
+
reasonCode = 'landing_branch_missing_base';
|
|
429
|
+
}
|
|
430
|
+
const conflictingFiles = reasonCode === 'landing_branch_merge_conflict'
|
|
431
|
+
? execFileSync('git', ['diff', '--name-only', '--diff-filter=U'], {
|
|
432
|
+
cwd: resolvedTempWorktreePath,
|
|
433
|
+
encoding: 'utf8',
|
|
434
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
435
|
+
}).trim().split('\n').filter(Boolean)
|
|
436
|
+
: [];
|
|
437
|
+
try {
|
|
438
|
+
execFileSync('git', ['merge', '--abort'], {
|
|
439
|
+
cwd: resolvedTempWorktreePath,
|
|
440
|
+
encoding: 'utf8',
|
|
441
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
442
|
+
});
|
|
443
|
+
} catch {
|
|
444
|
+
// No active merge to abort.
|
|
445
|
+
}
|
|
446
|
+
const landingError = new Error(message);
|
|
447
|
+
landingError.code = reasonCode;
|
|
448
|
+
landingError.details = {
|
|
449
|
+
branch,
|
|
450
|
+
base_branch: baseBranch,
|
|
451
|
+
merge_branches: uniqueBranches,
|
|
452
|
+
failed_branch: currentMergeBranch,
|
|
453
|
+
conflicting_files: conflictingFiles,
|
|
454
|
+
output: combinedOutput.slice(0, 1000),
|
|
455
|
+
temp_worktree_path: resolvedTempWorktreePath,
|
|
456
|
+
};
|
|
457
|
+
throw landingError;
|
|
458
|
+
} finally {
|
|
459
|
+
try {
|
|
460
|
+
execFileSync('git', ['worktree', 'remove', resolvedTempWorktreePath, '--force'], {
|
|
461
|
+
cwd: repoRoot,
|
|
462
|
+
encoding: 'utf8',
|
|
463
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
464
|
+
});
|
|
465
|
+
} catch {
|
|
466
|
+
rmSync(resolvedTempWorktreePath, { recursive: true, force: true });
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
export function gitPrepareIntegrationRecoveryWorktree(repoRoot, {
|
|
472
|
+
branch,
|
|
473
|
+
baseBranch = 'main',
|
|
474
|
+
mergeBranches = [],
|
|
475
|
+
recoveryPath,
|
|
476
|
+
} = {}) {
|
|
477
|
+
const uniqueBranches = [...new Set(mergeBranches.filter(Boolean))].filter((candidate) => candidate !== branch);
|
|
478
|
+
if (!recoveryPath) {
|
|
479
|
+
throw new Error('Recovery path is required.');
|
|
480
|
+
}
|
|
481
|
+
if (existsSync(recoveryPath)) {
|
|
482
|
+
throw new Error(`Recovery path already exists: ${recoveryPath}`);
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
execFileSync('git', ['worktree', 'add', '--detach', recoveryPath, baseBranch], {
|
|
486
|
+
cwd: repoRoot,
|
|
487
|
+
encoding: 'utf8',
|
|
488
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
489
|
+
});
|
|
490
|
+
|
|
491
|
+
try {
|
|
492
|
+
execFileSync('git', ['checkout', '-B', branch, baseBranch], {
|
|
493
|
+
cwd: recoveryPath,
|
|
494
|
+
encoding: 'utf8',
|
|
495
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
496
|
+
});
|
|
497
|
+
|
|
498
|
+
for (const mergeBranch of uniqueBranches) {
|
|
499
|
+
try {
|
|
500
|
+
execFileSync('git', ['merge', '--no-ff', '--no-edit', mergeBranch], {
|
|
501
|
+
cwd: recoveryPath,
|
|
502
|
+
encoding: 'utf8',
|
|
503
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
504
|
+
});
|
|
505
|
+
} catch (err) {
|
|
506
|
+
const conflictingFiles = execFileSync('git', ['diff', '--name-only', '--diff-filter=U'], {
|
|
507
|
+
cwd: recoveryPath,
|
|
508
|
+
encoding: 'utf8',
|
|
509
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
510
|
+
}).trim().split('\n').filter(Boolean);
|
|
511
|
+
return {
|
|
512
|
+
ok: false,
|
|
513
|
+
branch,
|
|
514
|
+
base_branch: baseBranch,
|
|
515
|
+
recovery_path: recoveryPath,
|
|
516
|
+
merged_branches: uniqueBranches,
|
|
517
|
+
failed_branch: mergeBranch,
|
|
518
|
+
conflicting_files: conflictingFiles,
|
|
519
|
+
output: `${String(err.stdout || '')}${String(err.stderr || '')}`.trim().slice(0, 1000),
|
|
520
|
+
};
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
return {
|
|
525
|
+
ok: true,
|
|
526
|
+
branch,
|
|
527
|
+
base_branch: baseBranch,
|
|
528
|
+
recovery_path: recoveryPath,
|
|
529
|
+
merged_branches: uniqueBranches,
|
|
530
|
+
head_commit: gitRevParse(recoveryPath, 'HEAD'),
|
|
531
|
+
conflicting_files: [],
|
|
532
|
+
failed_branch: null,
|
|
533
|
+
output: '',
|
|
534
|
+
};
|
|
535
|
+
} catch (err) {
|
|
536
|
+
try {
|
|
537
|
+
execFileSync('git', ['worktree', 'remove', recoveryPath, '--force'], {
|
|
538
|
+
cwd: repoRoot,
|
|
539
|
+
encoding: 'utf8',
|
|
540
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
541
|
+
});
|
|
542
|
+
} catch {
|
|
543
|
+
rmSync(recoveryPath, { recursive: true, force: true });
|
|
544
|
+
}
|
|
545
|
+
throw err;
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
export function gitRemoveWorktree(repoRoot, worktreePath) {
|
|
550
|
+
execFileSync('git', ['worktree', 'remove', worktreePath, '--force'], {
|
|
551
|
+
cwd: repoRoot,
|
|
552
|
+
encoding: 'utf8',
|
|
553
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
554
|
+
});
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
export function gitPruneWorktrees(repoRoot) {
|
|
558
|
+
execFileSync('git', ['worktree', 'prune'], {
|
|
559
|
+
cwd: repoRoot,
|
|
560
|
+
encoding: 'utf8',
|
|
561
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
562
|
+
});
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
function isSwitchmanTempLandingWorktreePath(worktreePath) {
|
|
566
|
+
const resolvedPath = normalizeFsPath(worktreePath || '');
|
|
567
|
+
const tempRoot = normalizeFsPath(tmpdir());
|
|
568
|
+
return resolvedPath.startsWith(`${tempRoot}/`) && basename(resolvedPath).startsWith('switchman-landing-');
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
export function cleanupCrashedLandingTempWorktrees(
|
|
572
|
+
repoRoot,
|
|
573
|
+
{
|
|
574
|
+
olderThanMs = 15 * 60 * 1000,
|
|
575
|
+
now = Date.now(),
|
|
576
|
+
} = {},
|
|
577
|
+
) {
|
|
578
|
+
const actions = [];
|
|
579
|
+
const beforePrune = listGitWorktrees(repoRoot)
|
|
580
|
+
.filter((worktree) => isSwitchmanTempLandingWorktreePath(worktree.path));
|
|
581
|
+
const missingBeforePrune = beforePrune.filter((worktree) => !existsSync(worktree.path));
|
|
582
|
+
|
|
583
|
+
gitPruneWorktrees(repoRoot);
|
|
584
|
+
|
|
585
|
+
const afterPrune = listGitWorktrees(repoRoot)
|
|
586
|
+
.filter((worktree) => isSwitchmanTempLandingWorktreePath(worktree.path));
|
|
587
|
+
const remainingPaths = new Set(afterPrune.map((worktree) => worktree.path));
|
|
588
|
+
|
|
589
|
+
for (const worktree of missingBeforePrune) {
|
|
590
|
+
if (!remainingPaths.has(worktree.path)) {
|
|
591
|
+
actions.push({
|
|
592
|
+
kind: 'stale_temp_worktree_pruned',
|
|
593
|
+
path: worktree.path,
|
|
594
|
+
branch: worktree.branch || null,
|
|
595
|
+
});
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
for (const worktree of afterPrune) {
|
|
600
|
+
if (!existsSync(worktree.path)) continue;
|
|
601
|
+
|
|
602
|
+
let ageMs = 0;
|
|
603
|
+
try {
|
|
604
|
+
ageMs = Math.max(0, now - statSync(worktree.path).mtimeMs);
|
|
605
|
+
} catch {
|
|
606
|
+
continue;
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
if (ageMs < olderThanMs) continue;
|
|
610
|
+
|
|
611
|
+
gitRemoveWorktree(repoRoot, worktree.path);
|
|
612
|
+
actions.push({
|
|
613
|
+
kind: 'stale_temp_worktree_removed',
|
|
614
|
+
path: worktree.path,
|
|
615
|
+
branch: worktree.branch || null,
|
|
616
|
+
age_ms: ageMs,
|
|
617
|
+
});
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
return {
|
|
621
|
+
repaired: actions.length > 0,
|
|
622
|
+
actions,
|
|
623
|
+
};
|
|
624
|
+
}
|
|
625
|
+
|
|
342
626
|
/**
|
|
343
627
|
* Create a new git worktree
|
|
344
628
|
*/
|
|
@@ -348,6 +632,7 @@ export function createGitWorktree(repoRoot, name, branch) {
|
|
|
348
632
|
execSync(`git worktree add -b "${branch}" "${wtPath}"`, {
|
|
349
633
|
cwd: repoRoot,
|
|
350
634
|
encoding: 'utf8',
|
|
635
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
351
636
|
});
|
|
352
637
|
return wtPath;
|
|
353
638
|
}
|