From 2f13a2d6c27fda2ced66558460a72c11c4d43c26 Mon Sep 17 00:00:00 2001 From: Scott Haug Date: Mon, 27 Jul 2026 22:26:09 -0700 Subject: [PATCH] fix: keep database comparison output ephemeral by default ## Summary - Capture database comparison output in a restricted temporary workspace by default and remove it on every exit path - Retain exact raw output only through an explicit, permission-restricted destination while preserving the legacy `--out-dir` spelling as an opt-in - Bound command and diff diagnostics and cover success, difference, failure, interruption, restoration, permissions, and ignored-state behavior ## Why - Avoid persistently storing potentially sensitive schema and operational output during ordinary comparisons - Preserve exact argv execution, comparison semantics, and intentional debugging workflows --- CHANGELOG.md | 4 +- skills/carve-changesets/SKILL.md | 3 + skills/carve-changesets/references/SPEC.md | 9 + skills/carve-changesets/references/cli.md | 21 ++ skills/carve-changesets/scripts/cli.py | 16 +- skills/carve-changesets/scripts/db_compare.py | 160 ++++++++-- .../scripts/tests/test_db_compare.py | 290 +++++++++++++++++- .../scripts/tests/test_scripts_integration.py | 50 +++ 8 files changed, 505 insertions(+), 48 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 95f4242..a2c5b46 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,9 +4,11 @@ summary: Chronological history of repository and skill changes. # Changelog -## 2026-07-27 — Enforced untrusted-content boundaries, bound epic delegation, hardened command execution, populated the solution-simplicity and code-simplicity strata, enforced acceptance-gated closeout, populated the correctness stratum, recovered carved suffixes, folded owner adjudications, and ran the frozen v1 baseline +## 2026-07-27 — Made database comparison output ephemeral, enforced untrusted-content boundaries, bound epic delegation, hardened command execution, populated the solution-simplicity and code-simplicity strata, enforced acceptance-gated closeout, populated the correctness stratum, recovered carved suffixes, folded owner adjudications, and ran the frozen v1 baseline +- fix: keep database comparison output ephemeral by default - feat: enforce untrusted content boundaries + (`ff3f4b9cca9b062a7113b95ab08bd1d36331a27c`) - docs: record the small-sample caveat the frozen protocol's step 6 requires (`e720e656cd3729a857aa4bcb6f6592fae1facc57`) - fix: enforce owner_disposition exactly when owner_confirmed diff --git a/skills/carve-changesets/SKILL.md b/skills/carve-changesets/SKILL.md index b1bbf6f..a02396b 100644 --- a/skills/carve-changesets/SKILL.md +++ b/skills/carve-changesets/SKILL.md @@ -170,6 +170,9 @@ been verified. ## Preserve safety and truth - Keep `.carve-changesets/` ignored and out of commits and PRs. +- Keep `db-compare` raw outputs ephemeral by default. Retain exact raw output + only at an explicitly selected, reported, permission-restricted destination; + bound terminal diagnostics without transforming comparison inputs. - Resolve every mutation target to an exact repository, ref, SHA, PR, and worktree immediately before acting. - Keep remote mutation dry-run by default and use explicit refspecs and exact diff --git a/skills/carve-changesets/references/SPEC.md b/skills/carve-changesets/references/SPEC.md index ae2f36c..385d957 100644 --- a/skills/carve-changesets/references/SPEC.md +++ b/skills/carve-changesets/references/SPEC.md @@ -457,6 +457,15 @@ preserve user changes, credentials, environment files, local databases, and non-reproducible artifacts. Temporary integration worktrees and branches may be removed only after exact ownership and clean disposable state are proven. +Database comparison captures approved command stdout without changing equality +semantics. By default both raw outputs live only in an owner-only temporary +directory that is removed after success, difference detection, command or diff +failure, interruption, and checkout restoration. Raw `source.txt` and +`chain.txt` outputs persist only through an explicit retention destination; +those files use owner-only permissions, the resolved paths are reported, and an +in-repository destination must participate in ignored recordkeeping state. +Operator-visible command and difference diagnostics remain useful but bounded. + ### Terminal states Every invocation returns exactly one named terminal state with evidence bound to diff --git a/skills/carve-changesets/references/cli.md b/skills/carve-changesets/references/cli.md index b6108a9..5a7cdc8 100644 --- a/skills/carve-changesets/references/cli.md +++ b/skills/carve-changesets/references/cli.md @@ -59,6 +59,13 @@ are intentional, make that boundary explicit with an argv such as - Legacy `--test-cmd`, `--source-cmd`, `--chain-cmd`, and plan `test_command` strings fail with migration guidance; they are never whitespace-split or passed to a shell. +- `db-compare` keeps raw source and chain output in an automatically removed, + owner-only temporary directory by default. Use `--keep-output-dir PATH` only + when retaining both raw outputs is intentional. The legacy `--out-dir PATH` + spelling is an explicit alias for the same retention request. Retained files + use owner-only permissions, their resolved paths are reported, and any + destination inside the repository must be `.carve-changesets/` or another + ignored path. ### Explicit shell migrations @@ -121,6 +128,20 @@ python3 scripts/cli.py db-compare \ --chain-argv '["./scripts/schema-chain"]' ``` +That default leaves no raw output behind. To retain exact raw results for an +audited debugging session, select the destination explicitly: + +```bash +python3 scripts/cli.py db-compare \ + --source-argv '["./scripts/schema-source"]' \ + --chain-argv '["./scripts/schema-chain"]' \ + --keep-output-dir .carve-changesets/db-compare-investigation +``` + +Difference and failure diagnostics are bounded in terminal output. Retention +does not redact or transform `source.txt` or `chain.txt`; review them as +potentially sensitive operational data. + ## Publication walkthrough Preview both remote operations first: diff --git a/skills/carve-changesets/scripts/cli.py b/skills/carve-changesets/scripts/cli.py index 2989a02..3ef710a 100755 --- a/skills/carve-changesets/scripts/cli.py +++ b/skills/carve-changesets/scripts/cli.py @@ -322,7 +322,9 @@ def cmd_db_compare(args: argparse.Namespace) -> None: load_and_validate(Path(args.plan)), source_argv=source_argv, chain_argv=chain_argv, - out_dir=Path(args.out_dir), + keep_output_dir=( + Path(args.keep_output_dir) if args.keep_output_dir is not None else None + ), ) @@ -595,7 +597,17 @@ def build_parser() -> argparse.ArgumentParser: default=None, help=argparse.SUPPRESS, ) - item.add_argument("--out-dir", default=str(DEFAULT_PLAN_PATH.parent / "db-compare")) + item.add_argument( + "--keep-output-dir", + "--out-dir", + dest="keep_output_dir", + default=None, + metavar="PATH", + help=( + "Explicitly retain raw outputs at PATH; --out-dir is a legacy alias. " + "The default uses an automatically removed restricted directory." + ), + ) item.set_defaults(func=cmd_db_compare) item = _command(sub, "hunk-preview", "Preview explicit textual hunk selectors.") diff --git a/skills/carve-changesets/scripts/db_compare.py b/skills/carve-changesets/scripts/db_compare.py index 2463ee8..8eee2a6 100755 --- a/skills/carve-changesets/scripts/db_compare.py +++ b/skills/carve-changesets/scripts/db_compare.py @@ -3,30 +3,112 @@ from __future__ import annotations +import os +import tempfile +from contextlib import contextmanager from pathlib import Path -from typing import Dict +from typing import Dict, Iterator, Optional from command_argv import display_argv, execute_argv, validate_argv from common import ( CommandError, branch_name_for, checkout_restore, + current_branch, delete_branch, ensure_branches_exist, ensure_clean_tree, ensure_git_repo, git, + is_path_ignored, + repo_root, unique_temp_branch, ) +DIAGNOSTIC_LIMIT = 4096 + + +def bounded_diagnostic(value: str) -> str: + """Return useful command output without propagating an unbounded payload.""" + + detail = value.strip() + if len(detail) <= DIAGNOSTIC_LIMIT: + return detail + return ( + detail[:DIAGNOSTIC_LIMIT] + + f"\n[TRUNCATED] Diagnostic limited to {DIAGNOSTIC_LIMIT} characters." + ) + + +def write_restricted_text(path: Path, value: str) -> None: + """Write one raw comparison output with owner-only permissions.""" + + flags = os.O_WRONLY | os.O_CREAT | os.O_TRUNC + flags |= getattr(os, "O_NOFOLLOW", 0) + descriptor = os.open(path, flags, 0o600) + try: + os.fchmod(descriptor, 0o600) + with os.fdopen(descriptor, "w") as stream: + descriptor = -1 + stream.write(value) + finally: + if descriptor >= 0: + os.close(descriptor) + + +def resolve_keep_output_dir(destination: Path) -> Path: + """Resolve and validate one explicit raw-output retention destination.""" + + resolved = destination.expanduser().resolve() + root = repo_root().resolve() + try: + relative = resolved.relative_to(root) + except ValueError: + return resolved + + if relative.parts and relative.parts[0] == ".carve-changesets": + return resolved + if is_path_ignored(relative): + return resolved + raise CommandError( + "Raw comparison output inside the repository must use " + ".carve-changesets/ or another ignored path." + ) + + +@contextmanager +def comparison_workspace(keep_output_dir: Optional[Path]) -> Iterator[Path]: + """Yield a restricted persistent or automatically removed workspace.""" + + if keep_output_dir is None: + with tempfile.TemporaryDirectory( + prefix="carve-changesets-db-compare-" + ) as temporary: + directory = Path(temporary).resolve() + os.chmod(directory, 0o700) + print("[INFO] Raw comparison outputs are ephemeral.") + yield directory + return + + directory = resolve_keep_output_dir(keep_output_dir) + directory.mkdir(mode=0o700, parents=True, exist_ok=True) + if not directory.is_dir(): + raise CommandError(f"Raw output destination is not a directory: {directory}") + print(f"[INFO] Retaining source output: {directory / 'source.txt'}") + print(f"[INFO] Retaining chain output: {directory / 'chain.txt'}") + yield directory + def run_capture(argv: object, outfile: Path) -> None: approved_argv = validate_argv(argv, label="database command argv") result = execute_argv(approved_argv, text=True, capture_output=True) if result.returncode != 0: - detail = (result.stderr or result.stdout or "").strip() - raise CommandError(f"Command failed: {display_argv(approved_argv)}\n{detail}") - outfile.write_text(result.stdout) + detail = bounded_diagnostic(result.stderr or result.stdout or "") + message = f"Command failed ({result.returncode}): {display_argv(approved_argv)}" + if detail: + message += f"\n{detail}" + raise CommandError(message) + write_restricted_text(outfile, result.stdout) def db_compare( @@ -34,7 +116,7 @@ def db_compare( *, source_argv: object, chain_argv: object, - out_dir: Path, + keep_output_dir: Optional[Path] = None, ) -> None: approved_source_argv = validate_argv( source_argv, label="approved source schema argv" @@ -50,38 +132,52 @@ def db_compare( chain = [branch_name_for(source, i) for i in range(1, total + 1)] ensure_branches_exist([base, source, *chain]) - out_dir.mkdir(parents=True, exist_ok=True) - source_out = out_dir / "source.txt" - chain_out = out_dir / "chain.txt" - temp_branch = unique_temp_branch("carve-temp-db_compare") - print(f"[INFO] Using output directory: {out_dir}") print(f"[INFO] Creating temporary branch: {temp_branch}") - with checkout_restore() as original: + original = current_branch() + with comparison_workspace(keep_output_dir) as output_dir: + source_out = output_dir / "source.txt" + chain_out = output_dir / "chain.txt" try: - git("checkout", source) - print(f"[STEP] Running source command on {source}") - run_capture(approved_source_argv, source_out) - - git("checkout", "-B", temp_branch, base) - for name in chain: - print(f"[STEP] Merging {name} into {temp_branch}") - git("merge", "--no-ff", "--no-edit", name) - - print(f"[STEP] Running chain command on {temp_branch}") - run_capture(approved_chain_argv, chain_out) - - print("[STEP] Diffing outputs (git diff --no-index)") - diff = git( - "diff", "--no-index", "--", str(source_out), str(chain_out), check=False - ) - if diff.returncode == 0: - print("[OK] No differences detected.") - else: - print(diff.stdout.strip() or "[WARN] Differences detected.") + with checkout_restore(): + git("checkout", source) + print(f"[STEP] Running source command on {source}") + run_capture(approved_source_argv, source_out) + + git("checkout", "-B", temp_branch, base) + for name in chain: + print(f"[STEP] Merging {name} into {temp_branch}") + git("merge", "--no-ff", "--no-edit", name) + + print(f"[STEP] Running chain command on {temp_branch}") + run_capture(approved_chain_argv, chain_out) + + print("[STEP] Diffing outputs (git diff --no-index)") + diff = git( + "diff", + "--no-index", + "--", + str(source_out), + str(chain_out), + check=False, + ) + if diff.returncode == 0: + print("[OK] No differences detected.") + elif diff.returncode == 1: + print( + bounded_diagnostic(diff.stdout) + or "[WARN] Differences detected." + ) + else: + detail = bounded_diagnostic(diff.stderr or diff.stdout) + message = f"Database output comparison failed ({diff.returncode})." + if detail: + message += f"\n{detail}" + raise CommandError(message) finally: - git("checkout", original) + if current_branch() != original: + git("checkout", original) delete_branch(temp_branch) ensure_clean_tree() diff --git a/skills/carve-changesets/scripts/tests/test_db_compare.py b/skills/carve-changesets/scripts/tests/test_db_compare.py index 2d93144..9f7bdd4 100644 --- a/skills/carve-changesets/scripts/tests/test_db_compare.py +++ b/skills/carve-changesets/scripts/tests/test_db_compare.py @@ -1,16 +1,54 @@ from __future__ import annotations +import io import shutil +import stat +import subprocess +import tempfile import unittest +from contextlib import contextmanager, redirect_stdout from pathlib import Path from unittest.mock import patch import db_compare as db_compare_mod from chain import create_chain -from legacy_helpers import chdir, init_repo +from legacy_helpers import chdir, init_repo, run class DbCompareTests(unittest.TestCase): + def _prepare_repo(self) -> tuple[Path, dict]: + repo_dir, plan = init_repo() + with chdir(repo_dir): + create_chain(plan) + return repo_dir, plan + + @contextmanager + def _record_ephemeral_directory(self): + real_temporary_directory = tempfile.TemporaryDirectory + paths: list[Path] = [] + + def create(*args, **kwargs): + temporary = real_temporary_directory(*args, **kwargs) + paths.append(Path(temporary.name)) + return temporary + + with patch.object( + db_compare_mod.tempfile, "TemporaryDirectory", side_effect=create + ): + yield paths + + def _assert_restored_and_clean(self, repo_dir: Path) -> None: + branch = run(["git", "branch", "--show-current"], cwd=repo_dir).stdout.strip() + temporary_branches = run( + ["git", "branch", "--list", "carve-temp-db_compare-*"], cwd=repo_dir + ).stdout.strip() + status = run( + ["git", "status", "--porcelain", "--untracked-files=all"], cwd=repo_dir + ).stdout.strip() + self.assertEqual("feature/test", branch) + self.assertEqual("", temporary_branches) + self.assertEqual("", status) + def test_db_compare_rejects_unknown_command_representations_before_git( self, ) -> None: @@ -23,7 +61,6 @@ def test_db_compare_rejects_unknown_command_representations_before_git( {}, source_argv=value, chain_argv=["true"], - out_dir=Path("unused"), ) with self.subTest(value=value, boundary="chain"): with self.assertRaises(db_compare_mod.CommandError): @@ -31,28 +68,255 @@ def test_db_compare_rejects_unknown_command_representations_before_git( {}, source_argv=["true"], chain_argv=value, - out_dir=Path("unused"), ) ensure_git_repo.assert_not_called() - def test_db_compare_creates_outputs(self) -> None: - repo_dir, plan = init_repo() + def test_run_capture_preserves_exact_argv_and_restricts_output(self) -> None: + exact_argv = ["python3", "-c", "print('two words')", "literal $VALUE"] + with tempfile.TemporaryDirectory() as directory: + outfile = Path(directory) / "source.txt" + completed = subprocess.CompletedProcess(exact_argv, 0, "payload\n", "") + with patch.object( + db_compare_mod, "execute_argv", return_value=completed + ) as execute: + db_compare_mod.run_capture(exact_argv, outfile) + + execute.assert_called_once_with(exact_argv, text=True, capture_output=True) + self.assertEqual("payload\n", outfile.read_text()) + self.assertEqual(0o600, stat.S_IMODE(outfile.stat().st_mode)) + + def test_default_success_is_ephemeral_and_restores_repository(self) -> None: + repo_dir, plan = self._prepare_repo() try: - out_dir = repo_dir / ".carve-changesets" / "db-compare-test" - with chdir(repo_dir): - create_chain(plan) + output = io.StringIO() + with ( + chdir(repo_dir), + self._record_ephemeral_directory() as ephemeral_paths, + redirect_stdout(output), + ): + db_compare_mod.db_compare( + plan, + source_argv=["cat", "a.txt"], + chain_argv=["cat", "a.txt"], + ) + + self.assertEqual(1, len(ephemeral_paths)) + self.assertFalse(ephemeral_paths[0].exists()) + self.assertIn("Raw comparison outputs are ephemeral.", output.getvalue()) + self.assertIn("[OK] No differences detected.", output.getvalue()) + self.assertFalse((repo_dir / ".carve-changesets" / "db-compare").exists()) + self._assert_restored_and_clean(repo_dir) + finally: + shutil.rmtree(repo_dir) + + def test_difference_diagnostic_is_bounded_and_outputs_are_removed(self) -> None: + repo_dir, plan = self._prepare_repo() + try: + output = io.StringIO() + long_value = "x" * (db_compare_mod.DIAGNOSTIC_LIMIT + 1000) + with ( + chdir(repo_dir), + self._record_ephemeral_directory() as ephemeral_paths, + redirect_stdout(output), + ): + db_compare_mod.db_compare( + plan, + source_argv=["cat", "a.txt"], + chain_argv=["python3", "-c", f"print({long_value!r})"], + ) + + self.assertFalse(ephemeral_paths[0].exists()) + self.assertIn("[TRUNCATED] Diagnostic limited to", output.getvalue()) + self.assertLess( + len(output.getvalue()), db_compare_mod.DIAGNOSTIC_LIMIT + 1500 + ) + self._assert_restored_and_clean(repo_dir) + finally: + shutil.rmtree(repo_dir) + + def test_command_failure_is_bounded_and_removes_ephemeral_outputs(self) -> None: + repo_dir, plan = self._prepare_repo() + try: + command = [ + "python3", + "-c", + ( + "import sys; " + f"print('s' * {db_compare_mod.DIAGNOSTIC_LIMIT + 1000}); " + "sys.exit(3)" + ), + ] + with ( + chdir(repo_dir), + self._record_ephemeral_directory() as ephemeral_paths, + ): + with self.assertRaises(db_compare_mod.CommandError) as raised: + db_compare_mod.db_compare( + plan, + source_argv=command, + chain_argv=["true"], + ) + + self.assertFalse(ephemeral_paths[0].exists()) + self.assertIn("Command failed (3)", str(raised.exception)) + self.assertIn("[TRUNCATED] Diagnostic limited to", str(raised.exception)) + self.assertLess( + len(str(raised.exception)), db_compare_mod.DIAGNOSTIC_LIMIT + 500 + ) + self._assert_restored_and_clean(repo_dir) + finally: + shutil.rmtree(repo_dir) + + def test_diff_failure_removes_ephemeral_outputs(self) -> None: + repo_dir, plan = self._prepare_repo() + original_git = db_compare_mod.git + + def fail_diff(*args, **kwargs): + if args[:2] == ("diff", "--no-index"): + return subprocess.CompletedProcess(args, 2, "", "diff failed") + return original_git(*args, **kwargs) + + try: + with ( + chdir(repo_dir), + self._record_ephemeral_directory() as ephemeral_paths, + patch.object(db_compare_mod, "git", side_effect=fail_diff), + ): + with self.assertRaisesRegex( + db_compare_mod.CommandError, + r"(?s)Database output comparison failed \(2\).*diff failed", + ): + db_compare_mod.db_compare( + plan, + source_argv=["cat", "a.txt"], + chain_argv=["cat", "a.txt"], + ) + + self.assertFalse(ephemeral_paths[0].exists()) + self._assert_restored_and_clean(repo_dir) + finally: + shutil.rmtree(repo_dir) + + def test_interruption_removes_outputs_and_restores_repository(self) -> None: + repo_dir, plan = self._prepare_repo() + original_capture = db_compare_mod.run_capture + call_count = 0 + + def interrupt_second_capture(argv, outfile): + nonlocal call_count + call_count += 1 + if call_count == 2: + raise KeyboardInterrupt + return original_capture(argv, outfile) + + try: + with ( + chdir(repo_dir), + self._record_ephemeral_directory() as ephemeral_paths, + patch.object( + db_compare_mod, + "run_capture", + side_effect=interrupt_second_capture, + ), + ): + with self.assertRaises(KeyboardInterrupt): + db_compare_mod.db_compare( + plan, + source_argv=["cat", "a.txt"], + chain_argv=["cat", "a.txt"], + ) + + self.assertFalse(ephemeral_paths[0].exists()) + self._assert_restored_and_clean(repo_dir) + finally: + shutil.rmtree(repo_dir) + + def test_restoration_failure_still_removes_outputs_and_retries_restoration( + self, + ) -> None: + repo_dir, plan = self._prepare_repo() + + @contextmanager + def fail_restoration(): + yield + raise db_compare_mod.CommandError("simulated restoration failure") + + try: + with ( + chdir(repo_dir), + self._record_ephemeral_directory() as ephemeral_paths, + patch.object( + db_compare_mod, + "checkout_restore", + side_effect=fail_restoration, + ), + ): + with self.assertRaisesRegex( + db_compare_mod.CommandError, "simulated restoration failure" + ): + db_compare_mod.db_compare( + plan, + source_argv=["cat", "a.txt"], + chain_argv=["cat", "a.txt"], + ) + + self.assertFalse(ephemeral_paths[0].exists()) + self._assert_restored_and_clean(repo_dir) + finally: + shutil.rmtree(repo_dir) + + def test_explicit_retention_reports_exact_paths_and_restricts_files(self) -> None: + repo_dir, plan = self._prepare_repo() + try: + destination = repo_dir / ".carve-changesets" / "retained-db-output" + output = io.StringIO() + with chdir(repo_dir), redirect_stdout(output): db_compare_mod.db_compare( plan, source_argv=["cat", "a.txt"], chain_argv=["cat", "a.txt"], - out_dir=out_dir, + keep_output_dir=destination, ) - source_out = out_dir / "source.txt" - chain_out = out_dir / "chain.txt" - self.assertTrue(source_out.exists()) - self.assertTrue(chain_out.exists()) + source_out = destination.resolve() / "source.txt" + chain_out = destination.resolve() / "chain.txt" self.assertEqual(source_out.read_text(), chain_out.read_text()) + self.assertEqual(0o600, stat.S_IMODE(source_out.stat().st_mode)) + self.assertEqual(0o600, stat.S_IMODE(chain_out.stat().st_mode)) + self.assertIn(str(source_out), output.getvalue()) + self.assertIn(str(chain_out), output.getvalue()) + self._assert_restored_and_clean(repo_dir) + finally: + shutil.rmtree(repo_dir) + + def test_unignored_repository_retention_path_fails_before_branch_mutation( + self, + ) -> None: + repo_dir, plan = self._prepare_repo() + try: + destination = repo_dir / "raw-output" + branches_before = run( + ["git", "for-each-ref", "--format=%(refname)", "refs/heads/"], + cwd=repo_dir, + ).stdout + with chdir(repo_dir): + with self.assertRaisesRegex( + db_compare_mod.CommandError, + "must use .carve-changesets/ or another ignored path", + ): + db_compare_mod.db_compare( + plan, + source_argv=["cat", "a.txt"], + chain_argv=["cat", "a.txt"], + keep_output_dir=destination, + ) + branches_after = run( + ["git", "for-each-ref", "--format=%(refname)", "refs/heads/"], + cwd=repo_dir, + ).stdout + self.assertEqual(branches_before, branches_after) + self.assertFalse(destination.exists()) + self._assert_restored_and_clean(repo_dir) finally: shutil.rmtree(repo_dir) diff --git a/skills/carve-changesets/scripts/tests/test_scripts_integration.py b/skills/carve-changesets/scripts/tests/test_scripts_integration.py index f817ff9..48c3cb0 100644 --- a/skills/carve-changesets/scripts/tests/test_scripts_integration.py +++ b/skills/carve-changesets/scripts/tests/test_scripts_integration.py @@ -1,6 +1,7 @@ from __future__ import annotations import shutil +import stat import unittest from common import DEFAULT_PLAN_PATH @@ -222,6 +223,55 @@ def test_legacy_database_command_surfaces_fail_before_branch_mutation(self) -> N finally: shutil.rmtree(repo_dir) + def test_db_compare_cli_defaults_to_ephemeral_and_accepts_explicit_legacy_retention( + self, + ) -> None: + repo_dir, plan = init_repo() + try: + cli = str(SCRIPTS_DIR / "cli.py") + plan_path = repo_dir / DEFAULT_PLAN_PATH + write_plan(plan_path, plan) + run([cli, "create-chain"], cwd=repo_dir) + + default_result = run( + [ + cli, + "db-compare", + "--source-argv", + '["cat", "a.txt"]', + "--chain-argv", + '["cat", "a.txt"]', + ], + cwd=repo_dir, + ) + historical_default = repo_dir / ".carve-changesets" / "db-compare" + self.assertIn( + "Raw comparison outputs are ephemeral.", default_result.stdout + ) + self.assertFalse(historical_default.exists()) + + retained = repo_dir / ".carve-changesets" / "legacy-retained" + retained_result = run( + [ + cli, + "db-compare", + "--source-argv", + '["cat", "a.txt"]', + "--chain-argv", + '["cat", "a.txt"]', + "--out-dir", + str(retained), + ], + cwd=repo_dir, + ) + for name in ("source.txt", "chain.txt"): + output = retained / name + self.assertTrue(output.is_file()) + self.assertEqual(0o600, stat.S_IMODE(output.stat().st_mode)) + self.assertIn(str(output.resolve()), retained_result.stdout) + finally: + shutil.rmtree(repo_dir) + def test_malformed_test_argv_fails_before_branch_mutation(self) -> None: repo_dir, plan = init_repo() try: