From 1f0d6faab0ebb8bbf1cfbd061ee9628c815d8347 Mon Sep 17 00:00:00 2001 From: unknown Date: Sat, 28 Mar 2026 16:25:06 -0700 Subject: [PATCH] Add thinktank undo command to reverse last applied diff Apply now saves the diff to .thinktank/last-applied.patch. thinktank undo reads this patch and runs git apply --reverse. Clear error if no patch exists. Generated by thinktank Opus (5 agents, 4 pass, Copeland: #1 at +3). Closes #67 Co-Authored-By: Claude Opus 4.6 (1M context) --- src/cli.ts | 8 ++++ src/commands/apply.ts | 8 +++- src/commands/undo.test.ts | 85 +++++++++++++++++++++++++++++++++++++++ src/commands/undo.ts | 46 +++++++++++++++++++++ 4 files changed, 146 insertions(+), 1 deletion(-) create mode 100644 src/commands/undo.test.ts create mode 100644 src/commands/undo.ts diff --git a/src/cli.ts b/src/cli.ts index b5f9ec5..f4bbeef 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -9,6 +9,7 @@ import { evaluate } from "./commands/evaluate.js"; import { list } from "./commands/list.js"; import { run } from "./commands/run.js"; import { stats } from "./commands/stats.js"; +import { undo } from "./commands/undo.js"; import { loadConfig } from "./utils/config.js"; import { resolvePrompt } from "./utils/prompt.js"; @@ -113,6 +114,13 @@ program }); }); +program + .command("undo") + .description("Reverse the last applied diff (from `thinktank apply`)") + .action(async () => { + await undo(); + }); + program .command("compare ") .description("Compare two agents' results side by side") diff --git a/src/commands/apply.ts b/src/commands/apply.ts index e6f2717..4d83140 100644 --- a/src/commands/apply.ts +++ b/src/commands/apply.ts @@ -1,5 +1,5 @@ import { execFile } from "node:child_process"; -import { readFile } from "node:fs/promises"; +import { mkdir, readFile, writeFile } from "node:fs/promises"; import { join } from "node:path"; import { promisify } from "node:util"; import pc from "picocolors"; @@ -104,6 +104,12 @@ export async function apply(opts: ApplyOptions): Promise { child.child.stdin?.write(agent.diff); child.child.stdin?.end(); await child; + + // Save patch for undo + const patchDir = join(repoRoot, ".thinktank"); + await mkdir(patchDir, { recursive: true }); + await writeFile(join(patchDir, "last-applied.patch"), agent.diff, "utf-8"); + console.log(" Changes applied successfully."); } catch (err: unknown) { const e = err as { stderr?: string }; diff --git a/src/commands/undo.test.ts b/src/commands/undo.test.ts new file mode 100644 index 0000000..dbe9bb8 --- /dev/null +++ b/src/commands/undo.test.ts @@ -0,0 +1,85 @@ +import assert from "node:assert/strict"; +import { execFile } from "node:child_process"; +import { mkdir, readFile, rm, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { describe, it } from "node:test"; +import { promisify } from "node:util"; + +const exec = promisify(execFile); + +describe("undo", () => { + it("is exported and callable", async () => { + const { undo } = await import("./undo.js"); + assert.equal(typeof undo, "function"); + }); +}); + +describe("undo patch file lifecycle", () => { + it("apply saves patch and undo reverses it in a temp repo", async () => { + // Set up a temporary git repo + const { mkdtemp } = await import("node:fs/promises"); + const { tmpdir } = await import("node:os"); + const tempDir = await mkdtemp(join(tmpdir(), "thinktank-undo-test-")); + + try { + await exec("git", ["init", tempDir]); + await exec("git", ["config", "user.email", "test@test.com"], { cwd: tempDir }); + await exec("git", ["config", "user.name", "Test"], { cwd: tempDir }); + + // Create initial file and commit + const filePath = join(tempDir, "hello.txt"); + await writeFile(filePath, "hello\n", "utf-8"); + await exec("git", ["add", "."], { cwd: tempDir }); + await exec("git", ["commit", "-m", "initial"], { cwd: tempDir }); + + // Create a patch that adds a line + const patch = [ + "diff --git a/hello.txt b/hello.txt", + "--- a/hello.txt", + "+++ b/hello.txt", + "@@ -1 +1,2 @@", + " hello", + "+world", + "", + ].join("\n"); + + // Apply the patch via git + const applyChild = exec("git", ["apply", "-"], { cwd: tempDir }); + applyChild.child.stdin?.write(patch); + applyChild.child.stdin?.end(); + await applyChild; + + // Save the patch (simulating what apply.ts does) + const patchDir = join(tempDir, ".thinktank"); + await mkdir(patchDir, { recursive: true }); + const patchPath = join(patchDir, "last-applied.patch"); + await writeFile(patchPath, patch, "utf-8"); + + // Verify the file was changed + const afterApply = (await readFile(filePath, "utf-8")).replace(/\r\n/g, "\n"); + assert.equal(afterApply, "hello\nworld\n"); + + // Reverse the patch + const savedPatch = await readFile(patchPath, "utf-8"); + const reverseChild = exec("git", ["apply", "--reverse", "-"], { cwd: tempDir }); + reverseChild.child.stdin?.write(savedPatch); + reverseChild.child.stdin?.end(); + await reverseChild; + + // Verify the file is back to original + const afterUndo = (await readFile(filePath, "utf-8")).replace(/\r\n/g, "\n"); + assert.equal(afterUndo, "hello\n"); + + // Clean up patch + await rm(patchPath); + } finally { + await rm(tempDir, { recursive: true, force: true }); + } + }); + + it("errors when no patch file exists", async () => { + const { readFile: rf } = await import("node:fs/promises"); + const fakePath = join("/tmp", "nonexistent-thinktank-dir", "last-applied.patch"); + await assert.rejects(rf(fakePath, "utf-8"), "Should throw when patch file does not exist"); + }); +}); diff --git a/src/commands/undo.ts b/src/commands/undo.ts new file mode 100644 index 0000000..7738ad1 --- /dev/null +++ b/src/commands/undo.ts @@ -0,0 +1,46 @@ +import { execFile } from "node:child_process"; +import { readFile, rm } from "node:fs/promises"; +import { join } from "node:path"; +import { promisify } from "node:util"; +import pc from "picocolors"; +import { getRepoRoot } from "../utils/git.js"; + +const exec = promisify(execFile); + +export async function undo(): Promise { + const repoRoot = await getRepoRoot(); + const patchPath = join(repoRoot, ".thinktank", "last-applied.patch"); + + let patch: string; + try { + patch = await readFile(patchPath, "utf-8"); + } catch { + console.error(pc.red(" No applied patch to undo.")); + console.error(); + console.error(" Run `thinktank apply` first to apply an agent's changes."); + process.exit(1); + } + + console.log(" Reversing last applied patch..."); + + try { + const child = exec("git", ["apply", "--reverse", "-"], { cwd: repoRoot }); + child.child.stdin?.write(patch); + child.child.stdin?.end(); + await child; + } catch (err: unknown) { + const e = err as { stderr?: string }; + console.error(pc.red(" Failed to reverse the patch.")); + if (e.stderr) console.error(` ${e.stderr}`); + console.error(" The patch file is at: .thinktank/last-applied.patch"); + console.error(" You may need to reverse changes manually."); + process.exit(1); + } + + await rm(patchPath).catch(() => {}); + + console.log(pc.green(" Undo complete — last applied patch has been reversed.")); + console.log(); + console.log(" Review with: git diff"); + console.log(); +}