use std::process::Command;
use camino::{Utf8Path, Utf8PathBuf};
use newgit_core::branch::ResourceStatus;
use newgit_core::checkpoint::CheckpointReason;
use newgit_core::config::WorkspaceSection;
use newgit_core::manager::BranchManager;
use newgit_core::store::MetadataStore;
use newgit_core::supervisor::Supervisor;
use newgit_core::tracker::Storage;
use newgit_core::{NewgitError, SourceSubstrate};
fn git(dir: &Utf8Path, args: &[&str]) {
let output = Command::new("git")
.arg("-C")
.arg(dir.as_str())
.args(args)
.env("GIT_AUTHOR_NAME", "test")
.env("GIT_AUTHOR_EMAIL", "test@example.com")
.env("GIT_COMMITTER_NAME", "test")
.env("GIT_COMMITTER_EMAIL", "test@example.com")
.output()
.expect("git runs");
assert!(
output.status.success(),
"git {args:?} failed: {}",
String::from_utf8_lossy(&output.stderr)
);
}
fn git_stdout(dir: &Utf8Path, args: &[&str]) -> String {
let output = Command::new("git")
.arg("-C")
.arg(dir.as_str())
.args(args)
.output()
.expect("git runs");
assert!(
output.status.success(),
"git {args:?} failed: {}",
String::from_utf8_lossy(&output.stderr)
);
String::from_utf8_lossy(&output.stdout).trim().to_owned()
}
fn setup(temp: &Utf8Path) -> MetadataStore {
let repo = temp.join("store");
std::fs::create_dir_all(&repo).expect("mkdir");
git(&repo, &["init", "-q", "-b", "main"]);
std::fs::write(repo.join("README.md"), "hello\n").expect("write");
std::fs::write(repo.join(".gitignore"), ".env.local\n").expect("write");
git(&repo, &["add", "."]);
git(&repo, &["commit", "-q", "-m", "initial"]);
let store = MetadataStore::init(&repo, "proj", SourceSubstrate::Git).expect("init");
let mut config = store.load_config().expect("config");
config.workspace = Some(WorkspaceSection {
root: Some(temp.join("workspaces")),
materializer: None,
});
store.write_config(&config).expect("write config");
store
}
fn tempdir() -> (tempfile::TempDir, Utf8PathBuf) {
let temp = tempfile::tempdir().expect("tempdir");
let path = Utf8PathBuf::from_path_buf(temp.path().canonicalize().expect("canonicalize"))
.expect("utf8 tempdir");
(temp, path)
}
fn manager(store: MetadataStore) -> BranchManager {
BranchManager::open(store).expect("manager")
}
fn write_resource(store: &MetadataStore, name: &str, contents: &str) {
std::fs::write(
store.paths().resources.join(format!("{name}.toml")),
contents,
)
.expect("write resource");
}
fn read(path: &Utf8Path) -> String {
std::fs::read_to_string(path).unwrap_or_else(|_| panic!("read {path}"))
}
#[test]
fn checkpoint_and_undo_restore_source_trackers_and_dirty_state() {
let (_guard, temp) = tempdir();
let store = setup(&temp);
let m = manager(store);
m.create_tracker("runtime-env", "user", Storage::Local, false)
.expect("create tracker");
m.track_paths("runtime-env", &[Utf8PathBuf::from(".env.local")])
.expect("track");
let m = manager(m.store().clone());
let ws = m
.spawn("feature-a", None)
.expect("spawn")
.branch
.workspace_path;
std::fs::write(ws.join("src.txt"), "code\n").expect("write");
git(&ws, &["add", "src.txt"]);
git(&ws, &["commit", "-q", "-m", "add src"]);
let good_head = git_stdout(&ws, &["rev-parse", "HEAD"]);
std::fs::write(ws.join(".env.local"), "SECRET=1\n").expect("write");
std::fs::write(ws.join("README.md"), "hello v2\n").expect("write");
std::fs::write(ws.join("notes.txt"), "untracked notes\n").expect("write");
let outcome = m
.checkpoint("feature-a", Some("good state"))
.expect("checkpoint");
assert_eq!(outcome.record.source.head_rev, good_head);
assert!(
outcome.record.source.dirty_rev.is_some(),
"worktree was dirty"
);
assert!(outcome.record.tracker_states[0].content_rev.is_some());
let repo = m.store().paths().project_root.clone();
assert_eq!(
git_stdout(&repo, &["rev-parse", "refs/heads/feature-a"]),
good_head
);
std::fs::write(ws.join("junk.txt"), "junk\n").expect("write");
git(&ws, &["add", "junk.txt"]);
git(&ws, &["commit", "-q", "-m", "junk"]);
std::fs::remove_file(ws.join("notes.txt")).expect("rm");
std::fs::write(ws.join("README.md"), "mangled\n").expect("write");
std::fs::write(ws.join(".env.local"), "SECRET=evil\n").expect("write");
let undo = m.undo("feature-a", None).expect("undo");
assert_eq!(undo.restored.id, outcome.record.id);
assert_eq!(undo.safety.reason, CheckpointReason::BeforeUndo);
assert_eq!(git_stdout(&ws, &["rev-parse", "HEAD"]), good_head);
assert_eq!(read(&ws.join("README.md")), "hello v2\n");
assert_eq!(read(&ws.join("notes.txt")), "untracked notes\n");
assert_eq!(read(&ws.join(".env.local")), "SECRET=1\n");
assert!(!ws.join("junk.txt").exists(), "junk commit is gone");
let status = git_stdout(&ws, &["status", "--porcelain"]);
assert!(
status.contains("README.md"),
"README shows as modified: {status}"
);
assert!(ws.join(".newgit/local/instance.toml").is_file());
let redo = m.undo("feature-a", None).expect("redo");
assert_eq!(redo.restored.id, undo.safety.id);
assert_eq!(read(&ws.join("README.md")), "mangled\n");
assert_eq!(read(&ws.join(".env.local")), "SECRET=evil\n");
assert!(ws.join("junk.txt").exists());
assert!(!ws.join("notes.txt").exists());
}
#[test]
fn undo_to_targets_an_older_checkpoint() {
let (_guard, temp) = tempdir();
let store = setup(&temp);
let m = manager(store);
let ws = m
.spawn("feature-b", None)
.expect("spawn")
.branch
.workspace_path;
std::fs::write(ws.join("README.md"), "version one\n").expect("write");
git(&ws, &["commit", "-qam", "v1"]);
let first = m.checkpoint("feature-b", Some("v1")).expect("checkpoint");
std::fs::write(ws.join("README.md"), "version two\n").expect("write");
git(&ws, &["commit", "-qam", "v2"]);
m.checkpoint("feature-b", Some("v2")).expect("checkpoint");
m.undo("feature-b", Some(&first.record.id))
.expect("undo --to");
assert_eq!(read(&ws.join("README.md")), "version one\n");
assert!(matches!(
m.undo("feature-b", Some("ckpt_099")),
Err(NewgitError::UnknownCheckpoint { .. })
));
assert_eq!(m.list_checkpoints("feature-b").expect("list").len(), 3);
}
const HASH_RECOMPUTE_RESOURCE: &str = r#"kind = "command"
ownership = "workspace"
[actions.prepare]
command = "echo run >> prep-runs.txt"
[checkpoint]
mode = "hash"
paths = ["README.md"]
[restore]
mode = "recompute"
action = "prepare"
"#;
#[test]
fn recompute_restore_reruns_prepare_and_hash_is_recorded() {
let (_guard, temp) = tempdir();
let store = setup(&temp);
write_resource(&store, "deps", HASH_RECOMPUTE_RESOURCE);
let m = manager(store);
let ws = m
.spawn("feature-c", None)
.expect("spawn")
.branch
.workspace_path;
assert_eq!(read(&ws.join("prep-runs.txt")).lines().count(), 1);
let outcome = m.checkpoint("feature-c", None).expect("checkpoint");
let state = &outcome.record.resource_states[0];
assert_eq!(state.mode, "hash");
assert!(
state
.state_ref
.as_deref()
.is_some_and(|r| r.starts_with("hash:"))
);
let undo = m.undo("feature-c", None).expect("undo");
assert_eq!(undo.resources[0].action, "recompute(prepare)");
assert!(undo.resources[0].ok);
assert!(undo.recovery_record.is_none());
assert_eq!(read(&ws.join("prep-runs.txt")).lines().count(), 2);
}
const DB_TRACKER_DEPOSIT_RESOURCE: &str = r#"kind = "command-snapshot"
ownership = "branch"
[checkpoint]
mode = "command"
command = "echo dump-data > {{snapshot.path}}/db.sql && echo {{snapshot.path}}/db.sql"
into_tracker = "db-snapshots"
[restore]
mode = "command"
command = "cp {{state_ref}} restored.sql"
"#;
#[test]
fn into_tracker_deposits_into_lane_and_restore_reads_it_back() {
let (_guard, temp) = tempdir();
let store = setup(&temp);
write_resource(&store, "db", DB_TRACKER_DEPOSIT_RESOURCE);
let m = manager(store);
m.create_tracker("db-snapshots", "project-devs", Storage::Local, false)
.expect("create tracker");
let m = manager(m.store().clone());
let ws = m
.spawn("feature-d", None)
.expect("spawn")
.branch
.workspace_path;
let outcome = m.checkpoint("feature-d", None).expect("checkpoint");
let state = &outcome.record.resource_states[0];
assert!(
state
.state_ref
.as_deref()
.is_some_and(|r| r.starts_with("tracker:db-snapshots@")),
"state_ref was {:?}",
state.state_ref
);
let state_path = state.state_path.clone().expect("state path");
assert_eq!(read(&state_path), "dump-data\n");
let tracker_state = outcome
.record
.tracker_states
.iter()
.find(|t| t.name == "db-snapshots")
.expect("tracker state");
assert!(tracker_state.content_rev.is_some());
let undo = m.undo("feature-d", None).expect("undo");
assert!(undo.resources[0].ok);
assert_eq!(read(&ws.join("restored.sql")), "dump-data\n");
}
const BAD_RESTORE_RESOURCE: &str = r#"kind = "command"
ownership = "branch"
[checkpoint]
mode = "none"
[restore]
mode = "command"
command = "exit 3"
"#;
#[test]
fn failed_restore_writes_recovery_record_and_restores_the_rest() {
let (_guard, temp) = tempdir();
let store = setup(&temp);
write_resource(&store, "bad", BAD_RESTORE_RESOURCE);
write_resource(&store, "deps", HASH_RECOMPUTE_RESOURCE);
let m = manager(store);
let ws = m
.spawn("feature-e", None)
.expect("spawn")
.branch
.workspace_path;
m.checkpoint("feature-e", None).expect("checkpoint");
let undo = m.undo("feature-e", None).expect("undo");
let bad = undo
.resources
.iter()
.find(|r| r.name == "bad")
.expect("bad");
assert!(!bad.ok);
let deps = undo
.resources
.iter()
.find(|r| r.name == "deps")
.expect("deps");
assert!(deps.ok, "other resources still restored");
assert_eq!(read(&ws.join("prep-runs.txt")).lines().count(), 2);
let recovery = undo.recovery_record.expect("recovery record");
let contents = read(&recovery);
assert!(
contents.contains("bad"),
"recovery names the resource: {contents}"
);
assert!(contents.contains("exited with 3"));
let report = m
.statuses()
.expect("statuses")
.into_iter()
.find(|r| r.branch.name == "feature-e")
.expect("report");
let bad_state = &report.branch.resources.get("bad").expect("binding").status;
assert_eq!(*bad_state, ResourceStatus::Failed);
}
const RUNNING_APP_RESOURCE: &str = r#"kind = "process"
ownership = "branch"
[ports]
app = { start = 4210, env = "PORT" }
[actions.start]
command = "sleep 30"
long_running = true
[actions.stop]
signal = "term"
"#;
#[test]
fn undo_stops_running_processes_and_restarts_what_was_running() {
let (_guard, temp) = tempdir();
let store = setup(&temp);
write_resource(&store, "app", RUNNING_APP_RESOURCE);
let m = manager(store);
let spawn = m.spawn("feature-f", None).expect("spawn");
let slug = spawn.branch.slug.clone();
m.run_action("feature-f", "app.start").expect("start");
let supervisor = Supervisor::new(m.store().instance_state_dir(&slug));
let pid_before = supervisor.running_pid("app").expect("running");
let outcome = m.checkpoint("feature-f", None).expect("checkpoint");
assert!(outcome.record.resource_states[0].was_running);
let undo = m.undo("feature-f", None).expect("undo");
assert!(undo.resources[0].action.ends_with("+ restarted"));
let pid_after = supervisor.running_pid("app").expect("restarted");
assert_ne!(pid_before, pid_after, "a fresh process was started");
m.run_action("feature-f", "app.stop").expect("stop");
}
#[test]
fn identical_checkpoints_dedupe_lane_revs() {
let (_guard, temp) = tempdir();
let store = setup(&temp);
let m = manager(store);
m.create_tracker("runtime-env", "user", Storage::Local, false)
.expect("create tracker");
m.track_paths("runtime-env", &[Utf8PathBuf::from(".env.local")])
.expect("track");
let m = manager(m.store().clone());
let ws = m
.spawn("feature-g", None)
.expect("spawn")
.branch
.workspace_path;
std::fs::write(ws.join(".env.local"), "SECRET=1\n").expect("write");
let first = m.checkpoint("feature-g", None).expect("checkpoint");
let second = m.checkpoint("feature-g", None).expect("checkpoint");
assert_eq!(
first.record.tracker_states[0].content_rev, second.record.tracker_states[0].content_rev,
"identical content dedupes to the same lane rev"
);
assert_ne!(first.record.id, second.record.id);
}
#[test]
fn an_incomplete_undo_says_so_and_marks_its_safety_checkpoint() {
let (_guard, temp) = tempdir();
let store = setup(&temp);
write_resource(&store, "bad", BAD_RESTORE_RESOURCE);
write_resource(&store, "deps", HASH_RECOMPUTE_RESOURCE);
let m = manager(store);
m.spawn("feature-f", None).expect("spawn");
m.checkpoint("feature-f", Some("good state"))
.expect("checkpoint");
let undo = m.undo("feature-f", None).expect("undo");
assert!(!undo.is_complete(), "one resource did not restore");
assert_eq!(undo.failed_resources(), vec!["bad"]);
assert_eq!(
undo.safety.undo_completed,
Some(false),
"the safety checkpoint records that the undo it preceded failed"
);
let listed = m.list_checkpoints("feature-f").expect("list");
let safety = listed
.iter()
.find(|record| record.id == undo.safety.id)
.expect("safety checkpoint listed");
assert_eq!(safety.reason, CheckpointReason::BeforeUndo);
assert_eq!(safety.undo_completed, Some(false));
let explicit = listed
.iter()
.find(|record| record.reason == CheckpointReason::Explicit)
.expect("explicit checkpoint");
assert_eq!(explicit.undo_completed, None);
}
#[test]
fn a_complete_undo_marks_its_safety_checkpoint_as_a_redo_point() {
let (_guard, temp) = tempdir();
let store = setup(&temp);
write_resource(&store, "deps", HASH_RECOMPUTE_RESOURCE);
let m = manager(store);
m.spawn("feature-g", None).expect("spawn");
m.checkpoint("feature-g", None).expect("checkpoint");
let undo = m.undo("feature-g", None).expect("undo");
assert!(undo.is_complete());
assert!(undo.failed_resources().is_empty());
assert_eq!(undo.safety.undo_completed, Some(true));
}