@amoghvj/txr 0.1.1 → 0.2.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/README.md +4 -11
- package/dist/chunk-3RG5ZIWI.js +10 -0
- package/dist/chunk-6SSBMHMQ.js +185 -0
- package/dist/chunk-KXCWVEEG.js +47 -0
- package/dist/{chunk-XKFFIQXW.js → chunk-MCJNEIJH.js} +0 -8
- package/dist/external-detector-5TT634UP.js +7 -0
- package/dist/git-store-MUETZYUT.js +7 -0
- package/dist/history-store-2H73O75H.js +7 -0
- package/dist/index.js +670 -188
- package/package.json +2 -1
- package/dist/git-store-XSOIM4KZ.js +0 -6
package/dist/index.js
CHANGED
|
@@ -1,11 +1,19 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import {
|
|
3
|
-
GitStore
|
|
3
|
+
GitStore
|
|
4
|
+
} from "./chunk-MCJNEIJH.js";
|
|
5
|
+
import {
|
|
6
|
+
HistoryStore
|
|
7
|
+
} from "./chunk-KXCWVEEG.js";
|
|
8
|
+
import {
|
|
9
|
+
ExternalChangeDetector
|
|
10
|
+
} from "./chunk-6SSBMHMQ.js";
|
|
11
|
+
import {
|
|
4
12
|
__require
|
|
5
|
-
} from "./chunk-
|
|
13
|
+
} from "./chunk-3RG5ZIWI.js";
|
|
6
14
|
|
|
7
15
|
// src/index.ts
|
|
8
|
-
import { Command } from "commander";
|
|
16
|
+
import { Command as Command3 } from "commander";
|
|
9
17
|
|
|
10
18
|
// src/cli/commands/init.ts
|
|
11
19
|
import * as fs from "fs/promises";
|
|
@@ -77,8 +85,8 @@ async function findTxrRoot(startDir) {
|
|
|
77
85
|
while (true) {
|
|
78
86
|
const candidate = path.join(dir, TXR_DIR);
|
|
79
87
|
try {
|
|
80
|
-
const
|
|
81
|
-
if (
|
|
88
|
+
const stat4 = await fs.stat(candidate);
|
|
89
|
+
if (stat4.isDirectory()) return dir;
|
|
82
90
|
} catch {
|
|
83
91
|
}
|
|
84
92
|
const parent = path.dirname(dir);
|
|
@@ -126,34 +134,89 @@ var TransactionStore = class _TransactionStore {
|
|
|
126
134
|
this.filePath = filePath;
|
|
127
135
|
this.data = data;
|
|
128
136
|
}
|
|
129
|
-
/** Load from disk, or create empty store. */
|
|
137
|
+
/** Load from disk, migrating v1 → v2 if needed, or create empty store. */
|
|
130
138
|
static async load(metadataDir) {
|
|
131
139
|
const filePath = path2.join(metadataDir, "transactions.json");
|
|
132
140
|
try {
|
|
133
141
|
const raw = await fs2.readFile(filePath, "utf-8");
|
|
134
|
-
const
|
|
135
|
-
|
|
142
|
+
const raw_data = JSON.parse(raw);
|
|
143
|
+
const data = _TransactionStore.migrate(raw_data);
|
|
144
|
+
const store = new _TransactionStore(filePath, data);
|
|
145
|
+
store.gc();
|
|
146
|
+
return store;
|
|
136
147
|
} catch {
|
|
137
148
|
const data = {
|
|
138
|
-
version:
|
|
149
|
+
version: 2,
|
|
139
150
|
head: null,
|
|
140
151
|
undoStack: [],
|
|
141
152
|
counter: 0,
|
|
153
|
+
extCounter: 0,
|
|
142
154
|
transactions: {}
|
|
143
155
|
};
|
|
144
156
|
return new _TransactionStore(filePath, data);
|
|
145
157
|
}
|
|
146
158
|
}
|
|
159
|
+
/**
|
|
160
|
+
* Migrate from v1 schema (no `type`, no `extCounter`) to v2.
|
|
161
|
+
* All v1 transactions are assumed to be type 'U' (undoable).
|
|
162
|
+
* Migration is in-memory; file is written on next save().
|
|
163
|
+
*/
|
|
164
|
+
static migrate(raw) {
|
|
165
|
+
if (raw.version === 2) return raw;
|
|
166
|
+
const transactions = raw.transactions ?? {};
|
|
167
|
+
const normalized = Array.isArray(transactions) ? Object.fromEntries(transactions.map((t) => [t.id, t])) : transactions;
|
|
168
|
+
for (const tx of Object.values(normalized)) {
|
|
169
|
+
if (!tx.type) tx.type = "U";
|
|
170
|
+
if (tx.source === void 0) tx.source = "txr-run";
|
|
171
|
+
if (tx.command === void 0) tx.command = tx.command ?? null;
|
|
172
|
+
if (tx.exitCode === void 0) tx.exitCode = tx.exitCode ?? null;
|
|
173
|
+
if (tx.attributionTier === void 0) tx.attributionTier = tx.attributionTier ?? null;
|
|
174
|
+
}
|
|
175
|
+
return {
|
|
176
|
+
version: 2,
|
|
177
|
+
head: raw.head ?? null,
|
|
178
|
+
undoStack: raw.undoStack ?? [],
|
|
179
|
+
counter: raw.counter ?? 0,
|
|
180
|
+
extCounter: 0,
|
|
181
|
+
transactions: normalized
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
/** Run garbage collection to remove any orphaned transactions not reachable from HEAD or the undo stack. */
|
|
185
|
+
gc() {
|
|
186
|
+
const reachable = /* @__PURE__ */ new Set();
|
|
187
|
+
let current = this.data.head;
|
|
188
|
+
while (current) {
|
|
189
|
+
reachable.add(current);
|
|
190
|
+
current = this.data.transactions[current]?.parent ?? null;
|
|
191
|
+
}
|
|
192
|
+
for (const id of this.data.undoStack) {
|
|
193
|
+
let currentUndo = id;
|
|
194
|
+
while (currentUndo) {
|
|
195
|
+
reachable.add(currentUndo);
|
|
196
|
+
currentUndo = this.data.transactions[currentUndo]?.parent ?? null;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
for (const id of Object.keys(this.data.transactions)) {
|
|
200
|
+
if (!reachable.has(id)) {
|
|
201
|
+
delete this.data.transactions[id];
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
}
|
|
147
205
|
async save() {
|
|
148
206
|
const tmp = this.filePath + ".tmp";
|
|
149
207
|
await fs2.writeFile(tmp, JSON.stringify(this.data, null, 2), "utf-8");
|
|
150
208
|
await fs2.rename(tmp, this.filePath);
|
|
151
209
|
}
|
|
152
|
-
/** Generate the next transaction ID (monotonically increasing). */
|
|
210
|
+
/** Generate the next user transaction ID (monotonically increasing). */
|
|
153
211
|
nextId() {
|
|
154
212
|
this.data.counter += 1;
|
|
155
213
|
return `tx_${String(this.data.counter).padStart(3, "0")}`;
|
|
156
214
|
}
|
|
215
|
+
/** Generate the next external change (E) transaction ID. */
|
|
216
|
+
nextExtId() {
|
|
217
|
+
this.data.extCounter += 1;
|
|
218
|
+
return `ext_${String(this.data.extCounter).padStart(3, "0")}`;
|
|
219
|
+
}
|
|
157
220
|
/** Get the current HEAD transaction ID, or null if no transactions exist. */
|
|
158
221
|
get head() {
|
|
159
222
|
return this.data.head;
|
|
@@ -170,15 +233,31 @@ var TransactionStore = class _TransactionStore {
|
|
|
170
233
|
get undoStack() {
|
|
171
234
|
return this.data.undoStack;
|
|
172
235
|
}
|
|
173
|
-
/**
|
|
236
|
+
/**
|
|
237
|
+
* Add a new user-initiated transaction (U or P) and set it as HEAD.
|
|
238
|
+
* Clears the undo stack — new user action invalidates redo history.
|
|
239
|
+
*/
|
|
174
240
|
addTransaction(tx) {
|
|
241
|
+
const discarded = this.clearUndoStack();
|
|
242
|
+
this.data.transactions[tx.id] = tx;
|
|
243
|
+
this.data.head = tx.id;
|
|
244
|
+
return discarded;
|
|
245
|
+
}
|
|
246
|
+
/**
|
|
247
|
+
* Add an automatically detected external change transaction (E) and set it as HEAD.
|
|
248
|
+
* Does NOT clear the undo stack.
|
|
249
|
+
*
|
|
250
|
+
* Rationale: External changes are not user-initiated. They should not destroy
|
|
251
|
+
* the user's redo chain. However, if the redo operation itself runs detection
|
|
252
|
+
* and finds an E, it WILL clear the undo stack at that point (in UndoEngine).
|
|
253
|
+
*/
|
|
254
|
+
addExternalTransaction(tx) {
|
|
175
255
|
this.data.transactions[tx.id] = tx;
|
|
176
256
|
this.data.head = tx.id;
|
|
177
|
-
this.data.undoStack = [];
|
|
178
257
|
}
|
|
179
258
|
/**
|
|
180
259
|
* Undo: move HEAD to the parent of the current HEAD.
|
|
181
|
-
* Pushes the undone transaction ID onto the undo stack.
|
|
260
|
+
* Pushes the undone transaction ID onto the undo stack for potential redo.
|
|
182
261
|
*/
|
|
183
262
|
undoHead() {
|
|
184
263
|
const head = this.data.head;
|
|
@@ -203,6 +282,15 @@ var TransactionStore = class _TransactionStore {
|
|
|
203
282
|
this.data.head = redoId;
|
|
204
283
|
return tx;
|
|
205
284
|
}
|
|
285
|
+
/** Clear the undo stack, permanently delete detached transactions, and return their IDs. */
|
|
286
|
+
clearUndoStack() {
|
|
287
|
+
const discarded = [...this.data.undoStack];
|
|
288
|
+
for (const id of discarded) {
|
|
289
|
+
delete this.data.transactions[id];
|
|
290
|
+
}
|
|
291
|
+
this.data.undoStack = [];
|
|
292
|
+
return discarded;
|
|
293
|
+
}
|
|
206
294
|
/** Get all transactions in chain order (oldest first), up to and including HEAD. */
|
|
207
295
|
getChain() {
|
|
208
296
|
const chain = [];
|
|
@@ -225,53 +313,9 @@ var TransactionStore = class _TransactionStore {
|
|
|
225
313
|
}
|
|
226
314
|
};
|
|
227
315
|
|
|
228
|
-
// src/core/
|
|
316
|
+
// src/core/file-map.ts
|
|
229
317
|
import * as fs3 from "fs/promises";
|
|
230
318
|
import * as path3 from "path";
|
|
231
|
-
var HistoryStore = class _HistoryStore {
|
|
232
|
-
data;
|
|
233
|
-
filePath;
|
|
234
|
-
constructor(filePath, data) {
|
|
235
|
-
this.filePath = filePath;
|
|
236
|
-
this.data = data;
|
|
237
|
-
}
|
|
238
|
-
static async load(metadataDir) {
|
|
239
|
-
const filePath = path3.join(metadataDir, "history.json");
|
|
240
|
-
try {
|
|
241
|
-
const raw = await fs3.readFile(filePath, "utf-8");
|
|
242
|
-
const data = JSON.parse(raw);
|
|
243
|
-
return new _HistoryStore(filePath, data);
|
|
244
|
-
} catch {
|
|
245
|
-
const data = { version: 1, entries: [] };
|
|
246
|
-
return new _HistoryStore(filePath, data);
|
|
247
|
-
}
|
|
248
|
-
}
|
|
249
|
-
async save() {
|
|
250
|
-
const tmp = this.filePath + ".tmp";
|
|
251
|
-
await fs3.writeFile(tmp, JSON.stringify(this.data, null, 2), "utf-8");
|
|
252
|
-
await fs3.rename(tmp, this.filePath);
|
|
253
|
-
}
|
|
254
|
-
/** Append a new history entry. */
|
|
255
|
-
append(entry) {
|
|
256
|
-
this.data.entries.push(entry);
|
|
257
|
-
}
|
|
258
|
-
/** Get all entries (oldest first). */
|
|
259
|
-
getAll() {
|
|
260
|
-
return this.data.entries;
|
|
261
|
-
}
|
|
262
|
-
/** Get the N most recent entries. */
|
|
263
|
-
getRecent(n) {
|
|
264
|
-
return this.data.entries.slice(-n);
|
|
265
|
-
}
|
|
266
|
-
/** Get total entry count. */
|
|
267
|
-
get count() {
|
|
268
|
-
return this.data.entries.length;
|
|
269
|
-
}
|
|
270
|
-
};
|
|
271
|
-
|
|
272
|
-
// src/core/file-map.ts
|
|
273
|
-
import * as fs4 from "fs/promises";
|
|
274
|
-
import * as path4 from "path";
|
|
275
319
|
import { nanoid } from "nanoid";
|
|
276
320
|
var FileMap = class _FileMap {
|
|
277
321
|
data;
|
|
@@ -286,9 +330,9 @@ var FileMap = class _FileMap {
|
|
|
286
330
|
}
|
|
287
331
|
/** Load mapping from disk, or create empty mapping if file doesn't exist. */
|
|
288
332
|
static async load(metadataDir) {
|
|
289
|
-
const filePath =
|
|
333
|
+
const filePath = path3.join(metadataDir, "mapping.json");
|
|
290
334
|
try {
|
|
291
|
-
const raw = await
|
|
335
|
+
const raw = await fs3.readFile(filePath, "utf-8");
|
|
292
336
|
const data = JSON.parse(raw);
|
|
293
337
|
return new _FileMap(filePath, data);
|
|
294
338
|
} catch {
|
|
@@ -299,12 +343,12 @@ var FileMap = class _FileMap {
|
|
|
299
343
|
/** Persist current state to disk atomically. */
|
|
300
344
|
async save() {
|
|
301
345
|
const tmp = this.filePath + ".tmp";
|
|
302
|
-
await
|
|
303
|
-
await
|
|
346
|
+
await fs3.writeFile(tmp, JSON.stringify(this.data, null, 2), "utf-8");
|
|
347
|
+
await fs3.rename(tmp, this.filePath);
|
|
304
348
|
}
|
|
305
349
|
/** Normalize a path for consistent lookup (forward slashes, resolved). */
|
|
306
350
|
static normalizePath(p) {
|
|
307
|
-
return
|
|
351
|
+
return path3.resolve(p).replace(/\\/g, "/");
|
|
308
352
|
}
|
|
309
353
|
/** Get file ID for a path, or undefined if not mapped. Only returns active (non-deleted) entries. */
|
|
310
354
|
getIdByPath(absolutePath) {
|
|
@@ -369,6 +413,35 @@ var FileMap = class _FileMap {
|
|
|
369
413
|
get activeCount() {
|
|
370
414
|
return this.pathIndex.size;
|
|
371
415
|
}
|
|
416
|
+
/**
|
|
417
|
+
* Register a brand-new file discovered externally, with a pre-assigned fileId.
|
|
418
|
+
* Used by ExternalChangeDetector for files that have never been seen by TXR.
|
|
419
|
+
*/
|
|
420
|
+
registerNewFile(absolutePath, fileId, transactionId) {
|
|
421
|
+
const norm = _FileMap.normalizePath(absolutePath);
|
|
422
|
+
this.data.files[fileId] = {
|
|
423
|
+
path: norm,
|
|
424
|
+
createdAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
425
|
+
createdByTx: transactionId
|
|
426
|
+
};
|
|
427
|
+
this.pathIndex.set(norm, fileId);
|
|
428
|
+
}
|
|
429
|
+
/**
|
|
430
|
+
* Untrack files that were created by the given transaction IDs.
|
|
431
|
+
* Called when the undo stack is cleared to discard abandoned file histories.
|
|
432
|
+
*/
|
|
433
|
+
untrackByTransactions(transactionIds) {
|
|
434
|
+
const txSet = new Set(transactionIds);
|
|
435
|
+
for (const [id, entry] of Object.entries(this.data.files)) {
|
|
436
|
+
if (txSet.has(entry.createdByTx)) {
|
|
437
|
+
delete this.data.files[id];
|
|
438
|
+
const norm = _FileMap.normalizePath(entry.path);
|
|
439
|
+
if (this.pathIndex.get(norm) === id) {
|
|
440
|
+
this.pathIndex.delete(norm);
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
}
|
|
372
445
|
/** Rebuild the in-memory path→ID index from persisted data. */
|
|
373
446
|
rebuildPathIndex() {
|
|
374
447
|
this.pathIndex.clear();
|
|
@@ -382,12 +455,12 @@ var FileMap = class _FileMap {
|
|
|
382
455
|
};
|
|
383
456
|
|
|
384
457
|
// src/core/transaction-engine.ts
|
|
385
|
-
import * as
|
|
386
|
-
import * as
|
|
458
|
+
import * as fs5 from "fs/promises";
|
|
459
|
+
import * as path6 from "path";
|
|
387
460
|
|
|
388
461
|
// src/attribution/tier3-hybrid.ts
|
|
389
|
-
import * as
|
|
390
|
-
import * as
|
|
462
|
+
import * as fs4 from "fs/promises";
|
|
463
|
+
import * as path4 from "path";
|
|
391
464
|
import * as crypto from "crypto";
|
|
392
465
|
import { spawn } from "child_process";
|
|
393
466
|
import { minimatch } from "minimatch";
|
|
@@ -402,7 +475,7 @@ var Tier3HybridProvider = class {
|
|
|
402
475
|
"Using scoped filesystem diff (Tier 3). Changes by concurrent processes within watch paths may be mis-attributed."
|
|
403
476
|
];
|
|
404
477
|
const resolvedWatchPaths = options.watchPaths.map(
|
|
405
|
-
(p) =>
|
|
478
|
+
(p) => path4.isAbsolute(p) ? p : path4.resolve(cwd, p)
|
|
406
479
|
);
|
|
407
480
|
const preSnapshot = await this.snapshotPaths(resolvedWatchPaths, options.ignorePaths);
|
|
408
481
|
const exitCode = await this.executeCommand(command, cwd, options);
|
|
@@ -428,36 +501,36 @@ var Tier3HybridProvider = class {
|
|
|
428
501
|
return snapshots;
|
|
429
502
|
}
|
|
430
503
|
async walkAndHash(currentPath, rootPath, ignorePaths, snapshots) {
|
|
431
|
-
let
|
|
504
|
+
let stat4;
|
|
432
505
|
try {
|
|
433
|
-
|
|
506
|
+
stat4 = await fs4.stat(currentPath);
|
|
434
507
|
} catch {
|
|
435
508
|
return;
|
|
436
509
|
}
|
|
437
510
|
const normalized = currentPath.replace(/\\/g, "/");
|
|
438
|
-
const relativePath =
|
|
511
|
+
const relativePath = path4.relative(rootPath, currentPath).replace(/\\/g, "/");
|
|
439
512
|
for (const pattern of ignorePaths) {
|
|
440
|
-
if (minimatch(relativePath, pattern, { dot: true }) || minimatch(
|
|
513
|
+
if (minimatch(relativePath, pattern, { dot: true }) || minimatch(path4.basename(currentPath), pattern, { dot: true })) {
|
|
441
514
|
return;
|
|
442
515
|
}
|
|
443
516
|
}
|
|
444
|
-
if (
|
|
517
|
+
if (stat4.isFile()) {
|
|
445
518
|
const hash = await this.hashFile(currentPath);
|
|
446
519
|
snapshots.set(normalized, {
|
|
447
520
|
path: normalized,
|
|
448
521
|
hash,
|
|
449
|
-
size:
|
|
522
|
+
size: stat4.size
|
|
450
523
|
});
|
|
451
|
-
} else if (
|
|
524
|
+
} else if (stat4.isDirectory()) {
|
|
452
525
|
let entries;
|
|
453
526
|
try {
|
|
454
|
-
entries = await
|
|
527
|
+
entries = await fs4.readdir(currentPath);
|
|
455
528
|
} catch {
|
|
456
529
|
return;
|
|
457
530
|
}
|
|
458
531
|
for (const entry of entries) {
|
|
459
532
|
await this.walkAndHash(
|
|
460
|
-
|
|
533
|
+
path4.join(currentPath, entry),
|
|
461
534
|
rootPath,
|
|
462
535
|
ignorePaths,
|
|
463
536
|
snapshots
|
|
@@ -466,7 +539,7 @@ var Tier3HybridProvider = class {
|
|
|
466
539
|
}
|
|
467
540
|
}
|
|
468
541
|
async hashFile(filePath) {
|
|
469
|
-
const content = await
|
|
542
|
+
const content = await fs4.readFile(filePath);
|
|
470
543
|
return crypto.createHash("sha256").update(content).digest("hex");
|
|
471
544
|
}
|
|
472
545
|
/**
|
|
@@ -500,7 +573,7 @@ var Tier3HybridProvider = class {
|
|
|
500
573
|
* Uses shell execution to support piping, env expansion, etc.
|
|
501
574
|
*/
|
|
502
575
|
executeCommand(command, cwd, options) {
|
|
503
|
-
return new Promise((
|
|
576
|
+
return new Promise((resolve7, reject) => {
|
|
504
577
|
const isWindows = process.platform === "win32";
|
|
505
578
|
const shell = isWindows ? "cmd.exe" : "/bin/sh";
|
|
506
579
|
const shellArgs = isWindows ? ["/c", command] : ["-c", command];
|
|
@@ -515,7 +588,7 @@ var Tier3HybridProvider = class {
|
|
|
515
588
|
});
|
|
516
589
|
child.on("error", reject);
|
|
517
590
|
child.on("close", (code) => {
|
|
518
|
-
|
|
591
|
+
resolve7(code ?? 1);
|
|
519
592
|
});
|
|
520
593
|
});
|
|
521
594
|
}
|
|
@@ -549,19 +622,22 @@ function getOrderedProviders() {
|
|
|
549
622
|
}
|
|
550
623
|
|
|
551
624
|
// src/classifier/classifier.ts
|
|
552
|
-
import * as
|
|
625
|
+
import * as path5 from "path";
|
|
553
626
|
var DEFAULT_RULES = [
|
|
554
627
|
// ── Containers & Orchestration ──
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
{ pattern: /^docker
|
|
558
|
-
{ pattern: /^
|
|
559
|
-
{ pattern: /^
|
|
560
|
-
{ pattern: /^
|
|
628
|
+
// These commands write to container layers / volumes (filesystem trackable)
|
|
629
|
+
// but also modify container/network state (not filesystem — partial undo)
|
|
630
|
+
{ pattern: /^docker\b/, type: "partially-transactional", category: "container", reason: "Container orchestration \u2014 modifies container/image state outside TXR rollback boundary" },
|
|
631
|
+
{ pattern: /^docker-compose\b/, type: "partially-transactional", category: "container", reason: "Container orchestration \u2014 modifies container/image state outside TXR rollback boundary" },
|
|
632
|
+
{ pattern: /^docker compose\b/, type: "partially-transactional", category: "container", reason: "Container orchestration \u2014 modifies container/image state outside TXR rollback boundary" },
|
|
633
|
+
{ pattern: /^podman\b/, type: "partially-transactional", category: "container", reason: "Container orchestration \u2014 modifies container/image state outside TXR rollback boundary" },
|
|
634
|
+
{ pattern: /^kubectl\b/, type: "non-transactional", category: "container", reason: "Kubernetes cluster management \u2014 no local filesystem changes" },
|
|
635
|
+
{ pattern: /^helm\b/, type: "non-transactional", category: "container", reason: "Helm chart management \u2014 no local filesystem changes" },
|
|
561
636
|
// ── Databases ──
|
|
562
|
-
|
|
563
|
-
{ pattern: /^
|
|
564
|
-
{ pattern: /^(
|
|
637
|
+
// Database clients may write local files (e.g., .sql dumps, migration files) but also modify DB state
|
|
638
|
+
{ pattern: /^(mysql|psql|mongosh?|redis-cli|sqlite3)\b/, type: "partially-transactional", category: "database", reason: "Database client \u2014 modifies database state outside TXR rollback boundary" },
|
|
639
|
+
{ pattern: /^prisma\s+(migrate|db\s+push|db\s+seed)/, type: "partially-transactional", category: "database", reason: "Database migration/seeding \u2014 modifies database state outside TXR rollback boundary" },
|
|
640
|
+
{ pattern: /^(knex|sequelize|typeorm)\s+migrate/, type: "partially-transactional", category: "database", reason: "Database migration \u2014 modifies database state outside TXR rollback boundary" },
|
|
565
641
|
// ── Services & Daemons ──
|
|
566
642
|
{ pattern: /^(systemctl|service)\b/, type: "non-transactional", category: "service", reason: "System service management" },
|
|
567
643
|
{ pattern: /^sc\s+(start|stop|config|create|delete)\b/, type: "non-transactional", category: "service", reason: "Windows service management" },
|
|
@@ -635,18 +711,18 @@ function classifyCommand(command, scope = "workspace", projectRoot, extraRules =
|
|
|
635
711
|
return { type: "transactional" };
|
|
636
712
|
}
|
|
637
713
|
function detectPathEscape(command, projectRoot) {
|
|
638
|
-
const normalizedRoot =
|
|
714
|
+
const normalizedRoot = path5.resolve(projectRoot).replace(/\\/g, "/").toLowerCase();
|
|
639
715
|
const tokens = command.split(/\s+/);
|
|
640
716
|
for (const token of tokens) {
|
|
641
717
|
const cleaned = token.replace(/["']/g, "");
|
|
642
718
|
if (cleaned.startsWith("/") && !cleaned.startsWith("/dev/null")) {
|
|
643
|
-
const normalizedToken =
|
|
719
|
+
const normalizedToken = path5.resolve(cleaned).replace(/\\/g, "/").toLowerCase();
|
|
644
720
|
if (!normalizedToken.startsWith(normalizedRoot)) {
|
|
645
721
|
return `Path "${cleaned}" is outside the workspace root (${projectRoot})`;
|
|
646
722
|
}
|
|
647
723
|
}
|
|
648
724
|
if (/^[A-Za-z]:[\\/]/.test(cleaned)) {
|
|
649
|
-
const normalizedToken =
|
|
725
|
+
const normalizedToken = path5.resolve(cleaned).replace(/\\/g, "/").toLowerCase();
|
|
650
726
|
if (!normalizedToken.startsWith(normalizedRoot)) {
|
|
651
727
|
return `Path "${cleaned}" is outside the workspace root (${projectRoot})`;
|
|
652
728
|
}
|
|
@@ -679,7 +755,19 @@ var TransactionEngine = class {
|
|
|
679
755
|
*/
|
|
680
756
|
async execute(command, cwd, options) {
|
|
681
757
|
const startTime = Date.now();
|
|
758
|
+
const detector = new ExternalChangeDetector(
|
|
759
|
+
this.txStore,
|
|
760
|
+
this.fileMap,
|
|
761
|
+
this.gitStore,
|
|
762
|
+
this.config,
|
|
763
|
+
this.projectRoot
|
|
764
|
+
);
|
|
765
|
+
await detector.detectAndRecord();
|
|
682
766
|
const classification = options?.forceTransactional ? { type: "transactional" } : options?.forceNonTransactional ? { type: "non-transactional", reason: "User override" } : classifyCommand(command, this.config.scope, this.projectRoot);
|
|
767
|
+
let txType = "U";
|
|
768
|
+
if (classification.type === "partially-transactional") {
|
|
769
|
+
txType = "P";
|
|
770
|
+
}
|
|
683
771
|
if (classification.type === "non-transactional" || classification.type === "scope-external") {
|
|
684
772
|
const exitCode = await this.executeDirectly(command, cwd);
|
|
685
773
|
const durationMs2 = Date.now() - startTime;
|
|
@@ -691,14 +779,15 @@ var TransactionEngine = class {
|
|
|
691
779
|
exitCode,
|
|
692
780
|
classification: classification.type,
|
|
693
781
|
attributionTier: null,
|
|
694
|
-
durationMs: durationMs2
|
|
782
|
+
durationMs: durationMs2,
|
|
783
|
+
actionType: null
|
|
695
784
|
});
|
|
696
785
|
await this.historyStore.save();
|
|
697
786
|
return null;
|
|
698
787
|
}
|
|
699
788
|
const provider = await selectProvider(this.config.preferredTier);
|
|
700
789
|
const resolvedWatchPaths = this.config.watchPaths.map(
|
|
701
|
-
(p) =>
|
|
790
|
+
(p) => path6.isAbsolute(p) ? p : path6.resolve(cwd, p)
|
|
702
791
|
);
|
|
703
792
|
const result = await provider.executeAndTrack(command, cwd, {
|
|
704
793
|
watchPaths: resolvedWatchPaths,
|
|
@@ -710,7 +799,7 @@ var TransactionEngine = class {
|
|
|
710
799
|
const scopeWarnings = [];
|
|
711
800
|
let trackedChanges;
|
|
712
801
|
if (this.config.scope === "workspace") {
|
|
713
|
-
const normalizedRoot =
|
|
802
|
+
const normalizedRoot = path6.resolve(this.projectRoot).replace(/\\/g, "/").toLowerCase();
|
|
714
803
|
trackedChanges = [];
|
|
715
804
|
for (const change of result.changes) {
|
|
716
805
|
const normalizedChange = change.absolutePath.replace(/\\/g, "/").toLowerCase();
|
|
@@ -740,7 +829,8 @@ var TransactionEngine = class {
|
|
|
740
829
|
exitCode: result.processExitCode,
|
|
741
830
|
classification: "transactional",
|
|
742
831
|
attributionTier: result.tier,
|
|
743
|
-
durationMs: durationMs2
|
|
832
|
+
durationMs: durationMs2,
|
|
833
|
+
actionType: null
|
|
744
834
|
});
|
|
745
835
|
await this.historyStore.save();
|
|
746
836
|
for (const w of scopeWarnings) {
|
|
@@ -749,6 +839,10 @@ var TransactionEngine = class {
|
|
|
749
839
|
}
|
|
750
840
|
return null;
|
|
751
841
|
}
|
|
842
|
+
const discarded = this.txStore.clearUndoStack();
|
|
843
|
+
if (discarded.length > 0) {
|
|
844
|
+
this.fileMap.untrackByTransactions(discarded);
|
|
845
|
+
}
|
|
752
846
|
const txId = this.txStore.nextId();
|
|
753
847
|
const touchedFiles = [];
|
|
754
848
|
const changeManifest = {};
|
|
@@ -759,7 +853,7 @@ var TransactionEngine = class {
|
|
|
759
853
|
if (change.type === "deleted" /* Deleted */) {
|
|
760
854
|
await this.gitStore.removeFile(fileId);
|
|
761
855
|
} else {
|
|
762
|
-
const content = await
|
|
856
|
+
const content = await fs5.readFile(change.absolutePath);
|
|
763
857
|
await this.gitStore.writeFile(fileId, content);
|
|
764
858
|
}
|
|
765
859
|
}
|
|
@@ -767,19 +861,27 @@ var TransactionEngine = class {
|
|
|
767
861
|
const commitHash = await this.gitStore.commit(commitMessage);
|
|
768
862
|
const tx = {
|
|
769
863
|
id: txId,
|
|
864
|
+
type: txType,
|
|
770
865
|
parent: this.txStore.head,
|
|
771
866
|
commit: commitHash,
|
|
772
867
|
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
773
868
|
command,
|
|
869
|
+
source: "txr-run",
|
|
774
870
|
touchedFiles,
|
|
775
871
|
changeManifest,
|
|
776
872
|
workingDirectory: cwd,
|
|
777
873
|
exitCode: result.processExitCode,
|
|
778
874
|
attributionTier: result.tier,
|
|
779
875
|
scope: this.config.scope,
|
|
780
|
-
scopeWarnings
|
|
876
|
+
scopeWarnings,
|
|
877
|
+
...txType === "P" && {
|
|
878
|
+
partialUndoReason: classification.reason ?? "Command has known side effects outside the filesystem."
|
|
879
|
+
}
|
|
781
880
|
};
|
|
782
|
-
this.txStore.addTransaction(tx);
|
|
881
|
+
const discarded2 = this.txStore.addTransaction(tx);
|
|
882
|
+
if (discarded2.length > 0) {
|
|
883
|
+
this.fileMap.untrackByTransactions(discarded2);
|
|
884
|
+
}
|
|
783
885
|
await this.txStore.save();
|
|
784
886
|
await this.fileMap.save();
|
|
785
887
|
const durationMs = Date.now() - startTime;
|
|
@@ -789,18 +891,26 @@ var TransactionEngine = class {
|
|
|
789
891
|
transactionId: txId,
|
|
790
892
|
workingDirectory: cwd,
|
|
791
893
|
exitCode: result.processExitCode,
|
|
792
|
-
classification: "transactional",
|
|
894
|
+
classification: txType === "P" ? "partially-transactional" : "transactional",
|
|
793
895
|
attributionTier: result.tier,
|
|
794
|
-
durationMs
|
|
896
|
+
durationMs,
|
|
897
|
+
actionType: txType
|
|
795
898
|
});
|
|
796
899
|
await this.historyStore.save();
|
|
900
|
+
if (scopeWarnings.length > 0) {
|
|
901
|
+
for (const w of scopeWarnings) {
|
|
902
|
+
process.stderr.write(`\x1B[33m\u26A0\x1B[0m ${w}
|
|
903
|
+
`);
|
|
904
|
+
}
|
|
905
|
+
}
|
|
797
906
|
return {
|
|
798
907
|
transactionId: txId,
|
|
799
908
|
commit: commitHash,
|
|
800
909
|
changesCount: trackedChanges.length,
|
|
801
910
|
tier: result.tier,
|
|
802
911
|
durationMs,
|
|
803
|
-
scopeWarnings
|
|
912
|
+
scopeWarnings,
|
|
913
|
+
actionType: txType
|
|
804
914
|
};
|
|
805
915
|
}
|
|
806
916
|
/** Resolve a FileChange to a file ID (creating new IDs as needed). */
|
|
@@ -832,16 +942,148 @@ var TransactionEngine = class {
|
|
|
832
942
|
return "deleted";
|
|
833
943
|
case "renamed" /* Renamed */:
|
|
834
944
|
return "modified";
|
|
945
|
+
default:
|
|
946
|
+
return "modified";
|
|
835
947
|
}
|
|
836
948
|
}
|
|
837
949
|
/** Truncate command for commit message. */
|
|
838
950
|
summarizeCommand(command) {
|
|
839
951
|
return command.length > 72 ? command.substring(0, 69) + "..." : command;
|
|
840
952
|
}
|
|
953
|
+
/**
|
|
954
|
+
* Commit a passive transaction (driven externally, e.g. by a Python Agent).
|
|
955
|
+
*
|
|
956
|
+
* @param source The tool or origin (e.g., "agent:write_file")
|
|
957
|
+
* @param changedPaths Absolute paths of files that were modified/created/deleted.
|
|
958
|
+
*/
|
|
959
|
+
async commitPassive(source, changedPaths) {
|
|
960
|
+
const startTime = Date.now();
|
|
961
|
+
const scopeWarnings = [];
|
|
962
|
+
const inScopePaths = [];
|
|
963
|
+
if (this.config.scope === "workspace") {
|
|
964
|
+
const normalizedRoot = path6.resolve(this.projectRoot).replace(/\\/g, "/").toLowerCase();
|
|
965
|
+
for (const p of changedPaths) {
|
|
966
|
+
const normalizedPath = path6.resolve(p).replace(/\\/g, "/").toLowerCase();
|
|
967
|
+
if (normalizedPath.startsWith(normalizedRoot)) {
|
|
968
|
+
inScopePaths.push(p);
|
|
969
|
+
} else {
|
|
970
|
+
scopeWarnings.push(`Out-of-scope change ignored: ${p}`);
|
|
971
|
+
}
|
|
972
|
+
}
|
|
973
|
+
} else {
|
|
974
|
+
inScopePaths.push(...changedPaths);
|
|
975
|
+
}
|
|
976
|
+
if (inScopePaths.length === 0) {
|
|
977
|
+
if (scopeWarnings.length > 0) {
|
|
978
|
+
for (const w of scopeWarnings) process.stderr.write(`\x1B[33m\u26A0\x1B[0m ${w}
|
|
979
|
+
`);
|
|
980
|
+
}
|
|
981
|
+
return null;
|
|
982
|
+
}
|
|
983
|
+
const discarded = this.txStore.clearUndoStack();
|
|
984
|
+
if (discarded.length > 0) {
|
|
985
|
+
this.fileMap.untrackByTransactions(discarded);
|
|
986
|
+
}
|
|
987
|
+
const txId = this.txStore.nextId();
|
|
988
|
+
const touchedFiles = [];
|
|
989
|
+
const changeManifest = {};
|
|
990
|
+
for (const p of inScopePaths) {
|
|
991
|
+
const absolutePath = path6.resolve(p);
|
|
992
|
+
let existsOnDisk = false;
|
|
993
|
+
let content = null;
|
|
994
|
+
try {
|
|
995
|
+
content = await fs5.readFile(absolutePath);
|
|
996
|
+
existsOnDisk = true;
|
|
997
|
+
} catch {
|
|
998
|
+
existsOnDisk = false;
|
|
999
|
+
}
|
|
1000
|
+
let changeType = "modified";
|
|
1001
|
+
const existingId = this.fileMap.getIdByPath(absolutePath);
|
|
1002
|
+
let fileId;
|
|
1003
|
+
if (!existingId) {
|
|
1004
|
+
if (!existsOnDisk) {
|
|
1005
|
+
continue;
|
|
1006
|
+
}
|
|
1007
|
+
changeType = "created";
|
|
1008
|
+
fileId = this.fileMap.getOrCreateId(absolutePath, txId);
|
|
1009
|
+
} else {
|
|
1010
|
+
fileId = existingId;
|
|
1011
|
+
if (!existsOnDisk) {
|
|
1012
|
+
changeType = "deleted";
|
|
1013
|
+
} else {
|
|
1014
|
+
changeType = "modified";
|
|
1015
|
+
}
|
|
1016
|
+
}
|
|
1017
|
+
touchedFiles.push(fileId);
|
|
1018
|
+
changeManifest[fileId] = changeType;
|
|
1019
|
+
if (changeType === "deleted") {
|
|
1020
|
+
this.fileMap.markDeleted(fileId, txId);
|
|
1021
|
+
await this.gitStore.removeFile(fileId);
|
|
1022
|
+
} else {
|
|
1023
|
+
await this.gitStore.writeFile(fileId, content);
|
|
1024
|
+
}
|
|
1025
|
+
}
|
|
1026
|
+
if (touchedFiles.length === 0) {
|
|
1027
|
+
return null;
|
|
1028
|
+
}
|
|
1029
|
+
const commitMessage = `txr: ${txId} | passive: ${source}`;
|
|
1030
|
+
const commitHash = await this.gitStore.commit(commitMessage);
|
|
1031
|
+
const tx = {
|
|
1032
|
+
id: txId,
|
|
1033
|
+
type: "U",
|
|
1034
|
+
parent: this.txStore.head,
|
|
1035
|
+
commit: commitHash,
|
|
1036
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1037
|
+
command: source,
|
|
1038
|
+
source,
|
|
1039
|
+
touchedFiles,
|
|
1040
|
+
changeManifest,
|
|
1041
|
+
workingDirectory: this.projectRoot,
|
|
1042
|
+
exitCode: 0,
|
|
1043
|
+
attributionTier: null,
|
|
1044
|
+
scope: this.config.scope,
|
|
1045
|
+
scopeWarnings
|
|
1046
|
+
};
|
|
1047
|
+
const discarded2 = this.txStore.addTransaction(tx);
|
|
1048
|
+
if (discarded2.length > 0) {
|
|
1049
|
+
this.fileMap.untrackByTransactions(discarded2);
|
|
1050
|
+
}
|
|
1051
|
+
await this.txStore.save();
|
|
1052
|
+
await this.fileMap.save();
|
|
1053
|
+
const durationMs = Date.now() - startTime;
|
|
1054
|
+
this.historyStore.append({
|
|
1055
|
+
timestamp: tx.timestamp,
|
|
1056
|
+
command: source,
|
|
1057
|
+
transactionId: txId,
|
|
1058
|
+
workingDirectory: this.projectRoot,
|
|
1059
|
+
exitCode: 0,
|
|
1060
|
+
classification: "transactional",
|
|
1061
|
+
attributionTier: null,
|
|
1062
|
+
durationMs,
|
|
1063
|
+
actionType: "U"
|
|
1064
|
+
});
|
|
1065
|
+
await this.historyStore.save();
|
|
1066
|
+
if (scopeWarnings.length > 0) {
|
|
1067
|
+
for (const w of scopeWarnings) {
|
|
1068
|
+
process.stderr.write(`\x1B[33m\u26A0\x1B[0m ${w}
|
|
1069
|
+
`);
|
|
1070
|
+
}
|
|
1071
|
+
}
|
|
1072
|
+
return {
|
|
1073
|
+
transactionId: txId,
|
|
1074
|
+
commit: commitHash,
|
|
1075
|
+
changesCount: touchedFiles.length,
|
|
1076
|
+
tier: 1,
|
|
1077
|
+
// Default base tier for passive
|
|
1078
|
+
durationMs,
|
|
1079
|
+
scopeWarnings,
|
|
1080
|
+
actionType: "U"
|
|
1081
|
+
};
|
|
1082
|
+
}
|
|
841
1083
|
/** Execute a command directly without tracking. */
|
|
842
1084
|
executeDirectly(command, cwd) {
|
|
843
1085
|
const { spawn: spawn2 } = __require("child_process");
|
|
844
|
-
return new Promise((
|
|
1086
|
+
return new Promise((resolve7, reject) => {
|
|
845
1087
|
const isWindows = process.platform === "win32";
|
|
846
1088
|
const shell = isWindows ? "cmd.exe" : "/bin/sh";
|
|
847
1089
|
const shellArgs = isWindows ? ["/c", command] : ["-c", command];
|
|
@@ -850,7 +1092,7 @@ var TransactionEngine = class {
|
|
|
850
1092
|
stdio: "inherit"
|
|
851
1093
|
});
|
|
852
1094
|
child.on("error", reject);
|
|
853
|
-
child.on("close", (code) =>
|
|
1095
|
+
child.on("close", (code) => resolve7(code ?? 1));
|
|
854
1096
|
});
|
|
855
1097
|
}
|
|
856
1098
|
};
|
|
@@ -866,7 +1108,7 @@ async function runCommand(command, options) {
|
|
|
866
1108
|
if ((classification.type === "non-transactional" || classification.type === "scope-external") && config.confirmUnsafe && !options.yes) {
|
|
867
1109
|
const readline = await import("readline");
|
|
868
1110
|
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
869
|
-
const answer = await new Promise((
|
|
1111
|
+
const answer = await new Promise((resolve7) => {
|
|
870
1112
|
console.log();
|
|
871
1113
|
if (classification.type === "scope-external") {
|
|
872
1114
|
console.log(`\x1B[33m\u26A0 SCOPE WARNING:\x1B[0m This command may modify files outside your workspace.`);
|
|
@@ -878,7 +1120,7 @@ async function runCommand(command, options) {
|
|
|
878
1120
|
console.log();
|
|
879
1121
|
console.log(" Rollback guarantees cannot be provided.");
|
|
880
1122
|
console.log();
|
|
881
|
-
rl.question(" Continue without tracking? [y/N] ",
|
|
1123
|
+
rl.question(" Continue without tracking? [y/N] ", resolve7);
|
|
882
1124
|
});
|
|
883
1125
|
rl.close();
|
|
884
1126
|
if (answer.toLowerCase() !== "y") {
|
|
@@ -913,8 +1155,8 @@ async function runCommand(command, options) {
|
|
|
913
1155
|
}
|
|
914
1156
|
|
|
915
1157
|
// src/core/undo-engine.ts
|
|
916
|
-
import * as
|
|
917
|
-
import * as
|
|
1158
|
+
import * as fs6 from "fs/promises";
|
|
1159
|
+
import * as path7 from "path";
|
|
918
1160
|
import * as crypto2 from "crypto";
|
|
919
1161
|
var UndoConflictError = class extends Error {
|
|
920
1162
|
conflicts;
|
|
@@ -924,28 +1166,64 @@ var UndoConflictError = class extends Error {
|
|
|
924
1166
|
this.conflicts = conflicts;
|
|
925
1167
|
}
|
|
926
1168
|
};
|
|
1169
|
+
var PartialUndoBlockedError = class extends Error {
|
|
1170
|
+
constructor(txId, reason) {
|
|
1171
|
+
super(
|
|
1172
|
+
`Cannot safely undo \x1B[1m${txId}\x1B[0m: This transaction has known side effects outside TXR's rollback boundary.
|
|
1173
|
+
Reason: ${reason}
|
|
1174
|
+
|
|
1175
|
+
Tracked filesystem effects CAN be reverted.
|
|
1176
|
+
Resolve any external side effects manually, then run: \x1B[1mtxr undo --force\x1B[0m`
|
|
1177
|
+
);
|
|
1178
|
+
this.name = "PartialUndoBlockedError";
|
|
1179
|
+
}
|
|
1180
|
+
};
|
|
1181
|
+
var ExternalUndoBlockedError = class extends Error {
|
|
1182
|
+
constructor(txId) {
|
|
1183
|
+
super(
|
|
1184
|
+
`Cannot undo \x1B[1m${txId}\x1B[0m: This is an automatically recorded external change.
|
|
1185
|
+
TXR did not create these changes, so it cannot undo them safely.
|
|
1186
|
+
|
|
1187
|
+
If you have already reverted these changes manually, run: \x1B[1mtxr undo --force\x1B[0m`
|
|
1188
|
+
);
|
|
1189
|
+
this.name = "ExternalUndoBlockedError";
|
|
1190
|
+
}
|
|
1191
|
+
};
|
|
927
1192
|
var UndoEngine = class {
|
|
928
|
-
constructor(txStore, fileMap, gitStore) {
|
|
1193
|
+
constructor(txStore, fileMap, gitStore, detector) {
|
|
929
1194
|
this.txStore = txStore;
|
|
930
1195
|
this.fileMap = fileMap;
|
|
931
1196
|
this.gitStore = gitStore;
|
|
1197
|
+
this.detector = detector;
|
|
932
1198
|
}
|
|
933
1199
|
txStore;
|
|
934
1200
|
fileMap;
|
|
935
1201
|
gitStore;
|
|
1202
|
+
detector;
|
|
936
1203
|
/** Undo the current HEAD transaction. */
|
|
937
|
-
async undo() {
|
|
1204
|
+
async undo(options = {}) {
|
|
1205
|
+
if (!options.force && this.detector) {
|
|
1206
|
+
await this.detector.detectAndRecord();
|
|
1207
|
+
}
|
|
938
1208
|
const headTx = this.txStore.getHead();
|
|
939
1209
|
if (!headTx) throw new Error("Nothing to undo.");
|
|
940
1210
|
const parentTx = headTx.parent ? this.txStore.get(headTx.parent) : void 0;
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
1211
|
+
if (!options.force) {
|
|
1212
|
+
if (headTx.type === "P") {
|
|
1213
|
+
throw new PartialUndoBlockedError(headTx.id, headTx.partialUndoReason ?? "Unknown side effect.");
|
|
1214
|
+
}
|
|
1215
|
+
if (headTx.type === "E") {
|
|
1216
|
+
throw new ExternalUndoBlockedError(headTx.id);
|
|
1217
|
+
}
|
|
1218
|
+
const conflicts = await this.validateForUndo(headTx);
|
|
1219
|
+
if (conflicts.length > 0) {
|
|
1220
|
+
throw new UndoConflictError(
|
|
1221
|
+
`Cannot undo ${headTx.id}: ${conflicts.length} file(s) have been externally modified.
|
|
945
1222
|
|
|
946
1223
|
` + conflicts.join("\n"),
|
|
947
|
-
|
|
948
|
-
|
|
1224
|
+
conflicts
|
|
1225
|
+
);
|
|
1226
|
+
}
|
|
949
1227
|
}
|
|
950
1228
|
const ops = [];
|
|
951
1229
|
for (const fileId of headTx.touchedFiles) {
|
|
@@ -958,18 +1236,31 @@ var UndoEngine = class {
|
|
|
958
1236
|
break;
|
|
959
1237
|
case "modified": {
|
|
960
1238
|
if (!parentTx) {
|
|
961
|
-
|
|
1239
|
+
process.stderr.write(`\\x1b[33m\u26A0\\x1b[0m Skipping revert for ${filePath}: original state is untracked.\\n`);
|
|
1240
|
+
break;
|
|
1241
|
+
}
|
|
1242
|
+
try {
|
|
1243
|
+
const prevContent = await this.gitStore.readBlob(parentTx.commit, fileId);
|
|
1244
|
+
ops.push({ type: "write", path: filePath, content: prevContent });
|
|
1245
|
+
} catch (e) {
|
|
1246
|
+
process.stderr.write(`\\x1b[33m\u26A0\\x1b[0m Skipping revert for ${filePath}: original state is untracked.\\n`);
|
|
962
1247
|
}
|
|
963
|
-
const prevContent = await this.gitStore.readBlob(parentTx.commit, fileId);
|
|
964
|
-
ops.push({ type: "write", path: filePath, content: prevContent });
|
|
965
1248
|
break;
|
|
966
1249
|
}
|
|
967
1250
|
case "deleted": {
|
|
968
1251
|
if (!parentTx) {
|
|
969
|
-
|
|
1252
|
+
process.stderr.write(`\\x1b[33m\u26A0\\x1b[0m Skipping restore for ${filePath}: original state is untracked.\\n`);
|
|
1253
|
+
break;
|
|
1254
|
+
}
|
|
1255
|
+
try {
|
|
1256
|
+
const restoredContent = await this.gitStore.readBlob(parentTx.commit, fileId);
|
|
1257
|
+
ops.push({ type: "write", path: filePath, content: restoredContent });
|
|
1258
|
+
} catch (e) {
|
|
1259
|
+
process.stderr.write(`\\x1b[33m\u26A0\\x1b[0m Skipping restore for ${filePath}: original state is untracked.\\n`);
|
|
970
1260
|
}
|
|
971
|
-
|
|
972
|
-
|
|
1261
|
+
break;
|
|
1262
|
+
}
|
|
1263
|
+
case "tracked": {
|
|
973
1264
|
break;
|
|
974
1265
|
}
|
|
975
1266
|
}
|
|
@@ -978,14 +1269,14 @@ var UndoEngine = class {
|
|
|
978
1269
|
const deletes = ops.filter((o) => o.type === "delete");
|
|
979
1270
|
for (const op of writes) {
|
|
980
1271
|
if (op.type === "write") {
|
|
981
|
-
await
|
|
982
|
-
await
|
|
1272
|
+
await fs6.mkdir(path7.dirname(op.path), { recursive: true });
|
|
1273
|
+
await fs6.writeFile(op.path, op.content);
|
|
983
1274
|
}
|
|
984
1275
|
}
|
|
985
1276
|
for (const op of deletes) {
|
|
986
1277
|
if (op.type === "delete") {
|
|
987
1278
|
try {
|
|
988
|
-
await
|
|
1279
|
+
await fs6.unlink(op.path);
|
|
989
1280
|
} catch {
|
|
990
1281
|
}
|
|
991
1282
|
await this.removeEmptyParents(op.path);
|
|
@@ -993,7 +1284,7 @@ var UndoEngine = class {
|
|
|
993
1284
|
}
|
|
994
1285
|
for (const fileId of headTx.touchedFiles) {
|
|
995
1286
|
const changeType = headTx.changeManifest[fileId];
|
|
996
|
-
if (changeType === "created") {
|
|
1287
|
+
if (changeType === "created" || changeType === "tracked") {
|
|
997
1288
|
this.fileMap.markDeleted(fileId, `undo:${headTx.id}`);
|
|
998
1289
|
} else if (changeType === "deleted") {
|
|
999
1290
|
this.fileMap.markRestored(fileId);
|
|
@@ -1010,11 +1301,28 @@ var UndoEngine = class {
|
|
|
1010
1301
|
return {
|
|
1011
1302
|
undone: headTx.id,
|
|
1012
1303
|
newHead: headTx.parent,
|
|
1013
|
-
filesRestored: ops.length
|
|
1304
|
+
filesRestored: ops.length,
|
|
1305
|
+
forced: options.force ?? false
|
|
1014
1306
|
};
|
|
1015
1307
|
}
|
|
1016
1308
|
/** Redo the most recently undone transaction. */
|
|
1017
1309
|
async redo() {
|
|
1310
|
+
if (this.detector) {
|
|
1311
|
+
const ext = await this.detector.detectAndRecord();
|
|
1312
|
+
if (ext) {
|
|
1313
|
+
const discarded = this.txStore.clearUndoStack();
|
|
1314
|
+
if (discarded.length > 0) {
|
|
1315
|
+
this.fileMap.untrackByTransactions(discarded);
|
|
1316
|
+
}
|
|
1317
|
+
await this.txStore.save();
|
|
1318
|
+
await this.fileMap.save();
|
|
1319
|
+
throw new Error(
|
|
1320
|
+
`Redo cancelled: External changes were detected and recorded as \x1B[1m${ext.id}\x1B[0m.
|
|
1321
|
+
The redo chain has been cleared because the filesystem state no longer matches the expected pre-redo state.
|
|
1322
|
+
Run \x1B[1mtxr history\x1B[0m to review what changed.`
|
|
1323
|
+
);
|
|
1324
|
+
}
|
|
1325
|
+
}
|
|
1018
1326
|
if (this.txStore.undoStack.length === 0) {
|
|
1019
1327
|
throw new Error("Nothing to redo.");
|
|
1020
1328
|
}
|
|
@@ -1040,25 +1348,28 @@ var UndoEngine = class {
|
|
|
1040
1348
|
case "created":
|
|
1041
1349
|
case "modified": {
|
|
1042
1350
|
const content = await this.gitStore.readBlob(redoTx.commit, fileId);
|
|
1043
|
-
await
|
|
1044
|
-
await
|
|
1351
|
+
await fs6.mkdir(path7.dirname(filePath), { recursive: true });
|
|
1352
|
+
await fs6.writeFile(filePath, content);
|
|
1045
1353
|
filesChanged++;
|
|
1046
1354
|
break;
|
|
1047
1355
|
}
|
|
1048
1356
|
case "deleted": {
|
|
1049
1357
|
try {
|
|
1050
|
-
await
|
|
1358
|
+
await fs6.unlink(filePath);
|
|
1051
1359
|
} catch {
|
|
1052
1360
|
}
|
|
1053
1361
|
await this.removeEmptyParents(filePath);
|
|
1054
1362
|
filesChanged++;
|
|
1055
1363
|
break;
|
|
1056
1364
|
}
|
|
1365
|
+
case "tracked": {
|
|
1366
|
+
break;
|
|
1367
|
+
}
|
|
1057
1368
|
}
|
|
1058
1369
|
}
|
|
1059
1370
|
for (const fileId of redoTx.touchedFiles) {
|
|
1060
1371
|
const changeType = redoTx.changeManifest[fileId];
|
|
1061
|
-
if (changeType === "created") {
|
|
1372
|
+
if (changeType === "created" || changeType === "tracked") {
|
|
1062
1373
|
this.fileMap.markRestored(fileId);
|
|
1063
1374
|
} else if (changeType === "deleted") {
|
|
1064
1375
|
this.fileMap.markDeleted(fileId, `redo:${redoTx.id}`);
|
|
@@ -1076,8 +1387,7 @@ var UndoEngine = class {
|
|
|
1076
1387
|
}
|
|
1077
1388
|
/**
|
|
1078
1389
|
* Validate all touched files for undo.
|
|
1079
|
-
* Returns
|
|
1080
|
-
* Empty array = safe to proceed.
|
|
1390
|
+
* Returns human-readable conflict descriptions. Empty = safe to proceed.
|
|
1081
1391
|
*/
|
|
1082
1392
|
async validateForUndo(tx) {
|
|
1083
1393
|
const conflicts = [];
|
|
@@ -1105,16 +1415,21 @@ var UndoEngine = class {
|
|
|
1105
1415
|
);
|
|
1106
1416
|
continue;
|
|
1107
1417
|
}
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1418
|
+
try {
|
|
1419
|
+
const expectedContent = await this.gitStore.readBlob(tx.commit, fileId);
|
|
1420
|
+
const actualContent = await fs6.readFile(filePath);
|
|
1421
|
+
if (!this.hashEquals(expectedContent, actualContent)) {
|
|
1422
|
+
const expectedHash = this.hash(expectedContent).substring(0, 12);
|
|
1423
|
+
const actualHash = this.hash(actualContent).substring(0, 12);
|
|
1424
|
+
conflicts.push(
|
|
1425
|
+
` \u2717 ${filePath}
|
|
1115
1426
|
Expected hash: ${expectedHash}...
|
|
1116
1427
|
Actual hash: ${actualHash}...`
|
|
1117
|
-
|
|
1428
|
+
);
|
|
1429
|
+
}
|
|
1430
|
+
} catch {
|
|
1431
|
+
conflicts.push(` \u2717 ${filePath}
|
|
1432
|
+
Could not read blob from internal store.`);
|
|
1118
1433
|
}
|
|
1119
1434
|
}
|
|
1120
1435
|
return conflicts;
|
|
@@ -1148,7 +1463,7 @@ var UndoEngine = class {
|
|
|
1148
1463
|
if (parentTx) {
|
|
1149
1464
|
try {
|
|
1150
1465
|
const expectedContent = await this.gitStore.readBlob(parentTx.commit, fileId);
|
|
1151
|
-
const actualContent = await
|
|
1466
|
+
const actualContent = await fs6.readFile(filePath);
|
|
1152
1467
|
if (!this.hashEquals(expectedContent, actualContent)) {
|
|
1153
1468
|
conflicts.push(
|
|
1154
1469
|
` \u2717 ${filePath}
|
|
@@ -1163,7 +1478,7 @@ var UndoEngine = class {
|
|
|
1163
1478
|
}
|
|
1164
1479
|
async fileExists(filePath) {
|
|
1165
1480
|
try {
|
|
1166
|
-
await
|
|
1481
|
+
await fs6.access(filePath);
|
|
1167
1482
|
return true;
|
|
1168
1483
|
} catch {
|
|
1169
1484
|
return false;
|
|
@@ -1177,13 +1492,13 @@ var UndoEngine = class {
|
|
|
1177
1492
|
}
|
|
1178
1493
|
/** Remove empty parent directories up to a reasonable depth. */
|
|
1179
1494
|
async removeEmptyParents(filePath) {
|
|
1180
|
-
let dir =
|
|
1495
|
+
let dir = path7.dirname(filePath);
|
|
1181
1496
|
for (let i = 0; i < 10; i++) {
|
|
1182
1497
|
try {
|
|
1183
|
-
const entries = await
|
|
1498
|
+
const entries = await fs6.readdir(dir);
|
|
1184
1499
|
if (entries.length === 0) {
|
|
1185
|
-
await
|
|
1186
|
-
dir =
|
|
1500
|
+
await fs6.rmdir(dir);
|
|
1501
|
+
dir = path7.dirname(dir);
|
|
1187
1502
|
} else {
|
|
1188
1503
|
break;
|
|
1189
1504
|
}
|
|
@@ -1195,18 +1510,25 @@ var UndoEngine = class {
|
|
|
1195
1510
|
};
|
|
1196
1511
|
|
|
1197
1512
|
// src/cli/commands/undo.ts
|
|
1198
|
-
async function undoCommand() {
|
|
1513
|
+
async function undoCommand(options = {}) {
|
|
1199
1514
|
const cwd = process.cwd();
|
|
1200
1515
|
const projectRoot = await findTxrRoot(cwd);
|
|
1201
1516
|
const paths = getTxrPaths(projectRoot);
|
|
1517
|
+
const config = await loadConfig(paths.metadataDir);
|
|
1202
1518
|
const txStore = await TransactionStore.load(paths.metadataDir);
|
|
1203
1519
|
const fileMap = await FileMap.load(paths.metadataDir);
|
|
1204
1520
|
const gitStore = new GitStore(paths.repoDir);
|
|
1205
|
-
const
|
|
1521
|
+
const detector = new ExternalChangeDetector(txStore, fileMap, gitStore, config, projectRoot);
|
|
1522
|
+
const engine = new UndoEngine(txStore, fileMap, gitStore, detector);
|
|
1206
1523
|
try {
|
|
1207
|
-
const result = await engine.undo();
|
|
1524
|
+
const result = await engine.undo({ force: options.force });
|
|
1208
1525
|
console.log();
|
|
1209
|
-
|
|
1526
|
+
if (result.forced) {
|
|
1527
|
+
console.log(`\x1B[33m\u26A0 Force undo applied.\x1B[0m Undone: \x1B[1m${result.undone}\x1B[0m`);
|
|
1528
|
+
console.log(` TXR assumes you have resolved any external side effects.`);
|
|
1529
|
+
} else {
|
|
1530
|
+
console.log(`\x1B[32m\u2713\x1B[0m Undone: \x1B[1m${result.undone}\x1B[0m`);
|
|
1531
|
+
}
|
|
1210
1532
|
console.log(` ${result.filesRestored} file operation(s) applied`);
|
|
1211
1533
|
console.log(` HEAD: ${result.newHead ?? "(empty)"}`);
|
|
1212
1534
|
} catch (err) {
|
|
@@ -1219,6 +1541,16 @@ async function undoCommand() {
|
|
|
1219
1541
|
console.error("To resolve: manually revert the conflicting files, then retry `txr undo`.");
|
|
1220
1542
|
process.exit(1);
|
|
1221
1543
|
}
|
|
1544
|
+
if (err instanceof PartialUndoBlockedError) {
|
|
1545
|
+
console.error();
|
|
1546
|
+
console.error(`\x1B[33mBLOCKED [P]:\x1B[0m ${err.message}`);
|
|
1547
|
+
process.exit(1);
|
|
1548
|
+
}
|
|
1549
|
+
if (err instanceof ExternalUndoBlockedError) {
|
|
1550
|
+
console.error();
|
|
1551
|
+
console.error(`\x1B[33mBLOCKED [E]:\x1B[0m ${err.message}`);
|
|
1552
|
+
process.exit(1);
|
|
1553
|
+
}
|
|
1222
1554
|
throw err;
|
|
1223
1555
|
}
|
|
1224
1556
|
}
|
|
@@ -1226,10 +1558,12 @@ async function redoCommand() {
|
|
|
1226
1558
|
const cwd = process.cwd();
|
|
1227
1559
|
const projectRoot = await findTxrRoot(cwd);
|
|
1228
1560
|
const paths = getTxrPaths(projectRoot);
|
|
1561
|
+
const config = await loadConfig(paths.metadataDir);
|
|
1229
1562
|
const txStore = await TransactionStore.load(paths.metadataDir);
|
|
1230
1563
|
const fileMap = await FileMap.load(paths.metadataDir);
|
|
1231
1564
|
const gitStore = new GitStore(paths.repoDir);
|
|
1232
|
-
const
|
|
1565
|
+
const detector = new ExternalChangeDetector(txStore, fileMap, gitStore, config, projectRoot);
|
|
1566
|
+
const engine = new UndoEngine(txStore, fileMap, gitStore, detector);
|
|
1233
1567
|
try {
|
|
1234
1568
|
const result = await engine.redo();
|
|
1235
1569
|
console.log();
|
|
@@ -1244,6 +1578,11 @@ async function redoCommand() {
|
|
|
1244
1578
|
console.error("Redo aborted. No files were changed.");
|
|
1245
1579
|
process.exit(1);
|
|
1246
1580
|
}
|
|
1581
|
+
if (err instanceof Error && err.message.startsWith("Redo cancelled:")) {
|
|
1582
|
+
console.error();
|
|
1583
|
+
console.error(`\x1B[33m\u26A0\x1B[0m ${err.message}`);
|
|
1584
|
+
process.exit(1);
|
|
1585
|
+
}
|
|
1247
1586
|
throw err;
|
|
1248
1587
|
}
|
|
1249
1588
|
}
|
|
@@ -1266,30 +1605,47 @@ async function statusCommand() {
|
|
|
1266
1605
|
console.log(` Redo stack: ${txStore.undoStack.length} transaction(s)`);
|
|
1267
1606
|
console.log(` Watch paths: ${config.watchPaths.join(", ")}`);
|
|
1268
1607
|
}
|
|
1269
|
-
async function historyCommand(
|
|
1608
|
+
async function historyCommand() {
|
|
1270
1609
|
const cwd = process.cwd();
|
|
1271
1610
|
const projectRoot = await findTxrRoot(cwd);
|
|
1272
1611
|
const paths = getTxrPaths(projectRoot);
|
|
1273
|
-
const
|
|
1274
|
-
const
|
|
1275
|
-
|
|
1276
|
-
|
|
1612
|
+
const txStore = await TransactionStore.load(paths.metadataDir);
|
|
1613
|
+
const chain = txStore.getChain();
|
|
1614
|
+
const redoStack = txStore.undoStack;
|
|
1615
|
+
if (chain.length === 0 && redoStack.length === 0) {
|
|
1616
|
+
console.log("\nNo transaction history yet.");
|
|
1277
1617
|
return;
|
|
1278
1618
|
}
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
`
|
|
1282
|
-
|
|
1283
|
-
const
|
|
1284
|
-
const
|
|
1285
|
-
|
|
1286
|
-
|
|
1619
|
+
const printTx = (tx) => {
|
|
1620
|
+
const txLabel = `\x1B[32m${tx.id}\x1B[0m`;
|
|
1621
|
+
const tierLabel = tx.attributionTier ? `T${tx.attributionTier}` : " ";
|
|
1622
|
+
const typeLabel = tx.type === "U" ? "\x1B[32m[U]\x1B[0m" : tx.type === "P" ? "\x1B[33m[P]\x1B[0m" : "\x1B[35m[E]\x1B[0m";
|
|
1623
|
+
const timeStr = new Date(tx.timestamp).toLocaleTimeString();
|
|
1624
|
+
const commandStr = tx.command ?? "\x1B[90m[external change]\x1B[0m";
|
|
1625
|
+
console.log(` ${timeStr} ${tierLabel} ${txLabel} ${typeLabel} ${commandStr}`);
|
|
1626
|
+
};
|
|
1627
|
+
console.log("\n\x1B[1m=== Active Timeline (Undoable) ===\x1B[0m\n");
|
|
1628
|
+
if (chain.length === 0) {
|
|
1629
|
+
console.log(" (empty)");
|
|
1630
|
+
} else {
|
|
1631
|
+
for (const tx of chain) {
|
|
1632
|
+
printTx(tx);
|
|
1633
|
+
}
|
|
1634
|
+
}
|
|
1635
|
+
console.log("\n\x1B[1m==== [ CURRENT HEAD ] ====\x1B[0m\n");
|
|
1636
|
+
if (redoStack.length > 0) {
|
|
1637
|
+
console.log("\x1B[1m=== Next Commands (Available to Redo) ===\x1B[0m\n");
|
|
1638
|
+
for (const txId of redoStack) {
|
|
1639
|
+
const tx = txStore.get(txId);
|
|
1640
|
+
if (tx) printTx(tx);
|
|
1641
|
+
}
|
|
1642
|
+
console.log();
|
|
1287
1643
|
}
|
|
1288
1644
|
}
|
|
1289
1645
|
|
|
1290
1646
|
// src/cli/commands/clear.ts
|
|
1291
|
-
import * as
|
|
1292
|
-
import * as
|
|
1647
|
+
import * as fs7 from "fs/promises";
|
|
1648
|
+
import * as path8 from "path";
|
|
1293
1649
|
async function clearCommand(options) {
|
|
1294
1650
|
const cwd = process.cwd();
|
|
1295
1651
|
let projectRoot;
|
|
@@ -1302,7 +1658,7 @@ async function clearCommand(options) {
|
|
|
1302
1658
|
if (!options.yes) {
|
|
1303
1659
|
const readline = await import("readline");
|
|
1304
1660
|
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
1305
|
-
const answer = await new Promise((
|
|
1661
|
+
const answer = await new Promise((resolve7) => {
|
|
1306
1662
|
console.log();
|
|
1307
1663
|
console.log(`\x1B[33mWARNING:\x1B[0m`);
|
|
1308
1664
|
console.log(`This will permanently delete all transaction history, undo history,`);
|
|
@@ -1310,7 +1666,7 @@ async function clearCommand(options) {
|
|
|
1310
1666
|
console.log();
|
|
1311
1667
|
console.log(`This operation cannot be undone.`);
|
|
1312
1668
|
console.log();
|
|
1313
|
-
rl.question("Continue? [y/N] ",
|
|
1669
|
+
rl.question("Continue? [y/N] ", resolve7);
|
|
1314
1670
|
});
|
|
1315
1671
|
rl.close();
|
|
1316
1672
|
if (answer.trim().toLowerCase() !== "y") {
|
|
@@ -1318,14 +1674,14 @@ async function clearCommand(options) {
|
|
|
1318
1674
|
return;
|
|
1319
1675
|
}
|
|
1320
1676
|
}
|
|
1321
|
-
const txrDir =
|
|
1322
|
-
const repoDir =
|
|
1323
|
-
const metadataDir =
|
|
1677
|
+
const txrDir = path8.join(projectRoot, ".txr");
|
|
1678
|
+
const repoDir = path8.join(txrDir, "repo");
|
|
1679
|
+
const metadataDir = path8.join(txrDir, "metadata");
|
|
1324
1680
|
try {
|
|
1325
1681
|
let retries = 3;
|
|
1326
1682
|
while (retries > 0) {
|
|
1327
1683
|
try {
|
|
1328
|
-
await
|
|
1684
|
+
await fs7.rm(repoDir, { recursive: true, force: true });
|
|
1329
1685
|
break;
|
|
1330
1686
|
} catch (err) {
|
|
1331
1687
|
if (err.code === "EBUSY" || err.code === "EPERM") {
|
|
@@ -1334,27 +1690,27 @@ async function clearCommand(options) {
|
|
|
1334
1690
|
throw new Error(`Files are locked by another process. Please close any IDEs or tools accessing the workspace and try again.
|
|
1335
1691
|
Details: ${err.message}`);
|
|
1336
1692
|
}
|
|
1337
|
-
await new Promise((
|
|
1693
|
+
await new Promise((resolve7) => setTimeout(resolve7, 500));
|
|
1338
1694
|
} else {
|
|
1339
1695
|
throw err;
|
|
1340
1696
|
}
|
|
1341
1697
|
}
|
|
1342
1698
|
}
|
|
1343
|
-
const { GitStore: GitStore2 } = await import("./git-store-
|
|
1699
|
+
const { GitStore: GitStore2 } = await import("./git-store-MUETZYUT.js");
|
|
1344
1700
|
const gitStore = new GitStore2(repoDir);
|
|
1345
1701
|
await gitStore.init();
|
|
1346
|
-
await
|
|
1347
|
-
|
|
1702
|
+
await fs7.writeFile(
|
|
1703
|
+
path8.join(metadataDir, "transactions.json"),
|
|
1348
1704
|
JSON.stringify({ version: 1, head: null, undoStack: [], counter: 0, transactions: {} }, null, 2),
|
|
1349
1705
|
"utf-8"
|
|
1350
1706
|
);
|
|
1351
|
-
await
|
|
1352
|
-
|
|
1707
|
+
await fs7.writeFile(
|
|
1708
|
+
path8.join(metadataDir, "mapping.json"),
|
|
1353
1709
|
JSON.stringify({ version: 1, files: {} }, null, 2),
|
|
1354
1710
|
"utf-8"
|
|
1355
1711
|
);
|
|
1356
|
-
await
|
|
1357
|
-
|
|
1712
|
+
await fs7.writeFile(
|
|
1713
|
+
path8.join(metadataDir, "history.json"),
|
|
1358
1714
|
JSON.stringify({ version: 1, entries: [] }, null, 2),
|
|
1359
1715
|
"utf-8"
|
|
1360
1716
|
);
|
|
@@ -1369,9 +1725,133 @@ Details: ${err.message}`);
|
|
|
1369
1725
|
}
|
|
1370
1726
|
}
|
|
1371
1727
|
|
|
1728
|
+
// src/cli/commands/track.ts
|
|
1729
|
+
import { Command } from "commander";
|
|
1730
|
+
import * as path9 from "path";
|
|
1731
|
+
import * as fs8 from "fs/promises";
|
|
1732
|
+
function createTrackCommand() {
|
|
1733
|
+
return new Command("track").description("Manually track an existing file to capture its baseline state").argument("<file>", "File to track").action(async (file) => {
|
|
1734
|
+
try {
|
|
1735
|
+
const cwd = process.cwd();
|
|
1736
|
+
const absolutePath = path9.resolve(cwd, file);
|
|
1737
|
+
try {
|
|
1738
|
+
const stat4 = await fs8.stat(absolutePath);
|
|
1739
|
+
if (!stat4.isFile()) {
|
|
1740
|
+
console.error(`Error: ${file} is not a regular file.`);
|
|
1741
|
+
process.exit(1);
|
|
1742
|
+
}
|
|
1743
|
+
} catch {
|
|
1744
|
+
console.error(`Error: File ${file} does not exist.`);
|
|
1745
|
+
process.exit(1);
|
|
1746
|
+
}
|
|
1747
|
+
const projectRoot = await findTxrRoot(cwd);
|
|
1748
|
+
if (!projectRoot) {
|
|
1749
|
+
console.error("Error: Not in a txr workspace (no .txr directory found).");
|
|
1750
|
+
process.exit(1);
|
|
1751
|
+
}
|
|
1752
|
+
const paths = getTxrPaths(projectRoot);
|
|
1753
|
+
const config = await loadConfig(paths.metadataDir);
|
|
1754
|
+
const [txStore, fileMap, historyStore] = await Promise.all([
|
|
1755
|
+
TransactionStore.load(paths.metadataDir),
|
|
1756
|
+
FileMap.load(paths.metadataDir),
|
|
1757
|
+
HistoryStore.load(paths.metadataDir)
|
|
1758
|
+
]);
|
|
1759
|
+
const gitStore = new GitStore(paths.repoDir);
|
|
1760
|
+
const existingId = fileMap.getIdByPath(absolutePath);
|
|
1761
|
+
if (existingId) {
|
|
1762
|
+
console.log(`File ${file} is already tracked.`);
|
|
1763
|
+
return;
|
|
1764
|
+
}
|
|
1765
|
+
const discarded = txStore.clearUndoStack();
|
|
1766
|
+
if (discarded.length > 0) {
|
|
1767
|
+
fileMap.untrackByTransactions(discarded);
|
|
1768
|
+
}
|
|
1769
|
+
const txId = txStore.nextId();
|
|
1770
|
+
const fileId = fileMap.getOrCreateId(absolutePath, txId);
|
|
1771
|
+
const content = await fs8.readFile(absolutePath);
|
|
1772
|
+
await gitStore.writeFile(fileId, content);
|
|
1773
|
+
const commitMessage = `txr: ${txId} | txr track ${path9.basename(absolutePath)}`;
|
|
1774
|
+
const commitHash = await gitStore.commit(commitMessage);
|
|
1775
|
+
const tx = {
|
|
1776
|
+
id: txId,
|
|
1777
|
+
type: "U",
|
|
1778
|
+
parent: txStore.head,
|
|
1779
|
+
commit: commitHash,
|
|
1780
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1781
|
+
command: `txr track ${file}`,
|
|
1782
|
+
source: "txr-run",
|
|
1783
|
+
touchedFiles: [fileId],
|
|
1784
|
+
changeManifest: { [fileId]: "tracked" },
|
|
1785
|
+
workingDirectory: cwd,
|
|
1786
|
+
exitCode: 0,
|
|
1787
|
+
attributionTier: null,
|
|
1788
|
+
scope: config.scope,
|
|
1789
|
+
scopeWarnings: []
|
|
1790
|
+
};
|
|
1791
|
+
const discarded2 = txStore.addTransaction(tx);
|
|
1792
|
+
if (discarded2.length > 0) {
|
|
1793
|
+
fileMap.untrackByTransactions(discarded2);
|
|
1794
|
+
}
|
|
1795
|
+
await txStore.save();
|
|
1796
|
+
await fileMap.save();
|
|
1797
|
+
historyStore.append({
|
|
1798
|
+
timestamp: tx.timestamp,
|
|
1799
|
+
command: tx.command,
|
|
1800
|
+
transactionId: txId,
|
|
1801
|
+
workingDirectory: cwd,
|
|
1802
|
+
exitCode: 0,
|
|
1803
|
+
classification: "transactional",
|
|
1804
|
+
attributionTier: null,
|
|
1805
|
+
durationMs: 0,
|
|
1806
|
+
actionType: "U"
|
|
1807
|
+
});
|
|
1808
|
+
await historyStore.save();
|
|
1809
|
+
console.log(`\u2713 Baseline tracked: ${file}`);
|
|
1810
|
+
console.log(` Recorded as transaction \x1B[1m${txId}\x1B[0m.`);
|
|
1811
|
+
} catch (error) {
|
|
1812
|
+
console.error(`Error: ${error.message}`);
|
|
1813
|
+
process.exit(1);
|
|
1814
|
+
}
|
|
1815
|
+
});
|
|
1816
|
+
}
|
|
1817
|
+
|
|
1818
|
+
// src/cli/commands/api.ts
|
|
1819
|
+
import { Command as Command2 } from "commander";
|
|
1820
|
+
function createApiCommand() {
|
|
1821
|
+
const api = new Command2("api").description("Headless API for agent integration");
|
|
1822
|
+
api.command("sync").description("Detect all filesystem changes since HEAD and commit them as a transaction").requiredOption("--source <string>", "Source of the transaction (e.g. agent:write_file or external)").action(async (opts) => {
|
|
1823
|
+
try {
|
|
1824
|
+
const cwd = process.cwd();
|
|
1825
|
+
const projectRoot = await findTxrRoot(cwd);
|
|
1826
|
+
if (!projectRoot) {
|
|
1827
|
+
console.error(JSON.stringify({ ok: false, error: "Not a txr repository" }));
|
|
1828
|
+
process.exit(1);
|
|
1829
|
+
}
|
|
1830
|
+
const paths = getTxrPaths(projectRoot);
|
|
1831
|
+
const config = await loadConfig(paths.metadataDir);
|
|
1832
|
+
const txStore = await TransactionStore.load(paths.metadataDir);
|
|
1833
|
+
const historyStore = await HistoryStore.load(paths.metadataDir);
|
|
1834
|
+
const fileMap = await FileMap.load(paths.metadataDir);
|
|
1835
|
+
const gitStore = new GitStore(paths.repoDir);
|
|
1836
|
+
const { ExternalChangeDetector: ExternalChangeDetector2 } = await import("./external-detector-5TT634UP.js");
|
|
1837
|
+
const detector = new ExternalChangeDetector2(txStore, fileMap, gitStore, config, projectRoot);
|
|
1838
|
+
const tx = await detector.detectAndRecordWithSource(opts.source);
|
|
1839
|
+
if (tx) {
|
|
1840
|
+
console.log(JSON.stringify({ ok: true, transactionId: tx.id, changesCount: Object.keys(tx.changeManifest).length }));
|
|
1841
|
+
} else {
|
|
1842
|
+
console.log(JSON.stringify({ ok: true, transactionId: null, changesCount: 0 }));
|
|
1843
|
+
}
|
|
1844
|
+
} catch (err) {
|
|
1845
|
+
console.error(JSON.stringify({ ok: false, error: err.stack }));
|
|
1846
|
+
process.exit(1);
|
|
1847
|
+
}
|
|
1848
|
+
});
|
|
1849
|
+
return api;
|
|
1850
|
+
}
|
|
1851
|
+
|
|
1372
1852
|
// src/index.ts
|
|
1373
|
-
var program = new
|
|
1374
|
-
program.name("txr").description("Transactional command runner \u2014 undo any shell command").version("0.
|
|
1853
|
+
var program = new Command3();
|
|
1854
|
+
program.name("txr").description("Transactional command runner \u2014 undo any shell command").version("0.2.0");
|
|
1375
1855
|
program.command("init").description("Initialize txr in the current directory").option("-g, --global", "Enable global tracking mode (track files outside workspace)").action(async (opts) => {
|
|
1376
1856
|
try {
|
|
1377
1857
|
const scope = opts.global ? "global" : "workspace";
|
|
@@ -1401,9 +1881,9 @@ program.command("run").description("Execute a command with transactional trackin
|
|
|
1401
1881
|
process.exit(1);
|
|
1402
1882
|
}
|
|
1403
1883
|
});
|
|
1404
|
-
program.command("undo").description("Undo the most recent transaction").action(async () => {
|
|
1884
|
+
program.command("undo").description("Undo the most recent transaction").option("--force", "Skip safety checks and force the undo (for P and E transactions)").action(async (opts) => {
|
|
1405
1885
|
try {
|
|
1406
|
-
await undoCommand();
|
|
1886
|
+
await undoCommand({ force: opts.force });
|
|
1407
1887
|
} catch (err) {
|
|
1408
1888
|
console.error(`\x1B[31mError:\x1B[0m ${err.message}`);
|
|
1409
1889
|
process.exit(1);
|
|
@@ -1425,9 +1905,9 @@ program.command("status").description("Show current transaction state").action(a
|
|
|
1425
1905
|
process.exit(1);
|
|
1426
1906
|
}
|
|
1427
1907
|
});
|
|
1428
|
-
program.command("history").description("Show command history").option("-n, --count <number>", "Number of entries to show", "20").action(async (
|
|
1908
|
+
program.command("history").description("Show command history").option("-n, --count <number>", "Number of entries to show", "20").action(async () => {
|
|
1429
1909
|
try {
|
|
1430
|
-
await historyCommand(
|
|
1910
|
+
await historyCommand();
|
|
1431
1911
|
} catch (err) {
|
|
1432
1912
|
console.error(`\x1B[31mError:\x1B[0m ${err.message}`);
|
|
1433
1913
|
process.exit(1);
|
|
@@ -1441,4 +1921,6 @@ program.command("clear").description("Reset the internal state and delete all tr
|
|
|
1441
1921
|
process.exit(1);
|
|
1442
1922
|
}
|
|
1443
1923
|
});
|
|
1924
|
+
program.addCommand(createTrackCommand());
|
|
1925
|
+
program.addCommand(createApiCommand());
|
|
1444
1926
|
program.parse();
|