@amoghvj/txr 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (4) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +127 -0
  3. package/dist/index.js +1518 -0
  4. package/package.json +56 -0
package/dist/index.js ADDED
@@ -0,0 +1,1518 @@
1
+ #!/usr/bin/env node
2
+ var __require = /* @__PURE__ */ ((x) => typeof require !== "undefined" ? require : typeof Proxy !== "undefined" ? new Proxy(x, {
3
+ get: (a, b) => (typeof require !== "undefined" ? require : a)[b]
4
+ }) : x)(function(x) {
5
+ if (typeof require !== "undefined") return require.apply(this, arguments);
6
+ throw Error('Dynamic require of "' + x + '" is not supported');
7
+ });
8
+
9
+ // src/index.ts
10
+ import { Command } from "commander";
11
+
12
+ // src/cli/commands/init.ts
13
+ import * as fs2 from "fs/promises";
14
+ import * as path2 from "path";
15
+
16
+ // src/git/git-store.ts
17
+ import * as fs from "fs/promises";
18
+ import * as path from "path";
19
+ import git from "isomorphic-git";
20
+ var GitStore = class {
21
+ repoDir;
22
+ constructor(repoDir) {
23
+ this.repoDir = repoDir;
24
+ }
25
+ /** Initialize a new Git repository with an empty initial commit. */
26
+ async init() {
27
+ await fs.mkdir(this.repoDir, { recursive: true });
28
+ await git.init({ fs, dir: this.repoDir });
29
+ const commitHash = await git.commit({
30
+ fs,
31
+ dir: this.repoDir,
32
+ message: "txr: init",
33
+ author: { name: "txr", email: "txr@internal" }
34
+ });
35
+ return commitHash;
36
+ }
37
+ /** Write a file (by file ID) into the repo working tree and stage it. */
38
+ async writeFile(fileId, content) {
39
+ const filePath = path.join(this.repoDir, fileId);
40
+ await fs.writeFile(filePath, content);
41
+ await git.add({ fs, dir: this.repoDir, filepath: fileId });
42
+ }
43
+ /** Remove a file (by file ID) from the repo working tree and stage the removal. */
44
+ async removeFile(fileId) {
45
+ const filePath = path.join(this.repoDir, fileId);
46
+ try {
47
+ await fs.unlink(filePath);
48
+ } catch {
49
+ }
50
+ await git.remove({ fs, dir: this.repoDir, filepath: fileId });
51
+ }
52
+ /** Create a commit with all currently staged changes. */
53
+ async commit(message) {
54
+ const commitHash = await git.commit({
55
+ fs,
56
+ dir: this.repoDir,
57
+ message,
58
+ author: { name: "txr", email: "txr@internal" }
59
+ });
60
+ return commitHash;
61
+ }
62
+ /** Read file content at a specific commit. Returns the raw Buffer. */
63
+ async readBlob(commitHash, fileId) {
64
+ const { blob } = await git.readBlob({
65
+ fs,
66
+ dir: this.repoDir,
67
+ oid: commitHash,
68
+ filepath: fileId
69
+ });
70
+ return Buffer.from(blob);
71
+ }
72
+ /**
73
+ * Check if a file exists in a specific commit's tree.
74
+ */
75
+ async fileExistsAtCommit(commitHash, fileId) {
76
+ try {
77
+ await git.readBlob({
78
+ fs,
79
+ dir: this.repoDir,
80
+ oid: commitHash,
81
+ filepath: fileId
82
+ });
83
+ return true;
84
+ } catch {
85
+ return false;
86
+ }
87
+ }
88
+ /**
89
+ * Hard reset the repo to a specific commit.
90
+ *
91
+ * isomorphic-git doesn't have a native `reset --hard`, so we:
92
+ * 1. Update the branch ref to point to the target commit.
93
+ * 2. Check out the working tree from that commit.
94
+ */
95
+ async resetHard(commitHash) {
96
+ await git.writeRef({
97
+ fs,
98
+ dir: this.repoDir,
99
+ ref: "refs/heads/main",
100
+ value: commitHash,
101
+ force: true
102
+ });
103
+ await git.checkout({
104
+ fs,
105
+ dir: this.repoDir,
106
+ ref: "main",
107
+ force: true
108
+ });
109
+ }
110
+ /**
111
+ * Reset to the initial empty state.
112
+ * Removes all tracked files and creates a fresh empty commit.
113
+ */
114
+ async resetToEmpty() {
115
+ const entries = await fs.readdir(this.repoDir);
116
+ for (const entry of entries) {
117
+ if (entry === ".git") continue;
118
+ const fullPath = path.join(this.repoDir, entry);
119
+ const stat4 = await fs.stat(fullPath);
120
+ if (stat4.isFile()) {
121
+ await git.remove({ fs, dir: this.repoDir, filepath: entry });
122
+ await fs.unlink(fullPath);
123
+ }
124
+ }
125
+ const commitHash = await git.commit({
126
+ fs,
127
+ dir: this.repoDir,
128
+ message: "txr: reset to empty",
129
+ author: { name: "txr", email: "txr@internal" }
130
+ });
131
+ return commitHash;
132
+ }
133
+ /** Get the current HEAD commit hash. */
134
+ async getHead() {
135
+ return await git.resolveRef({ fs, dir: this.repoDir, ref: "HEAD" });
136
+ }
137
+ /**
138
+ * List files that differ between two commits.
139
+ * Returns arrays of added, modified, and deleted file IDs.
140
+ */
141
+ async diffCommits(commitA, commitB) {
142
+ const treeA = await this.listTree(commitA);
143
+ const treeB = await this.listTree(commitB);
144
+ const added = [];
145
+ const modified = [];
146
+ const deleted = [];
147
+ for (const [filepath, oidB] of treeB) {
148
+ const oidA = treeA.get(filepath);
149
+ if (!oidA) {
150
+ added.push(filepath);
151
+ } else if (oidA !== oidB) {
152
+ modified.push(filepath);
153
+ }
154
+ }
155
+ for (const [filepath] of treeA) {
156
+ if (!treeB.has(filepath)) {
157
+ deleted.push(filepath);
158
+ }
159
+ }
160
+ return { added, modified, deleted };
161
+ }
162
+ /** List all files in a commit's tree as Map<filepath, blobOid>. */
163
+ async listTree(commitHash) {
164
+ const result = /* @__PURE__ */ new Map();
165
+ try {
166
+ const entries = await git.walk({
167
+ fs,
168
+ dir: this.repoDir,
169
+ trees: [git.TREE({ ref: commitHash })],
170
+ map: async (filepath, [entry]) => {
171
+ if (!entry || filepath === ".") return void 0;
172
+ const type = await entry.type();
173
+ if (type === "blob") {
174
+ const oid = await entry.oid();
175
+ return { filepath, oid };
176
+ }
177
+ return void 0;
178
+ }
179
+ });
180
+ for (const entry of entries) {
181
+ if (entry) {
182
+ result.set(entry.filepath, entry.oid);
183
+ }
184
+ }
185
+ } catch {
186
+ }
187
+ return result;
188
+ }
189
+ };
190
+
191
+ // src/cli/commands/init.ts
192
+ var TXR_DIR = ".txr";
193
+ var REPO_DIR = "repo";
194
+ var METADATA_DIR = "metadata";
195
+ var DEFAULT_CONFIG = {
196
+ version: 1,
197
+ scope: "workspace",
198
+ watchPaths: ["."],
199
+ ignorePaths: [
200
+ ".git",
201
+ ".txr",
202
+ "node_modules/.cache",
203
+ "*.log",
204
+ "*.tmp",
205
+ ".DS_Store",
206
+ "Thumbs.db"
207
+ ],
208
+ preferredTier: "auto",
209
+ confirmUnsafe: true,
210
+ maxTransactions: 100
211
+ };
212
+ var GLOBAL_CONFIG = {
213
+ ...DEFAULT_CONFIG,
214
+ scope: "global",
215
+ watchPaths: ["."]
216
+ // Base watch path; additional paths can be added
217
+ };
218
+ async function initTxr(projectRoot, options = {}) {
219
+ const txrDir = path2.join(projectRoot, TXR_DIR);
220
+ const repoDir = path2.join(txrDir, REPO_DIR);
221
+ const metadataDir = path2.join(txrDir, METADATA_DIR);
222
+ try {
223
+ await fs2.access(txrDir);
224
+ throw new Error(`Already initialized: ${txrDir} exists.`);
225
+ } catch (err) {
226
+ if (err.message?.startsWith("Already initialized")) throw err;
227
+ }
228
+ await fs2.mkdir(metadataDir, { recursive: true });
229
+ const gitStore = new GitStore(repoDir);
230
+ await gitStore.init();
231
+ await fs2.writeFile(
232
+ path2.join(metadataDir, "mapping.json"),
233
+ JSON.stringify({ version: 1, files: {} }, null, 2),
234
+ "utf-8"
235
+ );
236
+ await fs2.writeFile(
237
+ path2.join(metadataDir, "transactions.json"),
238
+ JSON.stringify({ version: 1, head: null, undoStack: [], counter: 0, transactions: {} }, null, 2),
239
+ "utf-8"
240
+ );
241
+ await fs2.writeFile(
242
+ path2.join(metadataDir, "history.json"),
243
+ JSON.stringify({ version: 1, entries: [] }, null, 2),
244
+ "utf-8"
245
+ );
246
+ const config = options.scope === "global" ? GLOBAL_CONFIG : DEFAULT_CONFIG;
247
+ await fs2.writeFile(
248
+ path2.join(metadataDir, "config.json"),
249
+ JSON.stringify(config, null, 2),
250
+ "utf-8"
251
+ );
252
+ }
253
+ async function findTxrRoot(startDir) {
254
+ let dir = path2.resolve(startDir);
255
+ while (true) {
256
+ const candidate = path2.join(dir, TXR_DIR);
257
+ try {
258
+ const stat4 = await fs2.stat(candidate);
259
+ if (stat4.isDirectory()) return dir;
260
+ } catch {
261
+ }
262
+ const parent = path2.dirname(dir);
263
+ if (parent === dir) {
264
+ throw new Error(
265
+ "Not a txr project (or any parent). Run `txr init` first."
266
+ );
267
+ }
268
+ dir = parent;
269
+ }
270
+ }
271
+ function getTxrPaths(projectRoot) {
272
+ const txrDir = path2.join(projectRoot, TXR_DIR);
273
+ return {
274
+ txrDir,
275
+ repoDir: path2.join(txrDir, REPO_DIR),
276
+ metadataDir: path2.join(txrDir, METADATA_DIR),
277
+ lockFile: path2.join(txrDir, "lock")
278
+ };
279
+ }
280
+ async function loadConfig(metadataDir) {
281
+ try {
282
+ const raw = await fs2.readFile(path2.join(metadataDir, "config.json"), "utf-8");
283
+ return JSON.parse(raw);
284
+ } catch {
285
+ return DEFAULT_CONFIG;
286
+ }
287
+ }
288
+
289
+ // src/core/transaction-store.ts
290
+ import * as fs3 from "fs/promises";
291
+ import * as path3 from "path";
292
+ var TransactionStore = class _TransactionStore {
293
+ data;
294
+ filePath;
295
+ constructor(filePath, data) {
296
+ this.filePath = filePath;
297
+ this.data = data;
298
+ }
299
+ /** Load from disk, or create empty store. */
300
+ static async load(metadataDir) {
301
+ const filePath = path3.join(metadataDir, "transactions.json");
302
+ try {
303
+ const raw = await fs3.readFile(filePath, "utf-8");
304
+ const data = JSON.parse(raw);
305
+ return new _TransactionStore(filePath, data);
306
+ } catch {
307
+ const data = {
308
+ version: 1,
309
+ head: null,
310
+ undoStack: [],
311
+ counter: 0,
312
+ transactions: {}
313
+ };
314
+ return new _TransactionStore(filePath, data);
315
+ }
316
+ }
317
+ async save() {
318
+ const tmp = this.filePath + ".tmp";
319
+ await fs3.writeFile(tmp, JSON.stringify(this.data, null, 2), "utf-8");
320
+ await fs3.rename(tmp, this.filePath);
321
+ }
322
+ /** Generate the next transaction ID (monotonically increasing). */
323
+ nextId() {
324
+ this.data.counter += 1;
325
+ return `tx_${String(this.data.counter).padStart(3, "0")}`;
326
+ }
327
+ /** Get the current HEAD transaction ID, or null if no transactions exist. */
328
+ get head() {
329
+ return this.data.head;
330
+ }
331
+ /** Get a transaction by ID. */
332
+ get(id) {
333
+ return this.data.transactions[id];
334
+ }
335
+ /** Get the HEAD transaction object, or undefined. */
336
+ getHead() {
337
+ return this.data.head ? this.data.transactions[this.data.head] : void 0;
338
+ }
339
+ /** Get the undo stack (most recently undone first). */
340
+ get undoStack() {
341
+ return this.data.undoStack;
342
+ }
343
+ /** Add a new transaction and set it as HEAD. Clears the undo stack. */
344
+ addTransaction(tx) {
345
+ this.data.transactions[tx.id] = tx;
346
+ this.data.head = tx.id;
347
+ this.data.undoStack = [];
348
+ }
349
+ /**
350
+ * Undo: move HEAD to the parent of the current HEAD.
351
+ * Pushes the undone transaction ID onto the undo stack.
352
+ */
353
+ undoHead() {
354
+ const head = this.data.head;
355
+ if (!head) throw new Error("Nothing to undo.");
356
+ const tx = this.data.transactions[head];
357
+ if (!tx) throw new Error(`Transaction "${head}" not found.`);
358
+ this.data.undoStack.unshift(head);
359
+ this.data.head = tx.parent;
360
+ return tx;
361
+ }
362
+ /**
363
+ * Redo: pop the most recently undone transaction from the undo stack
364
+ * and set it as HEAD.
365
+ */
366
+ redoNext() {
367
+ if (this.data.undoStack.length === 0) {
368
+ throw new Error("Nothing to redo.");
369
+ }
370
+ const redoId = this.data.undoStack.shift();
371
+ const tx = this.data.transactions[redoId];
372
+ if (!tx) throw new Error(`Transaction "${redoId}" not found.`);
373
+ this.data.head = redoId;
374
+ return tx;
375
+ }
376
+ /** Get all transactions in chain order (oldest first), up to and including HEAD. */
377
+ getChain() {
378
+ const chain = [];
379
+ let current = this.data.head;
380
+ while (current) {
381
+ const tx = this.data.transactions[current];
382
+ if (!tx) break;
383
+ chain.unshift(tx);
384
+ current = tx.parent;
385
+ }
386
+ return chain;
387
+ }
388
+ /** Get total number of transactions (including orphaned/undone). */
389
+ get totalCount() {
390
+ return Object.keys(this.data.transactions).length;
391
+ }
392
+ /** Get count of transactions in the active chain. */
393
+ get chainLength() {
394
+ return this.getChain().length;
395
+ }
396
+ };
397
+
398
+ // src/core/history-store.ts
399
+ import * as fs4 from "fs/promises";
400
+ import * as path4 from "path";
401
+ var HistoryStore = class _HistoryStore {
402
+ data;
403
+ filePath;
404
+ constructor(filePath, data) {
405
+ this.filePath = filePath;
406
+ this.data = data;
407
+ }
408
+ static async load(metadataDir) {
409
+ const filePath = path4.join(metadataDir, "history.json");
410
+ try {
411
+ const raw = await fs4.readFile(filePath, "utf-8");
412
+ const data = JSON.parse(raw);
413
+ return new _HistoryStore(filePath, data);
414
+ } catch {
415
+ const data = { version: 1, entries: [] };
416
+ return new _HistoryStore(filePath, data);
417
+ }
418
+ }
419
+ async save() {
420
+ const tmp = this.filePath + ".tmp";
421
+ await fs4.writeFile(tmp, JSON.stringify(this.data, null, 2), "utf-8");
422
+ await fs4.rename(tmp, this.filePath);
423
+ }
424
+ /** Append a new history entry. */
425
+ append(entry) {
426
+ this.data.entries.push(entry);
427
+ }
428
+ /** Get all entries (oldest first). */
429
+ getAll() {
430
+ return this.data.entries;
431
+ }
432
+ /** Get the N most recent entries. */
433
+ getRecent(n) {
434
+ return this.data.entries.slice(-n);
435
+ }
436
+ /** Get total entry count. */
437
+ get count() {
438
+ return this.data.entries.length;
439
+ }
440
+ };
441
+
442
+ // src/core/file-map.ts
443
+ import * as fs5 from "fs/promises";
444
+ import * as path5 from "path";
445
+ import { nanoid } from "nanoid";
446
+ var FileMap = class _FileMap {
447
+ data;
448
+ pathIndex;
449
+ // normalized path → fileId
450
+ filePath;
451
+ constructor(filePath, data) {
452
+ this.filePath = filePath;
453
+ this.data = data;
454
+ this.pathIndex = /* @__PURE__ */ new Map();
455
+ this.rebuildPathIndex();
456
+ }
457
+ /** Load mapping from disk, or create empty mapping if file doesn't exist. */
458
+ static async load(metadataDir) {
459
+ const filePath = path5.join(metadataDir, "mapping.json");
460
+ try {
461
+ const raw = await fs5.readFile(filePath, "utf-8");
462
+ const data = JSON.parse(raw);
463
+ return new _FileMap(filePath, data);
464
+ } catch {
465
+ const data = { version: 1, files: {} };
466
+ return new _FileMap(filePath, data);
467
+ }
468
+ }
469
+ /** Persist current state to disk atomically. */
470
+ async save() {
471
+ const tmp = this.filePath + ".tmp";
472
+ await fs5.writeFile(tmp, JSON.stringify(this.data, null, 2), "utf-8");
473
+ await fs5.rename(tmp, this.filePath);
474
+ }
475
+ /** Normalize a path for consistent lookup (forward slashes, resolved). */
476
+ static normalizePath(p) {
477
+ return path5.resolve(p).replace(/\\/g, "/");
478
+ }
479
+ /** Get file ID for a path, or undefined if not mapped. Only returns active (non-deleted) entries. */
480
+ getIdByPath(absolutePath) {
481
+ const norm = _FileMap.normalizePath(absolutePath);
482
+ return this.pathIndex.get(norm);
483
+ }
484
+ /** Get file entry by ID. */
485
+ getEntry(fileId) {
486
+ return this.data.files[fileId];
487
+ }
488
+ /** Get file path by ID (returns undefined if entry doesn't exist). */
489
+ getPath(fileId) {
490
+ return this.data.files[fileId]?.path;
491
+ }
492
+ /** Get or create a file ID for the given path. Returns existing ID if path is already mapped. */
493
+ getOrCreateId(absolutePath, transactionId) {
494
+ const existing = this.getIdByPath(absolutePath);
495
+ if (existing) return existing;
496
+ const fileId = nanoid();
497
+ const norm = _FileMap.normalizePath(absolutePath);
498
+ this.data.files[fileId] = {
499
+ path: norm,
500
+ createdAt: (/* @__PURE__ */ new Date()).toISOString(),
501
+ createdByTx: transactionId
502
+ };
503
+ this.pathIndex.set(norm, fileId);
504
+ return fileId;
505
+ }
506
+ /** Mark a file as deleted. Preserves the mapping entry for undo. */
507
+ markDeleted(fileId, transactionId) {
508
+ const entry = this.data.files[fileId];
509
+ if (!entry) throw new Error(`FileMap: unknown file ID "${fileId}"`);
510
+ entry.deletedAt = (/* @__PURE__ */ new Date()).toISOString();
511
+ entry.deletedByTx = transactionId;
512
+ const norm = _FileMap.normalizePath(entry.path);
513
+ this.pathIndex.delete(norm);
514
+ }
515
+ /** Restore a previously deleted file (used during undo). */
516
+ markRestored(fileId) {
517
+ const entry = this.data.files[fileId];
518
+ if (!entry) throw new Error(`FileMap: unknown file ID "${fileId}"`);
519
+ delete entry.deletedAt;
520
+ delete entry.deletedByTx;
521
+ const norm = _FileMap.normalizePath(entry.path);
522
+ this.pathIndex.set(norm, fileId);
523
+ }
524
+ /** Update the path for a file ID (rename). */
525
+ updatePath(fileId, newAbsolutePath) {
526
+ const entry = this.data.files[fileId];
527
+ if (!entry) throw new Error(`FileMap: unknown file ID "${fileId}"`);
528
+ const oldNorm = _FileMap.normalizePath(entry.path);
529
+ this.pathIndex.delete(oldNorm);
530
+ const newNorm = _FileMap.normalizePath(newAbsolutePath);
531
+ entry.path = newNorm;
532
+ this.pathIndex.set(newNorm, fileId);
533
+ }
534
+ /** Get all active (non-deleted) file entries. */
535
+ getActiveFiles() {
536
+ return Object.entries(this.data.files).filter(([, entry]) => !entry.deletedAt).map(([id, entry]) => ({ id, entry }));
537
+ }
538
+ /** Get count of active files. */
539
+ get activeCount() {
540
+ return this.pathIndex.size;
541
+ }
542
+ /** Rebuild the in-memory path→ID index from persisted data. */
543
+ rebuildPathIndex() {
544
+ this.pathIndex.clear();
545
+ for (const [id, entry] of Object.entries(this.data.files)) {
546
+ if (!entry.deletedAt) {
547
+ const norm = _FileMap.normalizePath(entry.path);
548
+ this.pathIndex.set(norm, id);
549
+ }
550
+ }
551
+ }
552
+ };
553
+
554
+ // src/core/transaction-engine.ts
555
+ import * as fs7 from "fs/promises";
556
+ import * as path8 from "path";
557
+
558
+ // src/attribution/tier3-hybrid.ts
559
+ import * as fs6 from "fs/promises";
560
+ import * as path6 from "path";
561
+ import * as crypto from "crypto";
562
+ import { spawn } from "child_process";
563
+ import { minimatch } from "minimatch";
564
+ var Tier3HybridProvider = class {
565
+ name = "Tier 3: Scoped Filesystem Diff";
566
+ tier = 3;
567
+ async isAvailable() {
568
+ return true;
569
+ }
570
+ async executeAndTrack(command, cwd, options) {
571
+ const warnings = [
572
+ "Using scoped filesystem diff (Tier 3). Changes by concurrent processes within watch paths may be mis-attributed."
573
+ ];
574
+ const resolvedWatchPaths = options.watchPaths.map(
575
+ (p) => path6.isAbsolute(p) ? p : path6.resolve(cwd, p)
576
+ );
577
+ const preSnapshot = await this.snapshotPaths(resolvedWatchPaths, options.ignorePaths);
578
+ const exitCode = await this.executeCommand(command, cwd, options);
579
+ const postSnapshot = await this.snapshotPaths(resolvedWatchPaths, options.ignorePaths);
580
+ const changes = this.diffSnapshots(preSnapshot, postSnapshot);
581
+ this.detectRenames(changes);
582
+ return {
583
+ changes,
584
+ tier: 3,
585
+ processExitCode: exitCode,
586
+ warnings
587
+ };
588
+ }
589
+ /**
590
+ * Recursively hash all files in the given paths.
591
+ * Returns a Map of normalized-path → FileSnapshot.
592
+ */
593
+ async snapshotPaths(watchPaths, ignorePaths) {
594
+ const snapshots = /* @__PURE__ */ new Map();
595
+ for (const watchPath of watchPaths) {
596
+ await this.walkAndHash(watchPath, watchPath, ignorePaths, snapshots);
597
+ }
598
+ return snapshots;
599
+ }
600
+ async walkAndHash(currentPath, rootPath, ignorePaths, snapshots) {
601
+ let stat4;
602
+ try {
603
+ stat4 = await fs6.stat(currentPath);
604
+ } catch {
605
+ return;
606
+ }
607
+ const normalized = currentPath.replace(/\\/g, "/");
608
+ const relativePath = path6.relative(rootPath, currentPath).replace(/\\/g, "/");
609
+ for (const pattern of ignorePaths) {
610
+ if (minimatch(relativePath, pattern, { dot: true }) || minimatch(path6.basename(currentPath), pattern, { dot: true })) {
611
+ return;
612
+ }
613
+ }
614
+ if (stat4.isFile()) {
615
+ const hash = await this.hashFile(currentPath);
616
+ snapshots.set(normalized, {
617
+ path: normalized,
618
+ hash,
619
+ size: stat4.size
620
+ });
621
+ } else if (stat4.isDirectory()) {
622
+ let entries;
623
+ try {
624
+ entries = await fs6.readdir(currentPath);
625
+ } catch {
626
+ return;
627
+ }
628
+ for (const entry of entries) {
629
+ await this.walkAndHash(
630
+ path6.join(currentPath, entry),
631
+ rootPath,
632
+ ignorePaths,
633
+ snapshots
634
+ );
635
+ }
636
+ }
637
+ }
638
+ async hashFile(filePath) {
639
+ const content = await fs6.readFile(filePath);
640
+ return crypto.createHash("sha256").update(content).digest("hex");
641
+ }
642
+ /**
643
+ * Compare pre and post snapshots to detect changes.
644
+ */
645
+ diffSnapshots(pre, post) {
646
+ const changes = [];
647
+ for (const [filePath, postSnap] of post) {
648
+ const preSnap = pre.get(filePath);
649
+ if (!preSnap) {
650
+ changes.push({ absolutePath: filePath, type: "created" /* Created */ });
651
+ } else if (preSnap.hash !== postSnap.hash) {
652
+ changes.push({ absolutePath: filePath, type: "modified" /* Modified */ });
653
+ }
654
+ }
655
+ for (const [filePath] of pre) {
656
+ if (!post.has(filePath)) {
657
+ changes.push({ absolutePath: filePath, type: "deleted" /* Deleted */ });
658
+ }
659
+ }
660
+ return changes;
661
+ }
662
+ /**
663
+ * Best-effort rename detection: if a file was deleted and a new file
664
+ * with identical content hash was created, treat as rename.
665
+ */
666
+ detectRenames(changes) {
667
+ }
668
+ /**
669
+ * Spawn the command and wait for exit.
670
+ * Uses shell execution to support piping, env expansion, etc.
671
+ */
672
+ executeCommand(command, cwd, options) {
673
+ return new Promise((resolve6, reject) => {
674
+ const isWindows = process.platform === "win32";
675
+ const shell = isWindows ? "cmd.exe" : "/bin/sh";
676
+ const shellArgs = isWindows ? ["/c", command] : ["-c", command];
677
+ const child = spawn(shell, shellArgs, {
678
+ cwd,
679
+ env: { ...process.env, ...options.env },
680
+ stdio: [
681
+ options.stdin ?? "inherit",
682
+ options.stdout ?? "inherit",
683
+ options.stderr ?? "inherit"
684
+ ]
685
+ });
686
+ child.on("error", reject);
687
+ child.on("close", (code) => {
688
+ resolve6(code ?? 1);
689
+ });
690
+ });
691
+ }
692
+ };
693
+
694
+ // src/attribution/tier-selector.ts
695
+ async function selectProvider(preferred = "auto") {
696
+ const providers = getOrderedProviders();
697
+ if (preferred !== "auto") {
698
+ const tierNum = parseInt(preferred.replace("tier", ""), 10);
699
+ const match = providers.find((p) => p.tier === tierNum);
700
+ if (match && await match.isAvailable()) return match;
701
+ throw new Error(
702
+ `Preferred attribution tier "${preferred}" is not available on this platform.`
703
+ );
704
+ }
705
+ for (const provider of providers) {
706
+ if (await provider.isAvailable()) {
707
+ return provider;
708
+ }
709
+ }
710
+ throw new Error("No attribution provider available.");
711
+ }
712
+ function getOrderedProviders() {
713
+ return [
714
+ // new Tier1WindowsProvider(), // Phase 5+
715
+ // new Tier1LinuxProvider(), // Phase 5+
716
+ // new Tier2LinuxProvider(), // Phase 3
717
+ new Tier3HybridProvider()
718
+ ];
719
+ }
720
+
721
+ // src/classifier/classifier.ts
722
+ import * as path7 from "path";
723
+ var DEFAULT_RULES = [
724
+ // ── Containers & Orchestration ──
725
+ { pattern: /^docker\b/, type: "non-transactional", category: "container", reason: "Container orchestration \u2014 may modify container/image state" },
726
+ { pattern: /^docker-compose\b/, type: "non-transactional", category: "container", reason: "Container orchestration" },
727
+ { pattern: /^docker compose\b/, type: "non-transactional", category: "container", reason: "Container orchestration" },
728
+ { pattern: /^podman\b/, type: "non-transactional", category: "container", reason: "Container orchestration" },
729
+ { pattern: /^kubectl\b/, type: "non-transactional", category: "container", reason: "Kubernetes cluster management" },
730
+ { pattern: /^helm\b/, type: "non-transactional", category: "container", reason: "Helm chart management" },
731
+ // ── Databases ──
732
+ { pattern: /^(mysql|psql|mongosh?|redis-cli|sqlite3)\b/, type: "non-transactional", category: "database", reason: "Database client \u2014 may modify database state" },
733
+ { pattern: /^prisma\s+(migrate|db\s+push|db\s+seed)/, type: "non-transactional", category: "database", reason: "Database migration/seeding" },
734
+ { pattern: /^(knex|sequelize|typeorm)\s+migrate/, type: "non-transactional", category: "database", reason: "Database migration" },
735
+ // ── Services & Daemons ──
736
+ { pattern: /^(systemctl|service)\b/, type: "non-transactional", category: "service", reason: "System service management" },
737
+ { pattern: /^sc\s+(start|stop|config|create|delete)\b/, type: "non-transactional", category: "service", reason: "Windows service management" },
738
+ { pattern: /^(pm2|forever)\b/, type: "non-transactional", category: "service", reason: "Node.js process manager" },
739
+ // ── System Administration ──
740
+ { pattern: /^sudo\b/, type: "non-transactional", category: "system", reason: "Elevated system command" },
741
+ { pattern: /^(chmod|chown|chgrp)\b/, type: "non-transactional", category: "system", reason: "Permission/ownership change \u2014 not tracked by content hashing" },
742
+ { pattern: /^(mount|umount)\b/, type: "non-transactional", category: "system", reason: "Filesystem mount operation" },
743
+ { pattern: /^(apt|apt-get|yum|dnf|pacman|brew|choco|winget|scoop)\b/, type: "non-transactional", category: "system", reason: "System package manager \u2014 modifies system-level state" },
744
+ // ── Network ──
745
+ { pattern: /^(ssh|scp|rsync)\b/, type: "non-transactional", category: "network", reason: "Remote operation \u2014 effects not local" },
746
+ // ── Version Control (user's repo) ──
747
+ { pattern: /^git\b/, type: "non-transactional", category: "vcs", reason: "Git operation \u2014 may conflict with internal repository" },
748
+ { pattern: /^(svn|hg|bzr)\b/, type: "non-transactional", category: "vcs", reason: "Version control operation" }
749
+ ];
750
+ var SCOPE_ESCAPE_PATTERNS = [
751
+ // Global npm/pnpm/yarn installs
752
+ { pattern: /\bnpm\s+install\s+.*-g\b/, reason: "Global npm install writes to system-level node_modules" },
753
+ { pattern: /\bnpm\s+install\s+.*--global\b/, reason: "Global npm install writes to system-level node_modules" },
754
+ { pattern: /\bpnpm\s+add\s+.*-g\b/, reason: "Global pnpm install writes outside workspace" },
755
+ { pattern: /\byarn\s+global\s+add\b/, reason: "Global yarn install writes outside workspace" },
756
+ // Commands that reference absolute paths outside cwd
757
+ { pattern: /\b(mkdir|touch|cp|mv|rm|rmdir)\s+\//, reason: "Command targets an absolute path (may be outside workspace)" },
758
+ { pattern: /\b(mkdir|touch|cp|mv|rm|rmdir)\s+[A-Z]:\\/, reason: "Command targets an absolute Windows path (may be outside workspace)" },
759
+ // pip / pipx global installs
760
+ { pattern: /\bpip\s+install\b(?!.*(-e|--editable)\s+\.)/, reason: "pip install may write to system site-packages" },
761
+ { pattern: /\bpipx\s+install\b/, reason: "pipx installs globally" },
762
+ // cargo global installs
763
+ { pattern: /\bcargo\s+install\b/, reason: "cargo install writes to ~/.cargo/bin" },
764
+ // go install
765
+ { pattern: /\bgo\s+install\b/, reason: "go install writes to $GOPATH/bin" }
766
+ ];
767
+ function classifyCommand(command, scope = "workspace", projectRoot, extraRules = []) {
768
+ const trimmed = command.trim();
769
+ const allRules = [...extraRules, ...DEFAULT_RULES];
770
+ for (const rule of allRules) {
771
+ if (rule.pattern.test(trimmed)) {
772
+ return {
773
+ type: rule.type,
774
+ category: rule.category,
775
+ reason: rule.reason,
776
+ matchedRule: rule
777
+ };
778
+ }
779
+ }
780
+ if (scope === "workspace") {
781
+ for (const escape of SCOPE_ESCAPE_PATTERNS) {
782
+ if (escape.pattern.test(trimmed)) {
783
+ return {
784
+ type: "scope-external",
785
+ category: "scope",
786
+ reason: escape.reason,
787
+ scopeViolation: true,
788
+ scopeReason: escape.reason
789
+ };
790
+ }
791
+ }
792
+ if (projectRoot) {
793
+ const scopeViolation = detectPathEscape(trimmed, projectRoot);
794
+ if (scopeViolation) {
795
+ return {
796
+ type: "scope-external",
797
+ category: "scope",
798
+ reason: scopeViolation,
799
+ scopeViolation: true,
800
+ scopeReason: scopeViolation
801
+ };
802
+ }
803
+ }
804
+ }
805
+ return { type: "transactional" };
806
+ }
807
+ function detectPathEscape(command, projectRoot) {
808
+ const normalizedRoot = path7.resolve(projectRoot).replace(/\\/g, "/").toLowerCase();
809
+ const tokens = command.split(/\s+/);
810
+ for (const token of tokens) {
811
+ const cleaned = token.replace(/["']/g, "");
812
+ if (cleaned.startsWith("/") && !cleaned.startsWith("/dev/null")) {
813
+ const normalizedToken = path7.resolve(cleaned).replace(/\\/g, "/").toLowerCase();
814
+ if (!normalizedToken.startsWith(normalizedRoot)) {
815
+ return `Path "${cleaned}" is outside the workspace root (${projectRoot})`;
816
+ }
817
+ }
818
+ if (/^[A-Za-z]:[\\/]/.test(cleaned)) {
819
+ const normalizedToken = path7.resolve(cleaned).replace(/\\/g, "/").toLowerCase();
820
+ if (!normalizedToken.startsWith(normalizedRoot)) {
821
+ return `Path "${cleaned}" is outside the workspace root (${projectRoot})`;
822
+ }
823
+ }
824
+ }
825
+ return null;
826
+ }
827
+
828
+ // src/core/transaction-engine.ts
829
+ var TransactionEngine = class {
830
+ constructor(txStore, historyStore, fileMap, gitStore, config, projectRoot) {
831
+ this.txStore = txStore;
832
+ this.historyStore = historyStore;
833
+ this.fileMap = fileMap;
834
+ this.gitStore = gitStore;
835
+ this.config = config;
836
+ this.projectRoot = projectRoot;
837
+ }
838
+ txStore;
839
+ historyStore;
840
+ fileMap;
841
+ gitStore;
842
+ config;
843
+ projectRoot;
844
+ /**
845
+ * Execute a command with full transaction lifecycle.
846
+ *
847
+ * Returns null if the command produced zero tracked changes
848
+ * (a history entry is still recorded).
849
+ */
850
+ async execute(command, cwd, options) {
851
+ const startTime = Date.now();
852
+ const classification = options?.forceTransactional ? { type: "transactional" } : options?.forceNonTransactional ? { type: "non-transactional", reason: "User override" } : classifyCommand(command, this.config.scope, this.projectRoot);
853
+ if (classification.type === "non-transactional" || classification.type === "scope-external") {
854
+ const exitCode = await this.executeDirectly(command, cwd);
855
+ const durationMs2 = Date.now() - startTime;
856
+ this.historyStore.append({
857
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
858
+ command,
859
+ transactionId: null,
860
+ workingDirectory: cwd,
861
+ exitCode,
862
+ classification: classification.type,
863
+ attributionTier: null,
864
+ durationMs: durationMs2
865
+ });
866
+ await this.historyStore.save();
867
+ return null;
868
+ }
869
+ const provider = await selectProvider(this.config.preferredTier);
870
+ const resolvedWatchPaths = this.config.watchPaths.map(
871
+ (p) => path8.isAbsolute(p) ? p : path8.resolve(cwd, p)
872
+ );
873
+ const result = await provider.executeAndTrack(command, cwd, {
874
+ watchPaths: resolvedWatchPaths,
875
+ ignorePaths: this.config.ignorePaths,
876
+ stdin: "inherit",
877
+ stdout: "inherit",
878
+ stderr: "inherit"
879
+ });
880
+ const scopeWarnings = [];
881
+ let trackedChanges;
882
+ if (this.config.scope === "workspace") {
883
+ const normalizedRoot = path8.resolve(this.projectRoot).replace(/\\/g, "/").toLowerCase();
884
+ trackedChanges = [];
885
+ for (const change of result.changes) {
886
+ const normalizedChange = change.absolutePath.replace(/\\/g, "/").toLowerCase();
887
+ if (normalizedChange.startsWith(normalizedRoot)) {
888
+ trackedChanges.push(change);
889
+ } else {
890
+ scopeWarnings.push(
891
+ `Out-of-scope change ignored: ${change.type} ${change.absolutePath}`
892
+ );
893
+ }
894
+ }
895
+ if (scopeWarnings.length > 0) {
896
+ scopeWarnings.unshift(
897
+ `${scopeWarnings.length} file change(s) outside workspace were NOT tracked. These changes cannot be undone. Run with 'txr init --global' for full tracking.`
898
+ );
899
+ }
900
+ } else {
901
+ trackedChanges = result.changes;
902
+ }
903
+ if (trackedChanges.length === 0) {
904
+ const durationMs2 = Date.now() - startTime;
905
+ this.historyStore.append({
906
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
907
+ command,
908
+ transactionId: null,
909
+ workingDirectory: cwd,
910
+ exitCode: result.processExitCode,
911
+ classification: "transactional",
912
+ attributionTier: result.tier,
913
+ durationMs: durationMs2
914
+ });
915
+ await this.historyStore.save();
916
+ for (const w of scopeWarnings) {
917
+ process.stderr.write(`\x1B[33m\u26A0\x1B[0m ${w}
918
+ `);
919
+ }
920
+ return null;
921
+ }
922
+ const txId = this.txStore.nextId();
923
+ const touchedFiles = [];
924
+ const changeManifest = {};
925
+ for (const change of trackedChanges) {
926
+ const fileId = this.resolveFileId(change, txId);
927
+ touchedFiles.push(fileId);
928
+ changeManifest[fileId] = this.mapChangeType(change.type);
929
+ if (change.type === "deleted" /* Deleted */) {
930
+ await this.gitStore.removeFile(fileId);
931
+ } else {
932
+ const content = await fs7.readFile(change.absolutePath);
933
+ await this.gitStore.writeFile(fileId, content);
934
+ }
935
+ }
936
+ const commitMessage = `txr: ${txId} | ${this.summarizeCommand(command)}`;
937
+ const commitHash = await this.gitStore.commit(commitMessage);
938
+ const tx = {
939
+ id: txId,
940
+ parent: this.txStore.head,
941
+ commit: commitHash,
942
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
943
+ command,
944
+ touchedFiles,
945
+ changeManifest,
946
+ workingDirectory: cwd,
947
+ exitCode: result.processExitCode,
948
+ attributionTier: result.tier,
949
+ scope: this.config.scope,
950
+ scopeWarnings
951
+ };
952
+ this.txStore.addTransaction(tx);
953
+ await this.txStore.save();
954
+ await this.fileMap.save();
955
+ const durationMs = Date.now() - startTime;
956
+ this.historyStore.append({
957
+ timestamp: tx.timestamp,
958
+ command,
959
+ transactionId: txId,
960
+ workingDirectory: cwd,
961
+ exitCode: result.processExitCode,
962
+ classification: "transactional",
963
+ attributionTier: result.tier,
964
+ durationMs
965
+ });
966
+ await this.historyStore.save();
967
+ return {
968
+ transactionId: txId,
969
+ commit: commitHash,
970
+ changesCount: trackedChanges.length,
971
+ tier: result.tier,
972
+ durationMs,
973
+ scopeWarnings
974
+ };
975
+ }
976
+ /** Resolve a FileChange to a file ID (creating new IDs as needed). */
977
+ resolveFileId(change, txId) {
978
+ if (change.type === "renamed" /* Renamed */ && change.oldPath) {
979
+ const existingId = this.fileMap.getIdByPath(change.oldPath);
980
+ if (existingId) {
981
+ this.fileMap.updatePath(existingId, change.absolutePath);
982
+ return existingId;
983
+ }
984
+ }
985
+ if (change.type === "deleted" /* Deleted */) {
986
+ const existingId = this.fileMap.getIdByPath(change.absolutePath);
987
+ if (existingId) {
988
+ this.fileMap.markDeleted(existingId, txId);
989
+ return existingId;
990
+ }
991
+ }
992
+ return this.fileMap.getOrCreateId(change.absolutePath, txId);
993
+ }
994
+ /** Map FileChangeType to ChangeType for storage. */
995
+ mapChangeType(type) {
996
+ switch (type) {
997
+ case "created" /* Created */:
998
+ return "created";
999
+ case "modified" /* Modified */:
1000
+ return "modified";
1001
+ case "deleted" /* Deleted */:
1002
+ return "deleted";
1003
+ case "renamed" /* Renamed */:
1004
+ return "modified";
1005
+ }
1006
+ }
1007
+ /** Truncate command for commit message. */
1008
+ summarizeCommand(command) {
1009
+ return command.length > 72 ? command.substring(0, 69) + "..." : command;
1010
+ }
1011
+ /** Execute a command directly without tracking. */
1012
+ executeDirectly(command, cwd) {
1013
+ const { spawn: spawn2 } = __require("child_process");
1014
+ return new Promise((resolve6, reject) => {
1015
+ const isWindows = process.platform === "win32";
1016
+ const shell = isWindows ? "cmd.exe" : "/bin/sh";
1017
+ const shellArgs = isWindows ? ["/c", command] : ["-c", command];
1018
+ const child = spawn2(shell, shellArgs, {
1019
+ cwd,
1020
+ stdio: "inherit"
1021
+ });
1022
+ child.on("error", reject);
1023
+ child.on("close", (code) => resolve6(code ?? 1));
1024
+ });
1025
+ }
1026
+ };
1027
+
1028
+ // src/cli/commands/run.ts
1029
+ async function runCommand(command, options) {
1030
+ const cwd = process.cwd();
1031
+ const projectRoot = await findTxrRoot(cwd);
1032
+ const paths = getTxrPaths(projectRoot);
1033
+ const config = await loadConfig(paths.metadataDir);
1034
+ if (!options.transactional && !options.noTransaction) {
1035
+ const classification = classifyCommand(command, config.scope, projectRoot);
1036
+ if ((classification.type === "non-transactional" || classification.type === "scope-external") && config.confirmUnsafe && !options.yes) {
1037
+ const readline = await import("readline");
1038
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
1039
+ const answer = await new Promise((resolve6) => {
1040
+ console.log();
1041
+ if (classification.type === "scope-external") {
1042
+ console.log(`\x1B[33m\u26A0 SCOPE WARNING:\x1B[0m This command may modify files outside your workspace.`);
1043
+ } else {
1044
+ console.log(`\x1B[33m\u26A0 WARNING:\x1B[0m This command may modify state outside the transaction engine.`);
1045
+ }
1046
+ console.log(` Category: ${classification.category}`);
1047
+ console.log(` Reason: ${classification.reason}`);
1048
+ console.log();
1049
+ console.log(" Rollback guarantees cannot be provided.");
1050
+ console.log();
1051
+ rl.question(" Continue without tracking? [y/N] ", resolve6);
1052
+ });
1053
+ rl.close();
1054
+ if (answer.toLowerCase() !== "y") {
1055
+ console.log("Aborted.");
1056
+ process.exit(0);
1057
+ }
1058
+ }
1059
+ }
1060
+ const txStore = await TransactionStore.load(paths.metadataDir);
1061
+ const historyStore = await HistoryStore.load(paths.metadataDir);
1062
+ const fileMap = await FileMap.load(paths.metadataDir);
1063
+ const gitStore = new GitStore(paths.repoDir);
1064
+ const engine = new TransactionEngine(txStore, historyStore, fileMap, gitStore, config, projectRoot);
1065
+ const result = await engine.execute(command, cwd, {
1066
+ forceTransactional: options.transactional,
1067
+ forceNonTransactional: options.noTransaction
1068
+ });
1069
+ if (result) {
1070
+ console.log();
1071
+ console.log(`\x1B[32m\u2713\x1B[0m Transaction \x1B[1m${result.transactionId}\x1B[0m committed`);
1072
+ console.log(` ${result.changesCount} file(s) changed | Tier ${result.tier} | ${result.durationMs}ms`);
1073
+ if (result.scopeWarnings && result.scopeWarnings.length > 0) {
1074
+ console.log();
1075
+ for (const warning of result.scopeWarnings) {
1076
+ console.log(` \x1B[33m\u26A0\x1B[0m ${warning}`);
1077
+ }
1078
+ }
1079
+ } else {
1080
+ console.log();
1081
+ console.log(`\x1B[90mCommand completed. No transaction created.\x1B[0m`);
1082
+ }
1083
+ }
1084
+
1085
+ // src/core/undo-engine.ts
1086
+ import * as fs8 from "fs/promises";
1087
+ import * as path9 from "path";
1088
+ import * as crypto2 from "crypto";
1089
+ var UndoConflictError = class extends Error {
1090
+ conflicts;
1091
+ constructor(message, conflicts = []) {
1092
+ super(message);
1093
+ this.name = "UndoConflictError";
1094
+ this.conflicts = conflicts;
1095
+ }
1096
+ };
1097
+ var UndoEngine = class {
1098
+ constructor(txStore, fileMap, gitStore) {
1099
+ this.txStore = txStore;
1100
+ this.fileMap = fileMap;
1101
+ this.gitStore = gitStore;
1102
+ }
1103
+ txStore;
1104
+ fileMap;
1105
+ gitStore;
1106
+ /** Undo the current HEAD transaction. */
1107
+ async undo() {
1108
+ const headTx = this.txStore.getHead();
1109
+ if (!headTx) throw new Error("Nothing to undo.");
1110
+ const parentTx = headTx.parent ? this.txStore.get(headTx.parent) : void 0;
1111
+ const conflicts = await this.validateForUndo(headTx);
1112
+ if (conflicts.length > 0) {
1113
+ throw new UndoConflictError(
1114
+ `Cannot undo ${headTx.id}: ${conflicts.length} file(s) have been externally modified.
1115
+
1116
+ ` + conflicts.join("\n"),
1117
+ conflicts
1118
+ );
1119
+ }
1120
+ const ops = [];
1121
+ for (const fileId of headTx.touchedFiles) {
1122
+ const filePath = this.fileMap.getPath(fileId);
1123
+ if (!filePath) continue;
1124
+ const changeType = headTx.changeManifest[fileId];
1125
+ switch (changeType) {
1126
+ case "created":
1127
+ ops.push({ type: "delete", path: filePath });
1128
+ break;
1129
+ case "modified": {
1130
+ const prevContent = await this.gitStore.readBlob(parentTx.commit, fileId);
1131
+ ops.push({ type: "write", path: filePath, content: prevContent });
1132
+ break;
1133
+ }
1134
+ case "deleted": {
1135
+ const restoredContent = await this.gitStore.readBlob(parentTx.commit, fileId);
1136
+ ops.push({ type: "write", path: filePath, content: restoredContent });
1137
+ break;
1138
+ }
1139
+ }
1140
+ }
1141
+ const writes = ops.filter((o) => o.type === "write");
1142
+ const deletes = ops.filter((o) => o.type === "delete");
1143
+ for (const op of writes) {
1144
+ if (op.type === "write") {
1145
+ await fs8.mkdir(path9.dirname(op.path), { recursive: true });
1146
+ await fs8.writeFile(op.path, op.content);
1147
+ }
1148
+ }
1149
+ for (const op of deletes) {
1150
+ if (op.type === "delete") {
1151
+ try {
1152
+ await fs8.unlink(op.path);
1153
+ } catch {
1154
+ }
1155
+ await this.removeEmptyParents(op.path);
1156
+ }
1157
+ }
1158
+ for (const fileId of headTx.touchedFiles) {
1159
+ const changeType = headTx.changeManifest[fileId];
1160
+ if (changeType === "created") {
1161
+ this.fileMap.markDeleted(fileId, `undo:${headTx.id}`);
1162
+ } else if (changeType === "deleted") {
1163
+ this.fileMap.markRestored(fileId);
1164
+ }
1165
+ }
1166
+ if (parentTx) {
1167
+ await this.gitStore.resetHard(parentTx.commit);
1168
+ } else {
1169
+ await this.gitStore.resetToEmpty();
1170
+ }
1171
+ this.txStore.undoHead();
1172
+ await this.txStore.save();
1173
+ await this.fileMap.save();
1174
+ return {
1175
+ undone: headTx.id,
1176
+ newHead: headTx.parent,
1177
+ filesRestored: ops.length
1178
+ };
1179
+ }
1180
+ /** Redo the most recently undone transaction. */
1181
+ async redo() {
1182
+ if (this.txStore.undoStack.length === 0) {
1183
+ throw new Error("Nothing to redo.");
1184
+ }
1185
+ const redoTxId = this.txStore.undoStack[0];
1186
+ const redoTx = this.txStore.get(redoTxId);
1187
+ if (!redoTx) throw new Error(`Transaction "${redoTxId}" not found.`);
1188
+ const parentTx = redoTx.parent ? this.txStore.get(redoTx.parent) : void 0;
1189
+ const conflicts = await this.validateForRedo(redoTx, parentTx);
1190
+ if (conflicts.length > 0) {
1191
+ throw new UndoConflictError(
1192
+ `Cannot redo ${redoTx.id}: filesystem state does not match expected pre-redo state.
1193
+
1194
+ ` + conflicts.join("\n"),
1195
+ conflicts
1196
+ );
1197
+ }
1198
+ let filesChanged = 0;
1199
+ for (const fileId of redoTx.touchedFiles) {
1200
+ const filePath = this.fileMap.getPath(fileId) ?? this.fileMap.getEntry(fileId)?.path;
1201
+ if (!filePath) continue;
1202
+ const changeType = redoTx.changeManifest[fileId];
1203
+ switch (changeType) {
1204
+ case "created":
1205
+ case "modified": {
1206
+ const content = await this.gitStore.readBlob(redoTx.commit, fileId);
1207
+ await fs8.mkdir(path9.dirname(filePath), { recursive: true });
1208
+ await fs8.writeFile(filePath, content);
1209
+ filesChanged++;
1210
+ break;
1211
+ }
1212
+ case "deleted": {
1213
+ try {
1214
+ await fs8.unlink(filePath);
1215
+ } catch {
1216
+ }
1217
+ await this.removeEmptyParents(filePath);
1218
+ filesChanged++;
1219
+ break;
1220
+ }
1221
+ }
1222
+ }
1223
+ for (const fileId of redoTx.touchedFiles) {
1224
+ const changeType = redoTx.changeManifest[fileId];
1225
+ if (changeType === "created") {
1226
+ this.fileMap.markRestored(fileId);
1227
+ } else if (changeType === "deleted") {
1228
+ this.fileMap.markDeleted(fileId, `redo:${redoTx.id}`);
1229
+ }
1230
+ }
1231
+ await this.gitStore.resetHard(redoTx.commit);
1232
+ this.txStore.redoNext();
1233
+ await this.txStore.save();
1234
+ await this.fileMap.save();
1235
+ return {
1236
+ redone: redoTx.id,
1237
+ newHead: redoTx.id,
1238
+ filesChanged
1239
+ };
1240
+ }
1241
+ /**
1242
+ * Validate all touched files for undo.
1243
+ * Returns an array of human-readable conflict descriptions.
1244
+ * Empty array = safe to proceed.
1245
+ */
1246
+ async validateForUndo(tx) {
1247
+ const conflicts = [];
1248
+ for (const fileId of tx.touchedFiles) {
1249
+ const filePath = this.fileMap.getPath(fileId) ?? this.fileMap.getEntry(fileId)?.path;
1250
+ if (!filePath) {
1251
+ conflicts.push(` \u2717 [${fileId}]: File ID has no path mapping.`);
1252
+ continue;
1253
+ }
1254
+ const changeType = tx.changeManifest[fileId];
1255
+ const existsOnDisk = await this.fileExists(filePath);
1256
+ if (changeType === "deleted") {
1257
+ if (existsOnDisk) {
1258
+ conflicts.push(
1259
+ ` \u2717 ${filePath}
1260
+ Was deleted by ${tx.id} but now exists on disk.`
1261
+ );
1262
+ }
1263
+ continue;
1264
+ }
1265
+ if (!existsOnDisk) {
1266
+ conflicts.push(
1267
+ ` \u2717 ${filePath}
1268
+ Expected on disk but missing.`
1269
+ );
1270
+ continue;
1271
+ }
1272
+ const expectedContent = await this.gitStore.readBlob(tx.commit, fileId);
1273
+ const actualContent = await fs8.readFile(filePath);
1274
+ if (!this.hashEquals(expectedContent, actualContent)) {
1275
+ const expectedHash = this.hash(expectedContent).substring(0, 12);
1276
+ const actualHash = this.hash(actualContent).substring(0, 12);
1277
+ conflicts.push(
1278
+ ` \u2717 ${filePath}
1279
+ Expected hash: ${expectedHash}...
1280
+ Actual hash: ${actualHash}...`
1281
+ );
1282
+ }
1283
+ }
1284
+ return conflicts;
1285
+ }
1286
+ /**
1287
+ * Validate current state matches the parent of the redo transaction.
1288
+ */
1289
+ async validateForRedo(redoTx, parentTx) {
1290
+ const conflicts = [];
1291
+ for (const fileId of redoTx.touchedFiles) {
1292
+ const filePath = this.fileMap.getPath(fileId) ?? this.fileMap.getEntry(fileId)?.path;
1293
+ if (!filePath) continue;
1294
+ const changeType = redoTx.changeManifest[fileId];
1295
+ const existsOnDisk = await this.fileExists(filePath);
1296
+ if (changeType === "created") {
1297
+ if (existsOnDisk) {
1298
+ conflicts.push(
1299
+ ` \u2717 ${filePath}
1300
+ Should not exist (it was created by ${redoTx.id}).`
1301
+ );
1302
+ }
1303
+ continue;
1304
+ }
1305
+ if (!existsOnDisk) {
1306
+ conflicts.push(
1307
+ ` \u2717 ${filePath}
1308
+ Expected on disk but missing.`
1309
+ );
1310
+ continue;
1311
+ }
1312
+ if (parentTx) {
1313
+ try {
1314
+ const expectedContent = await this.gitStore.readBlob(parentTx.commit, fileId);
1315
+ const actualContent = await fs8.readFile(filePath);
1316
+ if (!this.hashEquals(expectedContent, actualContent)) {
1317
+ conflicts.push(
1318
+ ` \u2717 ${filePath}
1319
+ Current content does not match expected parent state.`
1320
+ );
1321
+ }
1322
+ } catch {
1323
+ }
1324
+ }
1325
+ }
1326
+ return conflicts;
1327
+ }
1328
+ async fileExists(filePath) {
1329
+ try {
1330
+ await fs8.access(filePath);
1331
+ return true;
1332
+ } catch {
1333
+ return false;
1334
+ }
1335
+ }
1336
+ hash(content) {
1337
+ return crypto2.createHash("sha256").update(content).digest("hex");
1338
+ }
1339
+ hashEquals(a, b) {
1340
+ return this.hash(a) === this.hash(b);
1341
+ }
1342
+ /** Remove empty parent directories up to a reasonable depth. */
1343
+ async removeEmptyParents(filePath) {
1344
+ let dir = path9.dirname(filePath);
1345
+ for (let i = 0; i < 10; i++) {
1346
+ try {
1347
+ const entries = await fs8.readdir(dir);
1348
+ if (entries.length === 0) {
1349
+ await fs8.rmdir(dir);
1350
+ dir = path9.dirname(dir);
1351
+ } else {
1352
+ break;
1353
+ }
1354
+ } catch {
1355
+ break;
1356
+ }
1357
+ }
1358
+ }
1359
+ };
1360
+
1361
+ // src/cli/commands/undo.ts
1362
+ async function undoCommand() {
1363
+ const cwd = process.cwd();
1364
+ const projectRoot = await findTxrRoot(cwd);
1365
+ const paths = getTxrPaths(projectRoot);
1366
+ const txStore = await TransactionStore.load(paths.metadataDir);
1367
+ const fileMap = await FileMap.load(paths.metadataDir);
1368
+ const gitStore = new GitStore(paths.repoDir);
1369
+ const engine = new UndoEngine(txStore, fileMap, gitStore);
1370
+ try {
1371
+ const result = await engine.undo();
1372
+ console.log();
1373
+ console.log(`\x1B[32m\u2713\x1B[0m Undone: \x1B[1m${result.undone}\x1B[0m`);
1374
+ console.log(` ${result.filesRestored} file operation(s) applied`);
1375
+ console.log(` HEAD: ${result.newHead ?? "(empty)"}`);
1376
+ } catch (err) {
1377
+ if (err instanceof UndoConflictError) {
1378
+ console.error();
1379
+ console.error(`\x1B[31mERROR:\x1B[0m ${err.message}`);
1380
+ console.error();
1381
+ console.error("Undo aborted. No files were changed.");
1382
+ console.error();
1383
+ console.error("To resolve: manually revert the conflicting files, then retry `txr undo`.");
1384
+ process.exit(1);
1385
+ }
1386
+ throw err;
1387
+ }
1388
+ }
1389
+ async function redoCommand() {
1390
+ const cwd = process.cwd();
1391
+ const projectRoot = await findTxrRoot(cwd);
1392
+ const paths = getTxrPaths(projectRoot);
1393
+ const txStore = await TransactionStore.load(paths.metadataDir);
1394
+ const fileMap = await FileMap.load(paths.metadataDir);
1395
+ const gitStore = new GitStore(paths.repoDir);
1396
+ const engine = new UndoEngine(txStore, fileMap, gitStore);
1397
+ try {
1398
+ const result = await engine.redo();
1399
+ console.log();
1400
+ console.log(`\x1B[32m\u2713\x1B[0m Redone: \x1B[1m${result.redone}\x1B[0m`);
1401
+ console.log(` ${result.filesChanged} file(s) changed`);
1402
+ console.log(` HEAD: ${result.newHead}`);
1403
+ } catch (err) {
1404
+ if (err instanceof UndoConflictError) {
1405
+ console.error();
1406
+ console.error(`\x1B[31mERROR:\x1B[0m ${err.message}`);
1407
+ console.error();
1408
+ console.error("Redo aborted. No files were changed.");
1409
+ process.exit(1);
1410
+ }
1411
+ throw err;
1412
+ }
1413
+ }
1414
+
1415
+ // src/cli/commands/status.ts
1416
+ async function statusCommand() {
1417
+ const cwd = process.cwd();
1418
+ const projectRoot = await findTxrRoot(cwd);
1419
+ const paths = getTxrPaths(projectRoot);
1420
+ const config = await loadConfig(paths.metadataDir);
1421
+ const txStore = await TransactionStore.load(paths.metadataDir);
1422
+ const provider = await selectProvider(config.preferredTier);
1423
+ const head = txStore.getHead();
1424
+ console.log();
1425
+ console.log(`\x1B[1mtxr status\x1B[0m`);
1426
+ console.log(` Root: ${projectRoot}`);
1427
+ console.log(` Attribution: ${provider.name}`);
1428
+ console.log(` HEAD: ${head ? `${head.id} (${head.command})` : "(empty)"}`);
1429
+ console.log(` Chain length: ${txStore.chainLength}`);
1430
+ console.log(` Redo stack: ${txStore.undoStack.length} transaction(s)`);
1431
+ console.log(` Watch paths: ${config.watchPaths.join(", ")}`);
1432
+ }
1433
+ async function historyCommand(count = 20) {
1434
+ const cwd = process.cwd();
1435
+ const projectRoot = await findTxrRoot(cwd);
1436
+ const paths = getTxrPaths(projectRoot);
1437
+ const historyStore = await HistoryStore.load(paths.metadataDir);
1438
+ const entries = historyStore.getRecent(count);
1439
+ if (entries.length === 0) {
1440
+ console.log("\nNo command history yet.");
1441
+ return;
1442
+ }
1443
+ console.log();
1444
+ console.log(`\x1B[1mCommand History\x1B[0m (${entries.length} most recent)
1445
+ `);
1446
+ for (const entry of entries) {
1447
+ const txLabel = entry.transactionId ? `\x1B[32m${entry.transactionId}\x1B[0m` : "\x1B[90mno-tx\x1B[0m";
1448
+ const tierLabel = entry.attributionTier ? `T${entry.attributionTier}` : " ";
1449
+ const timeStr = new Date(entry.timestamp).toLocaleTimeString();
1450
+ console.log(` ${timeStr} ${tierLabel} ${txLabel} ${entry.command}`);
1451
+ }
1452
+ }
1453
+
1454
+ // src/index.ts
1455
+ var program = new Command();
1456
+ program.name("txr").description("Transactional command runner \u2014 undo any shell command").version("0.1.0");
1457
+ program.command("init").description("Initialize txr in the current directory").option("-g, --global", "Enable global tracking mode (track files outside workspace)").action(async (opts) => {
1458
+ try {
1459
+ const scope = opts.global ? "global" : "workspace";
1460
+ await initTxr(process.cwd(), { scope });
1461
+ const provider = await selectProvider("auto");
1462
+ console.log();
1463
+ console.log("\x1B[32m\u2713\x1B[0m Initialized txr");
1464
+ console.log(` Directory: .txr/`);
1465
+ console.log(` Scope: ${scope}`);
1466
+ console.log(` Attribution: ${provider.name}`);
1467
+ console.log();
1468
+ console.log('Run commands with: \x1B[1mtxr run "<command>"\x1B[0m');
1469
+ } catch (err) {
1470
+ console.error(`\x1B[31mError:\x1B[0m ${err.message}`);
1471
+ process.exit(1);
1472
+ }
1473
+ });
1474
+ program.command("run").description("Execute a command with transactional tracking").argument("<command>", "The shell command to execute").option("-y, --yes", "Skip confirmation for non-transactional commands").option("--transactional", "Force transactional execution").option("--no-transaction", "Force non-transactional execution").action(async (command, opts) => {
1475
+ try {
1476
+ await runCommand(command, {
1477
+ yes: opts.yes,
1478
+ transactional: opts.transactional,
1479
+ noTransaction: opts.noTransaction
1480
+ });
1481
+ } catch (err) {
1482
+ console.error(`\x1B[31mError:\x1B[0m ${err.message}`);
1483
+ process.exit(1);
1484
+ }
1485
+ });
1486
+ program.command("undo").description("Undo the most recent transaction").action(async () => {
1487
+ try {
1488
+ await undoCommand();
1489
+ } catch (err) {
1490
+ console.error(`\x1B[31mError:\x1B[0m ${err.message}`);
1491
+ process.exit(1);
1492
+ }
1493
+ });
1494
+ program.command("redo").description("Redo the most recently undone transaction").action(async () => {
1495
+ try {
1496
+ await redoCommand();
1497
+ } catch (err) {
1498
+ console.error(`\x1B[31mError:\x1B[0m ${err.message}`);
1499
+ process.exit(1);
1500
+ }
1501
+ });
1502
+ program.command("status").description("Show current transaction state").action(async () => {
1503
+ try {
1504
+ await statusCommand();
1505
+ } catch (err) {
1506
+ console.error(`\x1B[31mError:\x1B[0m ${err.message}`);
1507
+ process.exit(1);
1508
+ }
1509
+ });
1510
+ program.command("history").description("Show command history").option("-n, --count <number>", "Number of entries to show", "20").action(async (opts) => {
1511
+ try {
1512
+ await historyCommand(parseInt(opts.count, 10));
1513
+ } catch (err) {
1514
+ console.error(`\x1B[31mError:\x1B[0m ${err.message}`);
1515
+ process.exit(1);
1516
+ }
1517
+ });
1518
+ program.parse();