@searls/turbocommit 0.15.0 → 0.15.2
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/README.md +33 -11
- package/cli.js +247 -6
- package/lib/agent.js +10 -6
- package/lib/git.js +9 -5
- package/lib/install.js +11 -4
- package/lib/rescue.js +308 -0
- package/lib/run.js +128 -13
- package/lib/session.js +40 -1
- package/lib/track.js +679 -48
- package/package.json +1 -1
package/lib/track.js
CHANGED
|
@@ -1,12 +1,18 @@
|
|
|
1
1
|
const crypto = require('crypto')
|
|
2
|
+
const { execFileSync } = require('child_process')
|
|
2
3
|
const fs = require('fs')
|
|
3
4
|
const path = require('path')
|
|
4
5
|
const { fileURLToPath } = require('url')
|
|
5
6
|
const { ensureDir } = require('./io')
|
|
6
7
|
const { turbocommitDir } = require('./session')
|
|
7
|
-
const { canonicalRoot, canonicalTrackedPath, changedPathsInRepository } = require('./git')
|
|
8
|
+
const { canonicalRoot, canonicalTrackedPath, changedPathsInRepository, gitCommonDir } = require('./git')
|
|
8
9
|
|
|
9
10
|
const BASH_SNAPSHOT_TTL_MS = 60 * 60 * 1000
|
|
11
|
+
// A session's tracking file is rewritten on every tool call and deleted when
|
|
12
|
+
// its turn stops. One that has been idle this long belongs to a turn that
|
|
13
|
+
// never stopped, so its claims must not block other sessions from owning
|
|
14
|
+
// paths they change with shell commands.
|
|
15
|
+
const CLAIM_TTL_MS = 60 * 60 * 1000
|
|
10
16
|
|
|
11
17
|
/**
|
|
12
18
|
* Directory under the git common dir where turbocommit stores tracking state.
|
|
@@ -23,6 +29,11 @@ function trackingPath (root, sessionId) {
|
|
|
23
29
|
return dir && path.join(dir, sessionId + '.jsonl')
|
|
24
30
|
}
|
|
25
31
|
|
|
32
|
+
function preclaimDir (root) {
|
|
33
|
+
const base = turbocommitDir(root)
|
|
34
|
+
return base && path.join(base, 'preclaims')
|
|
35
|
+
}
|
|
36
|
+
|
|
26
37
|
/**
|
|
27
38
|
* Keys to probe in tool_input for a file path (MCP tools, Write, Edit, etc.)
|
|
28
39
|
*/
|
|
@@ -114,10 +125,11 @@ function extractFilePaths (toolName, toolInput, cwd) {
|
|
|
114
125
|
}
|
|
115
126
|
|
|
116
127
|
/**
|
|
117
|
-
* PreToolUse handler.
|
|
118
|
-
*
|
|
128
|
+
* PreToolUse handler. Persists ownership before waiting for overlap recovery.
|
|
129
|
+
* Returns false when the caller must deny the tool rather than let it run
|
|
130
|
+
* without a durable ownership claim.
|
|
119
131
|
*/
|
|
120
|
-
function handleTrack (input, root) {
|
|
132
|
+
function handleTrack (input, root, opts = {}) {
|
|
121
133
|
const hookInput = typeof input === 'string' ? parseInput(input) : input
|
|
122
134
|
root = root || hookInput?.root
|
|
123
135
|
if (!root || !hookInput) return
|
|
@@ -130,6 +142,11 @@ function handleTrack (input, root) {
|
|
|
130
142
|
|
|
131
143
|
const toolInput = hookInput.toolInput || hookInput.tool_input || {}
|
|
132
144
|
|
|
145
|
+
// Skip Bash with no command (malformed input). All other non-Bash tools
|
|
146
|
+
// passed the PreToolUse matcher, so they're known modifying tools even if
|
|
147
|
+
// we can't extract a specific file path (e.g. MultiEdit nests paths in edits[]).
|
|
148
|
+
if (toolName === 'Bash' && typeof toolInput.command !== 'string') return
|
|
149
|
+
|
|
133
150
|
const cwd = hookInput.cwd || hookInput.raw?.cwd || root
|
|
134
151
|
const entry = { tool: toolName, t: Date.now(), cwd }
|
|
135
152
|
const rawFiles = extractRawFilePaths(toolName, toolInput)
|
|
@@ -137,21 +154,53 @@ function handleTrack (input, root) {
|
|
|
137
154
|
if (rawFiles.length > 0) entry.rawFiles = rawFiles
|
|
138
155
|
if (files.length > 0) entry.files = files
|
|
139
156
|
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
157
|
+
const toolUseId = hookInput.toolUseId || hookInput.tool_use_id
|
|
158
|
+
let pendingSnapshot = false
|
|
159
|
+
let preclaim = null
|
|
160
|
+
try {
|
|
161
|
+
if (toolName === 'Bash') {
|
|
162
|
+
entry.command = toolInput.command
|
|
163
|
+
savePendingBashSnapshot(root, sessionId, toolUseId, cwd)
|
|
164
|
+
pendingSnapshot = true
|
|
165
|
+
appendTracking(root, sessionId, entry)
|
|
166
|
+
} else {
|
|
167
|
+
preclaim = savePreclaim(root, sessionId, entry)
|
|
168
|
+
}
|
|
169
|
+
} catch (error) {
|
|
170
|
+
if (pendingSnapshot) removeBashSnapshot(root, sessionId, toolUseId)
|
|
171
|
+
if (preclaim) removePreclaim(preclaim)
|
|
172
|
+
throw error
|
|
144
173
|
}
|
|
145
174
|
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
175
|
+
const recoveryWaitMs = opts.recoveryWaitMs ?? 5000
|
|
176
|
+
if (pendingSnapshot) {
|
|
177
|
+
const release = acquireBashOverlapRecoveryLock(root, recoveryWaitMs)
|
|
178
|
+
if (!release) {
|
|
179
|
+
removeBashSnapshot(root, sessionId, toolUseId)
|
|
180
|
+
return false
|
|
181
|
+
}
|
|
182
|
+
try {
|
|
183
|
+
pruneBashSnapshots(root)
|
|
184
|
+
initializeBashSnapshot(root, sessionId, toolUseId, cwd)
|
|
185
|
+
} catch (error) {
|
|
186
|
+
removeBashSnapshot(root, sessionId, toolUseId)
|
|
187
|
+
throw error
|
|
188
|
+
} finally {
|
|
189
|
+
release()
|
|
190
|
+
}
|
|
191
|
+
return true
|
|
192
|
+
}
|
|
150
193
|
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
194
|
+
if (!waitForBashOverlapRecovery(root, recoveryWaitMs)) {
|
|
195
|
+
removePreclaim(preclaim)
|
|
196
|
+
return false
|
|
197
|
+
}
|
|
198
|
+
try {
|
|
199
|
+
appendTracking(root, sessionId, entry)
|
|
200
|
+
return true
|
|
201
|
+
} finally {
|
|
202
|
+
removePreclaim(preclaim)
|
|
203
|
+
}
|
|
155
204
|
}
|
|
156
205
|
|
|
157
206
|
function handlePostTrack (input, root) {
|
|
@@ -163,31 +212,46 @@ function handlePostTrack (input, root) {
|
|
|
163
212
|
const toolName = hookInput.toolName || hookInput.tool_name
|
|
164
213
|
if (!sessionId || toolName !== 'Bash') return
|
|
165
214
|
|
|
166
|
-
const cwd = hookInput.cwd || hookInput.raw?.cwd || root
|
|
167
215
|
const toolUseId = hookInput.toolUseId || hookInput.tool_use_id
|
|
168
|
-
const
|
|
169
|
-
if (!
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
216
|
+
const release = acquireBashOverlapRecoveryLock(root, 5000)
|
|
217
|
+
if (!release) return
|
|
218
|
+
try {
|
|
219
|
+
const snapshot = loadBashSnapshot(root, sessionId, toolUseId)
|
|
220
|
+
if (!snapshot || Number.isFinite(snapshot.endedAt)) return
|
|
221
|
+
finishBashSnapshot(root, snapshot, {
|
|
222
|
+
cwd: hookInput.cwd || hookInput.raw?.cwd || snapshot.cwd || root,
|
|
223
|
+
endedAt: Date.now()
|
|
224
|
+
})
|
|
225
|
+
} finally {
|
|
226
|
+
release()
|
|
227
|
+
}
|
|
228
|
+
}
|
|
175
229
|
|
|
230
|
+
function finishBashSnapshot (root, snapshot, { cwd, endedAt }) {
|
|
231
|
+
if (snapshot.pending === true || !Array.isArray(snapshot.before) || !Number.isFinite(snapshot.startedAt)) return false
|
|
232
|
+
const overlaps = overlappingBashSnapshots(root, snapshot, endedAt)
|
|
176
233
|
const before = new Set(snapshot.before.map(canonicalTrackedPath))
|
|
177
|
-
const
|
|
178
|
-
const
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
234
|
+
const changed = changedPathsInRepository(snapshot.root).map(canonicalTrackedPath)
|
|
235
|
+
const overlapping = overlaps.length > 0
|
|
236
|
+
const claimed = overlapping
|
|
237
|
+
? claimedPathsBySessions(root)
|
|
238
|
+
: claimedPathsBySessions(root, snapshot.sessionId)
|
|
239
|
+
const candidates = changed.filter(file => !before.has(file) && !claimed.has(file))
|
|
240
|
+
const files = overlapping ? [] : candidates
|
|
241
|
+
|
|
242
|
+
const entry = { tool: 'Bash', phase: 'post', t: endedAt, cwd }
|
|
243
|
+
if (overlapping) {
|
|
244
|
+
entry.overlapping = true
|
|
245
|
+
entry.overlapEventId = recordBashOverlap(root, snapshot, overlaps, candidates, endedAt)
|
|
246
|
+
}
|
|
186
247
|
if (files.length > 0) {
|
|
187
248
|
entry.rawFiles = files
|
|
188
249
|
entry.files = files
|
|
189
250
|
}
|
|
190
|
-
appendTracking(root, sessionId, entry)
|
|
251
|
+
appendTracking(root, snapshot.sessionId, entry)
|
|
252
|
+
snapshot.endedAt = endedAt
|
|
253
|
+
writeBashSnapshot(root, snapshot.sessionId, snapshot.toolUseId, snapshot)
|
|
254
|
+
return true
|
|
191
255
|
}
|
|
192
256
|
|
|
193
257
|
function bashSnapshotDir (root) {
|
|
@@ -199,23 +263,43 @@ function bashSnapshotPath (root, sessionId, toolUseId) {
|
|
|
199
263
|
const dir = bashSnapshotDir(root)
|
|
200
264
|
if (!dir) return null
|
|
201
265
|
const key = crypto.createHash('sha256')
|
|
202
|
-
.update(`${sessionId}\0${toolUseId || 'current'}`)
|
|
266
|
+
.update(`${canonicalRoot(root)}\0${sessionId}\0${toolUseId || 'current'}`)
|
|
203
267
|
.digest('hex')
|
|
204
268
|
return path.join(dir, key + '.json')
|
|
205
269
|
}
|
|
206
270
|
|
|
207
|
-
function
|
|
271
|
+
function savePendingBashSnapshot (root, sessionId, toolUseId, cwd) {
|
|
208
272
|
const checkout = canonicalRoot(root)
|
|
209
|
-
pruneBashSnapshots(root)
|
|
210
273
|
writeBashSnapshot(root, sessionId, toolUseId, {
|
|
211
274
|
root: checkout,
|
|
275
|
+
cwd: cwd || checkout,
|
|
212
276
|
sessionId,
|
|
213
277
|
toolUseId: toolUseId || null,
|
|
278
|
+
createdAt: Date.now(),
|
|
279
|
+
pending: true
|
|
280
|
+
})
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
function initializeBashSnapshot (root, sessionId, toolUseId, cwd) {
|
|
284
|
+
const checkout = canonicalRoot(root)
|
|
285
|
+
const snapshot = loadBashSnapshot(root, sessionId, toolUseId)
|
|
286
|
+
if (!snapshot || snapshot.pending !== true) throw new Error('Bash ownership snapshot disappeared before initialization')
|
|
287
|
+
writeBashSnapshot(root, sessionId, toolUseId, {
|
|
288
|
+
...snapshot,
|
|
289
|
+
root: checkout,
|
|
290
|
+
cwd: cwd || checkout,
|
|
214
291
|
startedAt: Date.now(),
|
|
215
|
-
before: changedPathsInRepository(checkout)
|
|
292
|
+
before: changedPathsInRepository(checkout),
|
|
293
|
+
pending: false
|
|
216
294
|
})
|
|
217
295
|
}
|
|
218
296
|
|
|
297
|
+
function removeBashSnapshot (root, sessionId, toolUseId) {
|
|
298
|
+
try {
|
|
299
|
+
fs.unlinkSync(bashSnapshotPath(root, sessionId, toolUseId))
|
|
300
|
+
} catch {}
|
|
301
|
+
}
|
|
302
|
+
|
|
219
303
|
function writeBashSnapshot (root, sessionId, toolUseId, snapshot) {
|
|
220
304
|
const file = bashSnapshotPath(root, sessionId, toolUseId)
|
|
221
305
|
if (!file) return
|
|
@@ -237,21 +321,68 @@ function loadBashSnapshot (root, sessionId, toolUseId) {
|
|
|
237
321
|
}
|
|
238
322
|
}
|
|
239
323
|
|
|
240
|
-
function
|
|
324
|
+
function overlappingBashSnapshots (root, current, now) {
|
|
241
325
|
const dir = bashSnapshotDir(root)
|
|
242
326
|
let files
|
|
243
327
|
try {
|
|
244
328
|
files = fs.readdirSync(dir)
|
|
245
329
|
} catch {
|
|
246
|
-
return
|
|
330
|
+
return []
|
|
247
331
|
}
|
|
248
|
-
return files.
|
|
332
|
+
return files.flatMap(file => {
|
|
249
333
|
try {
|
|
250
334
|
const other = JSON.parse(fs.readFileSync(path.join(dir, file), 'utf8'))
|
|
251
|
-
if (other.sessionId === current.sessionId
|
|
252
|
-
if (canonicalRoot(other.root) !== canonicalRoot(current.root)) return
|
|
335
|
+
if (other.sessionId === current.sessionId) return []
|
|
336
|
+
if (canonicalRoot(other.root) !== canonicalRoot(current.root)) return []
|
|
337
|
+
if (other.pending === true || !Number.isFinite(other.startedAt)) return []
|
|
253
338
|
const endedAt = Number.isFinite(other.endedAt) ? other.endedAt : now
|
|
254
|
-
return other.startedAt <= now && endedAt >= current.startedAt
|
|
339
|
+
return other.startedAt <= now && endedAt >= current.startedAt ? [other] : []
|
|
340
|
+
} catch {
|
|
341
|
+
return []
|
|
342
|
+
}
|
|
343
|
+
})
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
function finalizeBashSnapshots (root, sessionId, endedAt = Date.now(), waitMs = 5000) {
|
|
347
|
+
const files = unfinishedBashSnapshotFiles(root, sessionId)
|
|
348
|
+
if (files.length === 0) return 0
|
|
349
|
+
const release = acquireBashOverlapRecoveryLock(root, waitMs)
|
|
350
|
+
if (!release) return null
|
|
351
|
+
try {
|
|
352
|
+
let finalized = 0
|
|
353
|
+
for (const file of files) {
|
|
354
|
+
try {
|
|
355
|
+
const snapshot = JSON.parse(fs.readFileSync(file, 'utf8'))
|
|
356
|
+
if (snapshot.sessionId !== sessionId || Number.isFinite(snapshot.endedAt) || snapshot.pending === true) continue
|
|
357
|
+
if (canonicalRoot(snapshot.root) !== canonicalRoot(root)) continue
|
|
358
|
+
if (finishBashSnapshot(root, snapshot, {
|
|
359
|
+
cwd: snapshot.cwd || snapshot.root || root,
|
|
360
|
+
endedAt
|
|
361
|
+
})) finalized++
|
|
362
|
+
} catch {}
|
|
363
|
+
}
|
|
364
|
+
return finalized
|
|
365
|
+
} finally {
|
|
366
|
+
release()
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
function unfinishedBashSnapshotFiles (root, sessionId) {
|
|
371
|
+
const dir = bashSnapshotDir(root)
|
|
372
|
+
let files
|
|
373
|
+
try {
|
|
374
|
+
files = fs.readdirSync(dir)
|
|
375
|
+
} catch {
|
|
376
|
+
return []
|
|
377
|
+
}
|
|
378
|
+
const checkout = canonicalRoot(root)
|
|
379
|
+
return files.map(file => path.join(dir, file)).filter(file => {
|
|
380
|
+
try {
|
|
381
|
+
const snapshot = JSON.parse(fs.readFileSync(file, 'utf8'))
|
|
382
|
+
return snapshot.sessionId === sessionId &&
|
|
383
|
+
!Number.isFinite(snapshot.endedAt) &&
|
|
384
|
+
snapshot.pending !== true &&
|
|
385
|
+
canonicalRoot(snapshot.root) === checkout
|
|
255
386
|
} catch {
|
|
256
387
|
return false
|
|
257
388
|
}
|
|
@@ -266,28 +397,50 @@ function pruneBashSnapshots (root) {
|
|
|
266
397
|
} catch {
|
|
267
398
|
return
|
|
268
399
|
}
|
|
400
|
+
const checkout = canonicalRoot(root)
|
|
269
401
|
const cutoff = Date.now() - BASH_SNAPSHOT_TTL_MS
|
|
402
|
+
const expired = []
|
|
270
403
|
for (const file of files) {
|
|
271
404
|
try {
|
|
272
405
|
const fullPath = path.join(dir, file)
|
|
273
406
|
const snapshot = JSON.parse(fs.readFileSync(fullPath, 'utf8'))
|
|
274
|
-
if ((snapshot.
|
|
407
|
+
if (canonicalRoot(snapshot.root) !== checkout) continue
|
|
408
|
+
if (Number.isFinite(snapshot.endedAt) && snapshot.endedAt < cutoff) {
|
|
409
|
+
fs.unlinkSync(fullPath)
|
|
410
|
+
continue
|
|
411
|
+
}
|
|
412
|
+
const startedAt = Number.isFinite(snapshot.startedAt) ? snapshot.startedAt : snapshot.createdAt
|
|
413
|
+
if (!Number.isFinite(snapshot.endedAt) && Number.isFinite(startedAt) && startedAt < cutoff) {
|
|
414
|
+
expired.push({ fullPath, snapshot })
|
|
415
|
+
}
|
|
275
416
|
} catch {}
|
|
276
417
|
}
|
|
418
|
+
|
|
419
|
+
if (expired.length === 0) return
|
|
420
|
+
const expiredIds = new Set(expired.map(({ snapshot }) => bashSnapshotId(snapshot)))
|
|
421
|
+
const eventIds = bashOverlapComponents(unresolvedBashOverlapEvents(root))
|
|
422
|
+
.filter(component => [...component.snapshotIds].some(id => expiredIds.has(id)))
|
|
423
|
+
.flatMap(component => component.events.map(event => event.id))
|
|
424
|
+
if (eventIds.length > 0) invalidateBashOverlap(root, [...new Set(eventIds)], 'expired-snapshot')
|
|
425
|
+
for (const { fullPath } of expired) {
|
|
426
|
+
try { fs.unlinkSync(fullPath) } catch {}
|
|
427
|
+
}
|
|
277
428
|
}
|
|
278
429
|
|
|
279
|
-
function
|
|
430
|
+
function claimedPathsBySessions (root, excludedSessionId) {
|
|
280
431
|
const result = new Set()
|
|
281
432
|
const dir = trackingDir(root)
|
|
282
433
|
let files
|
|
283
434
|
try {
|
|
284
435
|
files = fs.readdirSync(dir)
|
|
285
436
|
} catch {
|
|
286
|
-
|
|
437
|
+
files = []
|
|
287
438
|
}
|
|
439
|
+
const cutoff = Date.now() - CLAIM_TTL_MS
|
|
288
440
|
for (const file of files) {
|
|
289
|
-
if (file ===
|
|
441
|
+
if (file === excludedSessionId + '.jsonl' || !file.endsWith('.jsonl')) continue
|
|
290
442
|
try {
|
|
443
|
+
if (fs.statSync(path.join(dir, file)).mtimeMs < cutoff) continue
|
|
291
444
|
const entries = fs.readFileSync(path.join(dir, file), 'utf8').trim().split('\n')
|
|
292
445
|
for (const line of entries) {
|
|
293
446
|
const entry = JSON.parse(line)
|
|
@@ -296,9 +449,437 @@ function claimedPathsByOtherSessions (root, sessionId) {
|
|
|
296
449
|
}
|
|
297
450
|
} catch {}
|
|
298
451
|
}
|
|
452
|
+
const claimsDir = preclaimDir(root)
|
|
453
|
+
let claims
|
|
454
|
+
try {
|
|
455
|
+
claims = fs.readdirSync(claimsDir)
|
|
456
|
+
} catch {
|
|
457
|
+
claims = []
|
|
458
|
+
}
|
|
459
|
+
for (const file of claims) {
|
|
460
|
+
if (!file.endsWith('.json')) continue
|
|
461
|
+
try {
|
|
462
|
+
if (fs.statSync(path.join(claimsDir, file)).mtimeMs < cutoff) continue
|
|
463
|
+
const claim = JSON.parse(fs.readFileSync(path.join(claimsDir, file), 'utf8'))
|
|
464
|
+
if (claim.sessionId === excludedSessionId || !Array.isArray(claim.entry?.files)) continue
|
|
465
|
+
for (const claimed of claim.entry.files) result.add(canonicalTrackedPath(claimed))
|
|
466
|
+
} catch {}
|
|
467
|
+
}
|
|
299
468
|
return result
|
|
300
469
|
}
|
|
301
470
|
|
|
471
|
+
function bashOverlapPath (root) {
|
|
472
|
+
const base = turbocommitDir(root)
|
|
473
|
+
if (!base) return null
|
|
474
|
+
const checkout = crypto.createHash('sha256').update(canonicalRoot(root)).digest('hex').slice(0, 20)
|
|
475
|
+
return path.join(base, `bash-overlaps-${checkout}.jsonl`)
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
function bashOverlapRecoveryLockRef (root) {
|
|
479
|
+
if (!turbocommitDir(root)) return null
|
|
480
|
+
const checkout = crypto.createHash('sha256').update(canonicalRoot(root)).digest('hex').slice(0, 20)
|
|
481
|
+
return `refs/turbocommit/bash-overlap-locks/${checkout}`
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
function bashSessionStopDir (root) {
|
|
485
|
+
const base = turbocommitDir(root)
|
|
486
|
+
return base && path.join(base, 'bash-stops')
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
function bashSessionStopPath (root, sessionId) {
|
|
490
|
+
const dir = bashSessionStopDir(root)
|
|
491
|
+
if (!dir) return null
|
|
492
|
+
const key = crypto.createHash('sha256')
|
|
493
|
+
.update(`${canonicalRoot(root)}\0${sessionId}`)
|
|
494
|
+
.digest('hex')
|
|
495
|
+
return path.join(dir, key + '.json')
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
function recordBashOverlap (root, current, overlaps, files, at) {
|
|
499
|
+
const event = {
|
|
500
|
+
type: 'overlap',
|
|
501
|
+
id: crypto.randomUUID(),
|
|
502
|
+
t: at,
|
|
503
|
+
root: canonicalRoot(current.root),
|
|
504
|
+
completedSessionId: current.sessionId,
|
|
505
|
+
completedAt: at,
|
|
506
|
+
snapshotIds: [current, ...overlaps].map(bashSnapshotId).sort(),
|
|
507
|
+
sessionIds: [...new Set([current, ...overlaps].map(snapshot => snapshot.sessionId))].sort(),
|
|
508
|
+
paths: files.map(file => ({ path: file, fingerprint: fingerprintTrackedPath(file) }))
|
|
509
|
+
}
|
|
510
|
+
appendBashOverlapEvent(root, event)
|
|
511
|
+
return event.id
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
function bashSnapshotId (snapshot) {
|
|
515
|
+
return crypto.createHash('sha256')
|
|
516
|
+
.update(`${canonicalRoot(snapshot.root)}\0${snapshot.sessionId}\0${snapshot.toolUseId || 'current'}`)
|
|
517
|
+
.digest('hex')
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
function fingerprintTrackedPath (file) {
|
|
521
|
+
try {
|
|
522
|
+
const stat = fs.lstatSync(file)
|
|
523
|
+
if (stat.isSymbolicLink()) {
|
|
524
|
+
return 'link:' + crypto.createHash('sha256').update(fs.readlinkSync(file)).digest('hex')
|
|
525
|
+
}
|
|
526
|
+
if (stat.isFile()) {
|
|
527
|
+
const executable = (stat.mode & 0o111) !== 0 ? 'x' : '-'
|
|
528
|
+
return `file:${executable}:` + crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex')
|
|
529
|
+
}
|
|
530
|
+
return `other:${stat.mode}:${stat.size}:${stat.mtimeMs}`
|
|
531
|
+
} catch (error) {
|
|
532
|
+
return error && error.code === 'ENOENT' ? 'missing' : null
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
function recordBashSessionStop (root, sessionId, at = Date.now()) {
|
|
537
|
+
const overlapFile = bashOverlapPath(root)
|
|
538
|
+
if (!overlapFile || !fs.existsSync(overlapFile)) return
|
|
539
|
+
const file = sessionId && bashSessionStopPath(root, sessionId)
|
|
540
|
+
if (!file) return
|
|
541
|
+
ensureDir(path.dirname(file))
|
|
542
|
+
const temporary = file + `.${process.pid}.${crypto.randomUUID()}.tmp`
|
|
543
|
+
try {
|
|
544
|
+
fs.writeFileSync(temporary, JSON.stringify({ root: canonicalRoot(root), sessionId, t: at }) + '\n')
|
|
545
|
+
fs.renameSync(temporary, file)
|
|
546
|
+
if (!fs.existsSync(overlapFile)) {
|
|
547
|
+
try { fs.unlinkSync(file) } catch {}
|
|
548
|
+
}
|
|
549
|
+
} finally {
|
|
550
|
+
try { fs.unlinkSync(temporary) } catch {}
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
function appendBashOverlapEvent (root, event) {
|
|
555
|
+
const file = bashOverlapPath(root)
|
|
556
|
+
if (!file) return
|
|
557
|
+
ensureDir(path.dirname(file))
|
|
558
|
+
fs.appendFileSync(file, JSON.stringify(event) + '\n')
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
function readBashOverlapEvents (root) {
|
|
562
|
+
try {
|
|
563
|
+
return fs.readFileSync(bashOverlapPath(root), 'utf8').trim().split('\n').map(line => {
|
|
564
|
+
try {
|
|
565
|
+
return JSON.parse(line)
|
|
566
|
+
} catch {
|
|
567
|
+
return null
|
|
568
|
+
}
|
|
569
|
+
}).filter(Boolean)
|
|
570
|
+
} catch {
|
|
571
|
+
return []
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
function readBashSessionStops (root) {
|
|
576
|
+
const dir = bashSessionStopDir(root)
|
|
577
|
+
const checkout = canonicalRoot(root)
|
|
578
|
+
let files
|
|
579
|
+
try {
|
|
580
|
+
files = fs.readdirSync(dir)
|
|
581
|
+
} catch {
|
|
582
|
+
return []
|
|
583
|
+
}
|
|
584
|
+
return files.flatMap(file => {
|
|
585
|
+
try {
|
|
586
|
+
const stop = JSON.parse(fs.readFileSync(path.join(dir, file), 'utf8'))
|
|
587
|
+
return stop.root === checkout && stop.sessionId && Number.isFinite(stop.t) ? [stop] : []
|
|
588
|
+
} catch {
|
|
589
|
+
return []
|
|
590
|
+
}
|
|
591
|
+
})
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
function unresolvedBashOverlapEvents (root, events = readBashOverlapEvents(root)) {
|
|
595
|
+
const checkout = canonicalRoot(root)
|
|
596
|
+
const terminal = new Set(events
|
|
597
|
+
.filter(event => (event.type === 'resolved' || event.type === 'invalidated') && event.root === checkout)
|
|
598
|
+
.flatMap(event => event.eventIds || []))
|
|
599
|
+
return events.filter(event =>
|
|
600
|
+
event.type === 'overlap' && event.root === checkout && !terminal.has(event.id))
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
function bashOverlapComponents (overlaps) {
|
|
604
|
+
const components = []
|
|
605
|
+
for (const event of overlaps) {
|
|
606
|
+
const matching = components.filter(component =>
|
|
607
|
+
event.snapshotIds.some(id => component.snapshotIds.has(id)))
|
|
608
|
+
const component = matching.shift() || { events: [], snapshotIds: new Set() }
|
|
609
|
+
component.events.push(event)
|
|
610
|
+
for (const id of event.snapshotIds) component.snapshotIds.add(id)
|
|
611
|
+
for (const merged of matching) {
|
|
612
|
+
component.events.push(...merged.events)
|
|
613
|
+
for (const id of merged.snapshotIds) component.snapshotIds.add(id)
|
|
614
|
+
components.splice(components.indexOf(merged), 1)
|
|
615
|
+
}
|
|
616
|
+
if (!components.includes(component)) components.push(component)
|
|
617
|
+
}
|
|
618
|
+
return components
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
function readReadyBashOverlaps (root) {
|
|
622
|
+
const events = readBashOverlapEvents(root)
|
|
623
|
+
const stops = new Map()
|
|
624
|
+
for (const stop of readBashSessionStops(root)) {
|
|
625
|
+
stops.set(stop.sessionId, Math.max(stops.get(stop.sessionId) || 0, stop.t))
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
const components = bashOverlapComponents(unresolvedBashOverlapEvents(root, events))
|
|
629
|
+
|
|
630
|
+
return components.flatMap(component => {
|
|
631
|
+
const sessionIds = new Set()
|
|
632
|
+
const completionTimes = new Map()
|
|
633
|
+
const paths = new Map()
|
|
634
|
+
for (const event of component.events.sort((a, b) => a.t - b.t)) {
|
|
635
|
+
for (const sessionId of event.sessionIds || []) sessionIds.add(sessionId)
|
|
636
|
+
if (event.completedSessionId && Number.isFinite(event.completedAt)) {
|
|
637
|
+
completionTimes.set(event.completedSessionId, Math.max(
|
|
638
|
+
completionTimes.get(event.completedSessionId) || 0,
|
|
639
|
+
event.completedAt
|
|
640
|
+
))
|
|
641
|
+
} else {
|
|
642
|
+
for (const sessionId of event.sessionIds || []) {
|
|
643
|
+
completionTimes.set(sessionId, Math.max(completionTimes.get(sessionId) || 0, event.t))
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
for (const item of event.paths || []) paths.set(item.path, item)
|
|
647
|
+
}
|
|
648
|
+
const ready = [...sessionIds].every(sessionId => {
|
|
649
|
+
const completedAt = completionTimes.get(sessionId)
|
|
650
|
+
return Number.isFinite(completedAt) && (stops.get(sessionId) || 0) >= completedAt
|
|
651
|
+
})
|
|
652
|
+
if (!ready) return []
|
|
653
|
+
return [{
|
|
654
|
+
eventIds: component.events.map(event => event.id),
|
|
655
|
+
sessionIds: [...sessionIds].sort(),
|
|
656
|
+
paths: [...paths.values()].sort((a, b) => a.path.localeCompare(b.path))
|
|
657
|
+
}]
|
|
658
|
+
})
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
function compactBashOverlapEvents (root) {
|
|
662
|
+
const overlaps = unresolvedBashOverlapEvents(root)
|
|
663
|
+
const file = bashOverlapPath(root)
|
|
664
|
+
if (!file) return
|
|
665
|
+
if (overlaps.length === 0) {
|
|
666
|
+
try { fs.unlinkSync(file) } catch {}
|
|
667
|
+
for (const stop of readBashSessionStops(root)) {
|
|
668
|
+
try { fs.unlinkSync(bashSessionStopPath(root, stop.sessionId)) } catch {}
|
|
669
|
+
}
|
|
670
|
+
return
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
const referencedSessions = new Set(overlaps.flatMap(event => event.sessionIds || []))
|
|
674
|
+
for (const stop of readBashSessionStops(root)) {
|
|
675
|
+
if (referencedSessions.has(stop.sessionId)) continue
|
|
676
|
+
try { fs.unlinkSync(bashSessionStopPath(root, stop.sessionId)) } catch {}
|
|
677
|
+
}
|
|
678
|
+
ensureDir(path.dirname(file))
|
|
679
|
+
const temporary = file + `.${process.pid}.${crypto.randomUUID()}.tmp`
|
|
680
|
+
try {
|
|
681
|
+
fs.writeFileSync(temporary, overlaps
|
|
682
|
+
.sort((a, b) => a.t - b.t)
|
|
683
|
+
.map(event => JSON.stringify(event)).join('\n') + '\n')
|
|
684
|
+
fs.renameSync(temporary, file)
|
|
685
|
+
} finally {
|
|
686
|
+
try { fs.unlinkSync(temporary) } catch {}
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
function resolveBashOverlap (root, eventIds) {
|
|
691
|
+
appendBashOverlapEvent(root, {
|
|
692
|
+
type: 'resolved',
|
|
693
|
+
root: canonicalRoot(root),
|
|
694
|
+
eventIds,
|
|
695
|
+
t: Date.now()
|
|
696
|
+
})
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
function invalidateBashOverlap (root, eventIds, reason) {
|
|
700
|
+
appendBashOverlapEvent(root, {
|
|
701
|
+
type: 'invalidated',
|
|
702
|
+
root: canonicalRoot(root),
|
|
703
|
+
eventIds,
|
|
704
|
+
reason,
|
|
705
|
+
t: Date.now()
|
|
706
|
+
})
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
function hasActiveBashSnapshot (root) {
|
|
710
|
+
const dir = bashSnapshotDir(root)
|
|
711
|
+
const checkout = canonicalRoot(root)
|
|
712
|
+
let files
|
|
713
|
+
try {
|
|
714
|
+
files = fs.readdirSync(dir)
|
|
715
|
+
} catch {
|
|
716
|
+
return false
|
|
717
|
+
}
|
|
718
|
+
return files.some(file => {
|
|
719
|
+
try {
|
|
720
|
+
const snapshot = JSON.parse(fs.readFileSync(path.join(dir, file), 'utf8'))
|
|
721
|
+
return canonicalRoot(snapshot.root) === checkout && !Number.isFinite(snapshot.endedAt)
|
|
722
|
+
} catch {
|
|
723
|
+
return false
|
|
724
|
+
}
|
|
725
|
+
})
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
function acquireBashOverlapRecoveryLock (root, waitMs = 0) {
|
|
729
|
+
// update-ref supplies the compare-and-swap that a lock file cannot. A stale
|
|
730
|
+
// owner can only be replaced if the ref still points to its exact token.
|
|
731
|
+
// Released token blobs become unreachable and are removed by normal Git GC.
|
|
732
|
+
const ref = bashOverlapRecoveryLockRef(root)
|
|
733
|
+
const gitDir = gitCommonDir(root)
|
|
734
|
+
if (!ref || !gitDir) return null
|
|
735
|
+
const deadline = Date.now() + waitMs
|
|
736
|
+
const owner = JSON.stringify({
|
|
737
|
+
pid: process.pid,
|
|
738
|
+
startIdentity: processStartIdentity(process.pid),
|
|
739
|
+
token: crypto.randomUUID()
|
|
740
|
+
})
|
|
741
|
+
const token = writeRecoveryLockOwner(gitDir, owner)
|
|
742
|
+
if (!token) return null
|
|
743
|
+
const sleeper = new Int32Array(new SharedArrayBuffer(4))
|
|
744
|
+
while (true) {
|
|
745
|
+
if (!fs.existsSync(root)) return null
|
|
746
|
+
const state = readRecoveryLockToken(gitDir, ref)
|
|
747
|
+
if (!state.ok) return null
|
|
748
|
+
const current = state.token
|
|
749
|
+
if ((!current && updateRecoveryLock(gitDir, ref, token, '0'.repeat(token.length))) ||
|
|
750
|
+
(current && !isRecoveryLockOwnerAlive(gitDir, current) && updateRecoveryLock(gitDir, ref, token, current))) {
|
|
751
|
+
return () => {
|
|
752
|
+
deleteRecoveryLock(gitDir, ref, token)
|
|
753
|
+
}
|
|
754
|
+
}
|
|
755
|
+
if (Date.now() >= deadline) return null
|
|
756
|
+
Atomics.wait(sleeper, 0, 0, Math.min(25, deadline - Date.now()))
|
|
757
|
+
}
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
function waitForBashOverlapRecovery (root, waitMs) {
|
|
761
|
+
const ref = bashOverlapRecoveryLockRef(root)
|
|
762
|
+
const gitDir = gitCommonDir(root)
|
|
763
|
+
if (!ref || !gitDir) return false
|
|
764
|
+
const deadline = Date.now() + waitMs
|
|
765
|
+
const sleeper = new Int32Array(new SharedArrayBuffer(4))
|
|
766
|
+
while (true) {
|
|
767
|
+
if (!fs.existsSync(root)) return false
|
|
768
|
+
const state = readRecoveryLockToken(gitDir, ref)
|
|
769
|
+
if (!state.ok) return false
|
|
770
|
+
const current = state.token
|
|
771
|
+
if (state.ok && !current) return true
|
|
772
|
+
if (current && !isRecoveryLockOwnerAlive(gitDir, current) && deleteRecoveryLock(gitDir, ref, current)) continue
|
|
773
|
+
if (Date.now() >= deadline) return false
|
|
774
|
+
Atomics.wait(sleeper, 0, 0, Math.min(25, deadline - Date.now()))
|
|
775
|
+
}
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
function writeRecoveryLockOwner (gitDir, owner) {
|
|
779
|
+
try {
|
|
780
|
+
return execFileSync('git', ['--git-dir', gitDir, 'hash-object', '-w', '--stdin'], {
|
|
781
|
+
cwd: stableGitCwd(gitDir),
|
|
782
|
+
input: owner,
|
|
783
|
+
encoding: 'utf8',
|
|
784
|
+
stdio: ['pipe', 'pipe', 'ignore']
|
|
785
|
+
}).trim() || null
|
|
786
|
+
} catch {
|
|
787
|
+
return null
|
|
788
|
+
}
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
function readRecoveryLockToken (gitDir, ref) {
|
|
792
|
+
try {
|
|
793
|
+
const token = execFileSync('git', ['--git-dir', gitDir, 'rev-parse', '--verify', '--quiet', ref], {
|
|
794
|
+
cwd: stableGitCwd(gitDir),
|
|
795
|
+
encoding: 'utf8',
|
|
796
|
+
stdio: ['ignore', 'pipe', 'ignore']
|
|
797
|
+
}).trim() || null
|
|
798
|
+
return { ok: true, token }
|
|
799
|
+
} catch (error) {
|
|
800
|
+
return { ok: error?.status === 1, token: null }
|
|
801
|
+
}
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
function isRecoveryLockOwnerAlive (gitDir, token) {
|
|
805
|
+
let owner
|
|
806
|
+
try {
|
|
807
|
+
owner = execFileSync('git', ['--git-dir', gitDir, 'cat-file', 'blob', token], {
|
|
808
|
+
cwd: stableGitCwd(gitDir),
|
|
809
|
+
encoding: 'utf8',
|
|
810
|
+
stdio: ['ignore', 'pipe', 'ignore']
|
|
811
|
+
})
|
|
812
|
+
} catch {
|
|
813
|
+
return false
|
|
814
|
+
}
|
|
815
|
+
const parsed = parseRecoveryLockOwner(owner)
|
|
816
|
+
const pid = parsed?.pid
|
|
817
|
+
if (!Number.isInteger(pid) || pid <= 0) return false
|
|
818
|
+
if (parsed.startIdentity) {
|
|
819
|
+
const currentStartIdentity = processStartIdentity(pid)
|
|
820
|
+
if (currentStartIdentity && currentStartIdentity !== parsed.startIdentity) return false
|
|
821
|
+
}
|
|
822
|
+
return isProcessAlive(pid)
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
function updateRecoveryLock (gitDir, ref, token, expected) {
|
|
826
|
+
try {
|
|
827
|
+
execFileSync('git', ['--git-dir', gitDir, 'update-ref', ref, token, expected], {
|
|
828
|
+
cwd: stableGitCwd(gitDir),
|
|
829
|
+
stdio: 'ignore'
|
|
830
|
+
})
|
|
831
|
+
return true
|
|
832
|
+
} catch {
|
|
833
|
+
return false
|
|
834
|
+
}
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
function deleteRecoveryLock (gitDir, ref, expected) {
|
|
838
|
+
try {
|
|
839
|
+
execFileSync('git', ['--git-dir', gitDir, 'update-ref', '-d', ref, expected], {
|
|
840
|
+
cwd: stableGitCwd(gitDir),
|
|
841
|
+
stdio: 'ignore'
|
|
842
|
+
})
|
|
843
|
+
return true
|
|
844
|
+
} catch {
|
|
845
|
+
return false
|
|
846
|
+
}
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
function stableGitCwd (gitDir) {
|
|
850
|
+
const parent = path.dirname(gitDir)
|
|
851
|
+
return fs.existsSync(parent) ? parent : process.cwd()
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
function parseRecoveryLockOwner (owner) {
|
|
855
|
+
try {
|
|
856
|
+
const parsed = JSON.parse(owner)
|
|
857
|
+
if (Number.isInteger(parsed?.pid)) return parsed
|
|
858
|
+
} catch {}
|
|
859
|
+
const pid = Number(owner.split(':', 1)[0])
|
|
860
|
+
return Number.isInteger(pid) ? { pid } : null
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
function processStartIdentity (pid) {
|
|
864
|
+
try {
|
|
865
|
+
return execFileSync('ps', ['-o', 'lstart=', '-p', String(pid)], {
|
|
866
|
+
encoding: 'utf8',
|
|
867
|
+
stdio: ['ignore', 'pipe', 'ignore']
|
|
868
|
+
}).trim() || null
|
|
869
|
+
} catch {
|
|
870
|
+
return null
|
|
871
|
+
}
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
function isProcessAlive (pid) {
|
|
875
|
+
try {
|
|
876
|
+
process.kill(pid, 0)
|
|
877
|
+
return true
|
|
878
|
+
} catch (error) {
|
|
879
|
+
return error?.code !== 'ESRCH'
|
|
880
|
+
}
|
|
881
|
+
}
|
|
882
|
+
|
|
302
883
|
function appendTracking (root, sessionId, entry) {
|
|
303
884
|
const file = trackingPath(root, sessionId)
|
|
304
885
|
if (!file) return
|
|
@@ -306,6 +887,26 @@ function appendTracking (root, sessionId, entry) {
|
|
|
306
887
|
fs.appendFileSync(file, JSON.stringify(entry) + '\n')
|
|
307
888
|
}
|
|
308
889
|
|
|
890
|
+
function savePreclaim (root, sessionId, entry) {
|
|
891
|
+
const dir = preclaimDir(root)
|
|
892
|
+
if (!dir) throw new Error('Cannot persist path ownership outside a git repository')
|
|
893
|
+
ensureDir(dir)
|
|
894
|
+
const file = path.join(dir, `${crypto.randomUUID()}.json`)
|
|
895
|
+
const temporary = file + `.${process.pid}.tmp`
|
|
896
|
+
try {
|
|
897
|
+
fs.writeFileSync(temporary, JSON.stringify({ sessionId, entry }) + '\n')
|
|
898
|
+
fs.renameSync(temporary, file)
|
|
899
|
+
return file
|
|
900
|
+
} finally {
|
|
901
|
+
try { fs.unlinkSync(temporary) } catch {}
|
|
902
|
+
}
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
function removePreclaim (file) {
|
|
906
|
+
if (!file) return
|
|
907
|
+
try { fs.unlinkSync(file) } catch {}
|
|
908
|
+
}
|
|
909
|
+
|
|
309
910
|
function parseInput (input) {
|
|
310
911
|
try {
|
|
311
912
|
return JSON.parse(input)
|
|
@@ -349,11 +950,41 @@ function cleanupTracking (root, sessionId) {
|
|
|
349
950
|
try {
|
|
350
951
|
fs.unlinkSync(trackingPath(root, sessionId))
|
|
351
952
|
} catch {}
|
|
953
|
+
const dir = preclaimDir(root)
|
|
954
|
+
let files
|
|
955
|
+
try {
|
|
956
|
+
files = fs.readdirSync(dir)
|
|
957
|
+
} catch {
|
|
958
|
+
return
|
|
959
|
+
}
|
|
960
|
+
for (const file of files) {
|
|
961
|
+
if (!file.endsWith('.json')) continue
|
|
962
|
+
try {
|
|
963
|
+
const fullPath = path.join(dir, file)
|
|
964
|
+
const claim = JSON.parse(fs.readFileSync(fullPath, 'utf8'))
|
|
965
|
+
if (claim.sessionId === sessionId) fs.unlinkSync(fullPath)
|
|
966
|
+
} catch {}
|
|
967
|
+
}
|
|
352
968
|
}
|
|
353
969
|
|
|
354
970
|
module.exports = {
|
|
355
971
|
handleTrack,
|
|
356
972
|
handlePostTrack,
|
|
973
|
+
finalizeBashSnapshots,
|
|
974
|
+
pruneBashSnapshots,
|
|
975
|
+
recordBashSessionStop,
|
|
976
|
+
readBashOverlapEvents,
|
|
977
|
+
readBashSessionStops,
|
|
978
|
+
readReadyBashOverlaps,
|
|
979
|
+
compactBashOverlapEvents,
|
|
980
|
+
resolveBashOverlap,
|
|
981
|
+
invalidateBashOverlap,
|
|
982
|
+
hasActiveBashSnapshot,
|
|
983
|
+
acquireBashOverlapRecoveryLock,
|
|
984
|
+
bashOverlapRecoveryLockRef,
|
|
985
|
+
bashSnapshotPath,
|
|
986
|
+
fingerprintTrackedPath,
|
|
987
|
+
claimedPathsBySessions,
|
|
357
988
|
hasTrackedModifications,
|
|
358
989
|
cleanupTracking,
|
|
359
990
|
extractFilePath,
|