Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,14 @@ loosely while pre-1.0 (breaking changes can land on minor bumps).
## [Unreleased]

### Fixed
- **Reconcile rollback no longer wipes the workspace on a failed restore.**
`restore_workspace` now copies the snapshot into a staging directory
under `.arbiter-reconcile-snapshots` before clearing the live tree.
If that copy fails (unreadable snapshot, I/O error), the original
files stay in place and restore returns false. Previously
`clear_dir_contents` ran first, so a subsequent `copy_tree` failure
left the workspace empty while `run_reconcile` reported `failed`
rather than `rolled_back`.
- **LaTeX math recursion depth.** `latex_math_to_plain` now stops converting
after 64 nested `\frac` / `^` / `_` / `\sqrt` / `\text` groups and emits
the remaining raw fragment, matching the JSON parser's nesting cap so
Expand Down
3 changes: 3 additions & 0 deletions include/reconcile.h
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,9 @@ VerificationEvidence run_verification(const ReconcileSpec& spec,
bool snapshot_workspace(const std::string& root,
const std::string& dest,
std::string* err);
// Copy `snapshot` into `root`. The snapshot is staged first so a
// failed copy cannot clear the live tree; only a successful stage
// replaces workspace contents (`.arbiter-reconcile-snapshots` is kept).
bool restore_workspace(const std::string& snapshot,
const std::string& root,
std::string* err);
Expand Down
25 changes: 24 additions & 1 deletion src/reconcile.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -926,8 +926,31 @@ bool restore_workspace(const std::string& snapshot,
if (err) *err = "workspace root is missing";
return false;
}

// Stage the snapshot copy first. clear_dir_contents(root) is not
// reversible, so a copy_tree failure after clearing used to leave
// the live workspace empty (or partial) even though restore
// returned false. Staging lives under .arbiter-reconcile-snapshots
// so clear_dir_contents skips it (same rule as the `pre` snapshot).
const fs::path snap_dir = fs::path(root) / ".arbiter-reconcile-snapshots";
const fs::path staging = snap_dir / ".restore-staging";
fs::create_directories(snap_dir, ec);
if (ec) {
if (err) *err = "cannot create snapshot dir: " + ec.message();
return false;
}
fs::remove_all(staging, ec);
if (!copy_tree(snapshot, staging.string(), err)) {
fs::remove_all(staging, ec);
return false;
}

clear_dir_contents(root);
return copy_tree(snapshot, root, err);
const bool ok = copy_tree(staging.string(), root, err);
if (ok) fs::remove_all(staging, ec);
// On a failed install into root, leave staging in place so the
// staged tree is still recoverable under .restore-staging.
return ok;
}

std::string format_reconcile_brief(const StateContract& c,
Expand Down
47 changes: 47 additions & 0 deletions tests/test_reconcile.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -227,6 +227,53 @@ TEST_CASE("broken tests never satisfy") {
CHECK_FALSE(r.verification.passed);
}

TEST_CASE("restore_workspace does not wipe the live tree when snapshot copy fails") {
TempDir dir;
const fs::path root = dir.path / "ws";
const fs::path snap = dir.path / "snap";
fs::create_directories(root);
fs::create_directories(snap);
write_file(root / "keep.txt", "original\n");
write_file(snap / "secret.txt", "from-snap\n");
fs::permissions(snap / "secret.txt", fs::perms::none);

std::string err;
CHECK_FALSE(restore_workspace(snap.string(), root.string(), &err));
CHECK(fs::exists(root / "keep.txt"));
CHECK_FALSE(fs::exists(root / "secret.txt"));
{
std::ifstream in(root / "keep.txt");
std::string body((std::istreambuf_iterator<char>(in)), {});
CHECK(body == "original\n");
}

std::error_code ec;
fs::permissions(snap / "secret.txt",
fs::perms::owner_read | fs::perms::owner_write, ec);
}

TEST_CASE("restore_workspace replaces live files from a readable snapshot") {
TempDir dir;
const fs::path root = dir.path / "ws";
const fs::path snap = dir.path / "snap";
fs::create_directories(root);
fs::create_directories(snap);
write_file(root / "keep.txt", "original\n");
write_file(root / "debris.py", "partial\n");
write_file(snap / "keep.txt", "restored\n");

std::string err;
CHECK(restore_workspace(snap.string(), root.string(), &err));
CHECK(fs::exists(root / "keep.txt"));
CHECK_FALSE(fs::exists(root / "debris.py"));
{
std::ifstream in(root / "keep.txt");
std::string body((std::istreambuf_iterator<char>(in)), {});
CHECK(body == "restored\n");
}
CHECK_FALSE(fs::exists(root / ".arbiter-reconcile-snapshots" / ".restore-staging"));
}

TEST_CASE("rollbackOnFailure restores pre-run tree") {
TempDir dir;
write_file(dir.path / "keep.txt", "original\n");
Expand Down
Loading