marver 0.0.28

A TUI workspace for AI agent sessions: tmux orchestration, git worktree management, and repo control in one place.
Documentation
//! Reclaiming the worktrees finished tasks leave behind, through the real
//! binary.
//!
//! Outside `src` because the command is the thing being tested: what it refuses
//! to delete matters more than any of the pieces it is built from, and the
//! pieces are already covered in `worktree`.

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;

/// A repo with one commit on `main`, the way the scanner would find one.
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 {
        // Short, because the config derives a unix socket path from it.
        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,
        }
    }

    /// A task with a worktree on disk, walked to `end`.
    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() {
    // marver commits onto the task branch and merges it nowhere, so deleting it
    // as part of freeing a directory would destroy the work the task did.
    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() {
    // "Finished" is a state marver recorded, not a promise about the directory.
    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:?}"
    );

    // And the override does what it says.
    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"
    );
}