use std::path::{Path, PathBuf};
use std::process::Command;
use chrono::Utc;
use marver::daemon::Config;
use marver::store::{Store, Transition};
use marver::{Task, TaskState, WorktreeManager};
use tempfile::TempDir;
fn init_repo(path: &Path) {
std::fs::create_dir_all(path).unwrap();
let git = |args: &[&str]| {
let out = Command::new("git")
.arg("-C")
.arg(path)
.args(args)
.output()
.expect("git");
assert!(out.status.success(), "git {args:?}: {out:?}");
};
git(&["init", "--initial-branch=main"]);
git(&["config", "user.email", "test@marver"]);
git(&["config", "user.name", "marver test"]);
std::fs::write(path.join("README.md"), "hello\n").unwrap();
git(&["add", "."]);
git(&["commit", "-m", "first"]);
}
struct Fixture {
dir: TempDir,
config: Config,
store: Store,
repo: PathBuf,
worktrees: WorktreeManager,
}
impl Fixture {
fn new() -> Self {
let dir = TempDir::new().unwrap();
let repo = dir.path().join("api");
init_repo(&repo);
let config = Config::new(dir.path(), dir.path());
let store = Store::open(&config.db).unwrap();
store.upsert_repo(&repo, "api", Utc::now()).unwrap();
let worktrees = WorktreeManager::new(&config.workspace_root);
Self {
dir,
config,
store,
repo,
worktrees,
}
}
fn finished(&mut self, title: &str, end: TaskState) -> Task {
let repo = self
.store
.upsert_repo(&self.repo, "api", Utc::now())
.unwrap();
let task = self
.store
.create_task(
title,
"do the thing",
&self.config.workspace_root,
&[repo.id],
Utc::now(),
)
.unwrap();
self.worktrees.provision(&self.store, &task).unwrap();
let path: &[TaskState] = match end {
TaskState::Cancelled => &[TaskState::Cancelled],
TaskState::Committed => &[
TaskState::Running,
TaskState::AwaitingReview,
TaskState::Committed,
],
TaskState::Running => &[TaskState::Running],
other => panic!("no walk to {other}"),
};
let mut last = task;
for &state in path {
last = self
.store
.transition(last.id, state, Transition::Plain, Utc::now())
.unwrap();
}
last
}
fn worktree(&self, task: &Task) -> PathBuf {
self.store.list_task_repos(task.id).unwrap()[0]
.worktree_path
.clone()
.unwrap()
}
fn cleanup(&self, extra: &[&str]) -> std::process::Output {
Command::new(env!("CARGO_BIN_EXE_marver"))
.arg("cleanup")
.arg("--data-dir")
.arg(self.dir.path())
.args(extra)
.output()
.expect("run marver cleanup")
}
fn branch_exists(&self, branch: &str) -> bool {
Command::new("git")
.arg("-C")
.arg(&self.repo)
.args(["rev-parse", "--verify", &format!("refs/heads/{branch}")])
.output()
.expect("git")
.status
.success()
}
}
#[test]
fn a_finished_task_gives_its_worktree_back() {
let mut fx = Fixture::new();
let task = fx.finished("Done with it", TaskState::Cancelled);
let worktree = fx.worktree(&task);
assert!(worktree.exists());
let out = fx.cleanup(&[]);
let stdout = String::from_utf8_lossy(&out.stdout);
assert!(out.status.success(), "{out:?}");
assert!(!worktree.exists(), "the directory should be gone: {stdout}");
assert!(
!task.workspace_dir.exists(),
"and so should the task directory"
);
assert!(stdout.contains("cleaned up"), "{stdout:?}");
assert!(
fx.store.list_task_repos(task.id).unwrap()[0]
.worktree_path
.is_none(),
"the record must catch up with the disk, or cleanup offers it again"
);
}
#[test]
fn the_branch_survives_by_default() {
let mut fx = Fixture::new();
let task = fx.finished("Committed something", TaskState::Committed);
let branch = marver::branch_name(&task);
fx.cleanup(&[]);
assert!(fx.branch_exists(&branch), "{branch} should still be there");
}
#[test]
fn deleting_branches_keeps_the_ones_holding_commits() {
let mut fx = Fixture::new();
let task = fx.finished("Committed something", TaskState::Committed);
let worktree = fx.worktree(&task);
std::fs::write(worktree.join("new.rs"), "fn f() {}\n").unwrap();
for args in [vec!["add", "."], vec!["commit", "-m", "agent work"]] {
Command::new("git")
.arg("-C")
.arg(&worktree)
.args(&args)
.output()
.expect("git");
}
let branch = marver::branch_name(&task);
let out = fx.cleanup(&["--branches"]);
let stdout = String::from_utf8_lossy(&out.stdout);
assert!(!worktree.exists(), "the directory still goes");
assert!(
fx.branch_exists(&branch),
"the only copy of the commit must survive: {stdout}"
);
assert!(
stdout.contains("1 branch kept"),
"and it says so: {stdout:?}"
);
}
#[test]
fn a_worktree_holding_changes_is_left_alone() {
let mut fx = Fixture::new();
let task = fx.finished("Cancelled mid-edit", TaskState::Cancelled);
let worktree = fx.worktree(&task);
std::fs::write(worktree.join("half-done.rs"), "fn f() {}\n").unwrap();
let out = fx.cleanup(&[]);
let stdout = String::from_utf8_lossy(&out.stdout);
assert!(out.status.success(), "leaving it alone is not a failure");
assert!(worktree.exists(), "unreviewed work must survive: {stdout}");
assert!(stdout.contains("left alone"), "{stdout:?}");
assert!(
stdout.contains("--force"),
"and how to override it: {stdout:?}"
);
fx.cleanup(&["--force"]);
assert!(!worktree.exists(), "--force means force");
}
#[test]
fn a_dry_run_changes_nothing() {
let mut fx = Fixture::new();
let task = fx.finished("Done with it", TaskState::Cancelled);
let worktree = fx.worktree(&task);
let out = fx.cleanup(&["--dry-run"]);
let stdout = String::from_utf8_lossy(&out.stdout);
assert!(out.status.success());
assert!(stdout.contains("would be removed"), "{stdout:?}");
assert!(
worktree.exists(),
"a dry run that removed something: {stdout}"
);
}
#[test]
fn a_live_task_is_never_touched() {
let mut fx = Fixture::new();
let task = fx.finished("Still working", TaskState::Running);
let worktree = fx.worktree(&task);
let out = fx.cleanup(&["--force"]);
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(out.status.success());
assert!(
worktree.exists(),
"--force is about uncommitted work, not about taking a worktree from a \
running agent"
);
assert!(stderr.contains("no finished task"), "{stderr:?}");
}
#[test]
fn cleanup_without_a_database_says_so_rather_than_making_one() {
let dir = TempDir::new().unwrap();
let out = Command::new(env!("CARGO_BIN_EXE_marver"))
.args(["cleanup", "--data-dir"])
.arg(dir.path())
.output()
.expect("run marver cleanup");
assert!(out.status.success());
assert!(
!dir.path().join("marver.db").exists(),
"a command that reports on the system must not build part of it"
);
}