use std::collections::HashSet;
use std::path::{Path, PathBuf};
use crate::domain::{Task, TaskRepo};
use crate::git;
use crate::store::Store;
#[derive(Debug, thiserror::Error)]
pub enum Error {
#[error(transparent)]
Git(#[from] git::Error),
#[error(transparent)]
Store(#[from] crate::store::Error),
#[error("io error at {path}: {source}")]
Io {
path: PathBuf,
#[source]
source: std::io::Error,
},
#[error("task {0} targets no repos")]
NoRepos(i64),
#[error("workspace {0} already exists")]
WorkspaceExists(PathBuf),
}
pub type Result<T> = std::result::Result<T, Error>;
const MAX_SLUG: usize = 40;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Branches {
Keep,
DeleteMerged,
Discard,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct Teardown {
pub removed: Vec<PathBuf>,
pub failed: Vec<(PathBuf, String)>,
pub workspace_removed: bool,
pub kept_branches: Vec<String>,
}
impl Teardown {
pub fn is_clean(&self) -> bool {
self.failed.is_empty() && self.workspace_removed
}
}
pub fn branch_name(task: &Task) -> String {
let slug = slugify(&task.title);
if slug.is_empty() {
format!("marver/{}", task.id)
} else {
format!("marver/{}-{}", task.id, slug)
}
}
fn slugify(title: &str) -> String {
let mut out = String::with_capacity(title.len().min(MAX_SLUG));
let mut last_dash = true; for ch in title.chars() {
if ch.is_ascii_alphanumeric() {
out.push(ch.to_ascii_lowercase());
last_dash = false;
} else if !last_dash {
out.push('-');
last_dash = true;
}
if out.len() >= MAX_SLUG {
break;
}
}
out.trim_matches('-').to_string()
}
pub struct WorktreeManager {
workspace_root: PathBuf,
}
impl WorktreeManager {
pub fn new(workspace_root: impl Into<PathBuf>) -> Self {
Self {
workspace_root: workspace_root.into(),
}
}
pub fn workspace_root(&self) -> &Path {
&self.workspace_root
}
pub fn provision(&self, store: &Store, task: &Task) -> Result<Vec<TaskRepo>> {
let selection = store.list_task_repos(task.id)?;
if selection.is_empty() {
return Err(Error::NoRepos(task.id));
}
let repos = selection
.iter()
.map(|link| store.get_repo(link.repo_id))
.collect::<std::result::Result<Vec<_>, _>>()?;
let repos = &repos[..];
let workspace = &task.workspace_dir;
if workspace.exists() {
return Err(Error::WorkspaceExists(workspace.clone()));
}
std::fs::create_dir_all(workspace).map_err(|source| Error::Io {
path: workspace.clone(),
source,
})?;
let branch = branch_name(task);
let mut created: Vec<(PathBuf, PathBuf)> = Vec::new(); let mut result = Vec::with_capacity(repos.len());
let mut used = HashSet::new();
for repo in repos {
let worktree_path = workspace.join(unique_dir_name(&repo.name, repo.id, &mut used));
let outcome = git::default_branch(&repo.path).and_then(|base| {
git::worktree_add(&repo.path, &worktree_path, &branch, &base)?;
Ok(base)
});
match outcome {
Ok(base) => {
created.push((repo.path.clone(), worktree_path.clone()));
match store.record_worktree(task.id, repo.id, &worktree_path, &branch, &base) {
Ok(record) => result.push(record),
Err(err) => {
self.unwind(workspace, &created, &branch);
return Err(err.into());
}
}
}
Err(err) => {
self.unwind(workspace, &created, &branch);
return Err(err.into());
}
}
}
Ok(result)
}
fn unwind(&self, workspace: &Path, created: &[(PathBuf, PathBuf)], branch: &str) {
for (repo_path, worktree_path) in created {
let _ = git::worktree_remove(repo_path, worktree_path, true);
let _ = git::branch_delete(repo_path, branch, true);
}
let _ = std::fs::remove_dir_all(workspace);
}
pub fn teardown(&self, store: &Store, task: &Task, branches: Branches) -> Result<Teardown> {
let mut outcome = Teardown::default();
for link in store.list_task_repos(task.id)? {
let Some(worktree_path) = link.worktree_path.clone() else {
continue;
};
let repo = match store.get_repo(link.repo_id) {
Ok(repo) => repo,
Err(err) => {
outcome.failed.push((worktree_path, err.to_string()));
continue;
}
};
match git::worktree_remove(&repo.path, &worktree_path, true) {
Ok(()) => outcome.removed.push(worktree_path.clone()),
Err(err) => {
let _ = git::worktree_prune(&repo.path);
if worktree_path.exists() {
outcome.failed.push((worktree_path, err.to_string()));
continue;
}
outcome.removed.push(worktree_path.clone());
}
}
if let Some(branch) = &link.branch {
match branches {
Branches::Keep => {}
Branches::Discard => {
let _ = git::branch_delete(&repo.path, branch, true);
}
Branches::DeleteMerged => {
if git::branch_delete(&repo.path, branch, false).is_err() {
outcome.kept_branches.push(branch.clone());
}
}
}
}
}
if outcome.failed.is_empty() {
match std::fs::remove_dir_all(&task.workspace_dir) {
Ok(()) => outcome.workspace_removed = true,
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
outcome.workspace_removed = true;
}
Err(err) => outcome
.failed
.push((task.workspace_dir.clone(), err.to_string())),
}
}
Ok(outcome)
}
}
#[derive(Debug, Clone)]
pub struct Candidate {
pub task: Task,
pub worktrees: Vec<PathBuf>,
pub dirty: Vec<PathBuf>,
}
impl Candidate {
pub fn is_clean(&self) -> bool {
self.dirty.is_empty()
}
}
pub fn reclaimable(store: &Store) -> Result<Vec<Candidate>> {
let mut candidates = Vec::new();
for task in store.list_tasks()? {
if !task.state.is_terminal() {
continue;
}
let worktrees: Vec<PathBuf> = store
.list_task_repos(task.id)?
.into_iter()
.filter_map(|link| link.worktree_path)
.collect();
if worktrees.is_empty() {
continue;
}
let mut dirty: Vec<PathBuf> = worktrees
.iter()
.filter(|path| holds_changes(path))
.cloned()
.collect();
dirty.extend(loose_files(&task, &worktrees));
candidates.push(Candidate {
task,
worktrees,
dirty,
});
}
Ok(candidates)
}
pub fn dirty_worktrees(store: &Store, task: &Task) -> Result<Vec<PathBuf>> {
let worktrees: Vec<PathBuf> = store
.list_task_repos(task.id)?
.into_iter()
.filter_map(|link| link.worktree_path)
.collect();
let mut dirty: Vec<PathBuf> = worktrees
.iter()
.filter(|path| holds_changes(path))
.cloned()
.collect();
dirty.extend(loose_files(task, &worktrees));
Ok(dirty)
}
fn loose_files(task: &Task, worktrees: &[PathBuf]) -> Vec<PathBuf> {
let entries = match std::fs::read_dir(&task.workspace_dir) {
Ok(entries) => entries,
Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Vec::new(),
Err(_) => return vec![task.workspace_dir.clone()],
};
entries
.flatten()
.map(|entry| entry.path())
.filter(|path| {
let name = path.file_name().and_then(|name| name.to_str());
!MARVERS_OWN.contains(&name.unwrap_or_default()) && !worktrees.contains(path)
})
.collect()
}
const MARVERS_OWN: &[&str] = &[crate::brief::FILE_NAME, crate::hook::SETTINGS_FILE];
fn holds_changes(path: &Path) -> bool {
if !path.exists() {
return false;
}
match git::status(path) {
Ok(entries) => !entries.is_empty(),
Err(_) => true,
}
}
fn unique_dir_name(name: &str, repo_id: i64, used: &mut HashSet<String>) -> String {
let base = if name.is_empty() {
format!("repo-{repo_id}")
} else {
name.to_string()
};
if used.insert(base.clone()) {
return base;
}
let disambiguated = format!("{base}-{repo_id}");
used.insert(disambiguated.clone());
disambiguated
}
#[cfg(test)]
mod tests {
use super::*;
use crate::domain::{Repo, TaskState};
use crate::git::testing::init_repo;
use crate::store::Transition;
use chrono::{DateTime, Utc};
use tempfile::TempDir;
fn at(secs: i64) -> DateTime<Utc> {
DateTime::from_timestamp(secs, 0).expect("valid timestamp")
}
struct Fixture {
_tmp: TempDir,
repos_dir: PathBuf,
store: Store,
manager: WorktreeManager,
}
impl Fixture {
fn new() -> Self {
let tmp = TempDir::new().unwrap();
let repos_dir = tmp.path().join("repos");
let workspace_root = tmp.path().join("tasks");
std::fs::create_dir_all(&repos_dir).unwrap();
Self {
repos_dir,
store: Store::open_in_memory().unwrap(),
manager: WorktreeManager::new(workspace_root),
_tmp: tmp,
}
}
fn repo(&self, name: &str, default_branch: &str) -> Repo {
let path = self.repos_dir.join(name);
init_repo(&path, default_branch);
self.store.upsert_repo(&path, name, at(0)).unwrap()
}
fn task(&mut self, title: &str, repos: &[Repo]) -> Task {
let root = self.manager.workspace_root().to_path_buf();
let ids: Vec<i64> = repos.iter().map(|r| r.id).collect();
self.store
.create_task(title, "do the thing", &root, &ids, at(0))
.unwrap()
}
fn finish(&mut self, task: &Task, end: TaskState) -> Task {
let path: &[TaskState] = match end {
TaskState::Cancelled => &[TaskState::Cancelled],
TaskState::Committed => &[
TaskState::Running,
TaskState::AwaitingReview,
TaskState::Committed,
],
other => panic!("no walk to {other}"),
};
let mut last = task.clone();
for &state in path {
last = self
.store
.transition(task.id, state, Transition::Plain, at(1))
.unwrap();
}
last
}
}
#[test]
fn slugs_are_branch_safe() {
assert_eq!(slugify("Fix the auth flow"), "fix-the-auth-flow");
assert_eq!(slugify(" Weird!! chars?? "), "weird-chars");
assert_eq!(slugify("CAPS and 123"), "caps-and-123");
assert_eq!(slugify("!!!"), "");
assert!(slugify(&"x".repeat(200)).len() <= MAX_SLUG);
}
#[test]
fn branch_names_are_namespaced_and_unique() {
let mut fx = Fixture::new();
let a = fx.task("Fix the auth flow", &[]);
let b = fx.task("Fix the auth flow", &[]);
assert_eq!(
branch_name(&a),
format!("marver/{}-fix-the-auth-flow", a.id)
);
assert_ne!(
branch_name(&a),
branch_name(&b),
"same title, different tasks"
);
}
#[test]
fn a_title_with_no_usable_characters_still_yields_a_branch() {
let mut fx = Fixture::new();
let task = fx.task("!!!", &[]);
assert_eq!(branch_name(&task), format!("marver/{}", task.id));
}
#[test]
fn provisions_a_single_repo() {
let mut fx = Fixture::new();
let repo = fx.repo("api", "main");
let task = fx.task("Fix the auth flow", std::slice::from_ref(&repo));
let links = fx.manager.provision(&fx.store, &task).unwrap();
assert_eq!(links.len(), 1);
assert_eq!(links[0].base_ref.as_deref(), Some("main"));
assert_eq!(
links[0].branch.as_deref(),
Some(branch_name(&task).as_str())
);
assert!(
links[0]
.worktree_path
.as_ref()
.unwrap()
.join("README.md")
.exists()
);
assert_eq!(
links[0].worktree_path.as_deref(),
Some(task.workspace_dir.join("api").as_path())
);
assert_eq!(fx.store.list_task_repos(task.id).unwrap().len(), 1);
}
#[test]
fn each_repo_branches_from_its_own_default() {
let mut fx = Fixture::new();
let api = fx.repo("api", "main");
let web = fx.repo("web", "develop");
let task = fx.task("Cross cutting change", &[api, web]);
let links = fx.manager.provision(&fx.store, &task).unwrap();
assert_eq!(links.len(), 2);
assert_eq!(links[0].base_ref.as_deref(), Some("main"));
assert_eq!(links[1].base_ref.as_deref(), Some("develop"));
assert_eq!(
links[0].branch, links[1].branch,
"one branch name across the task"
);
assert!(task.workspace_dir.join("api").exists());
assert!(task.workspace_dir.join("web").exists());
}
#[test]
fn repos_sharing_a_name_do_not_collide() {
let mut fx = Fixture::new();
let a = {
let path = fx.repos_dir.join("org-a/shared");
init_repo(&path, "main");
fx.store.upsert_repo(&path, "shared", at(0)).unwrap()
};
let b = {
let path = fx.repos_dir.join("org-b/shared");
init_repo(&path, "main");
fx.store.upsert_repo(&path, "shared", at(0)).unwrap()
};
let task = fx.task("Touch both", &[a, b.clone()]);
let links = fx.manager.provision(&fx.store, &task).unwrap();
assert_eq!(
links[0].worktree_path.as_deref(),
Some(task.workspace_dir.join("shared").as_path())
);
assert_eq!(
links[1].worktree_path.as_deref(),
Some(
task.workspace_dir
.join(format!("shared-{}", b.id))
.as_path()
)
);
}
#[test]
fn a_task_with_no_repos_is_rejected() {
let mut fx = Fixture::new();
let task = fx.task("Nothing to do", &[]);
assert!(matches!(
fx.manager.provision(&fx.store, &task),
Err(Error::NoRepos(_))
));
assert!(!task.workspace_dir.exists(), "nothing should be created");
}
#[test]
fn provisioning_twice_is_refused() {
let mut fx = Fixture::new();
let repo = fx.repo("api", "main");
let task = fx.task("Fix it", std::slice::from_ref(&repo));
fx.manager.provision(&fx.store, &task).unwrap();
assert!(matches!(
fx.manager.provision(&fx.store, &task),
Err(Error::WorkspaceExists(_))
));
}
#[test]
fn a_partial_failure_leaves_nothing_behind() {
let mut fx = Fixture::new();
let good = fx.repo("api", "main");
let broken_path = fx.repos_dir.join("broken");
std::fs::create_dir_all(&broken_path).unwrap();
let broken = fx.store.upsert_repo(&broken_path, "broken", at(0)).unwrap();
let task = fx.task("Will fail", &[good.clone(), broken]);
let err = fx.manager.provision(&fx.store, &task).unwrap_err();
assert!(matches!(err, Error::Git(_)));
assert!(
!task.workspace_dir.exists(),
"the workspace must be cleaned up"
);
assert!(
!git::branch_exists(&good.path, &branch_name(&task)).unwrap(),
"the branch created for the successful repo must be removed"
);
}
#[test]
fn a_failure_on_the_first_repo_also_leaves_nothing_behind() {
let mut fx = Fixture::new();
let broken_path = fx.repos_dir.join("broken");
std::fs::create_dir_all(&broken_path).unwrap();
let broken = fx.store.upsert_repo(&broken_path, "broken", at(0)).unwrap();
let good = fx.repo("api", "main");
let task = fx.task("Will fail", &[broken, good]);
let err = fx.manager.provision(&fx.store, &task).unwrap_err();
assert!(!matches!(err, Error::WorkspaceExists(_)));
assert!(
!task.workspace_dir.exists(),
"the workspace must be cleaned up even when nothing was created"
);
let again = fx.manager.provision(&fx.store, &task).unwrap_err();
assert!(
!matches!(again, Error::WorkspaceExists(_)),
"a retry must not be blocked by the last failure's leftovers"
);
}
#[test]
fn a_task_can_be_provisioned_again_after_a_teardown_that_kept_its_branch() {
let mut fx = Fixture::new();
let repo = fx.repo("api", "main");
let task = fx.task("Fix it", std::slice::from_ref(&repo));
let created = fx.manager.provision(&fx.store, &task).unwrap();
let wt = created[0].worktree_path.clone().unwrap();
std::fs::write(wt.join("new.rs"), "fn f() {}\n").unwrap();
git::stage_all(&wt).unwrap();
git::commit(&wt, "agent work").unwrap();
fx.manager
.teardown(&fx.store, &task, Branches::Keep)
.unwrap();
fx.store.clear_worktrees(task.id).unwrap();
assert!(git::branch_exists(&repo.path, &branch_name(&task)).unwrap());
let again = fx.manager.provision(&fx.store, &task).unwrap();
let wt = again[0].worktree_path.clone().unwrap();
assert!(
wt.join("new.rs").exists(),
"reprovisioning must pick the branch back up, not start over"
);
}
#[test]
fn teardown_removes_worktrees_and_the_workspace() {
let mut fx = Fixture::new();
let repo = fx.repo("api", "main");
let task = fx.task("Fix it", std::slice::from_ref(&repo));
fx.manager.provision(&fx.store, &task).unwrap();
let outcome = fx
.manager
.teardown(&fx.store, &task, Branches::Keep)
.unwrap();
assert!(outcome.is_clean());
assert_eq!(outcome.removed.len(), 1);
assert!(!task.workspace_dir.exists());
assert!(
git::branch_exists(&repo.path, &branch_name(&task)).unwrap(),
"the branch survives by default"
);
assert_eq!(
fx.store.list_task_repos(task.id).unwrap().len(),
1,
"the record of what the task did is kept"
);
}
#[test]
fn teardown_can_delete_the_branches_too() {
let mut fx = Fixture::new();
let repo = fx.repo("api", "main");
let task = fx.task("Abandoned", std::slice::from_ref(&repo));
fx.manager.provision(&fx.store, &task).unwrap();
fx.manager
.teardown(&fx.store, &task, Branches::Discard)
.unwrap();
assert!(!git::branch_exists(&repo.path, &branch_name(&task)).unwrap());
}
#[test]
fn teardown_discards_uncommitted_work() {
let mut fx = Fixture::new();
let repo = fx.repo("api", "main");
let task = fx.task("Dirty", std::slice::from_ref(&repo));
let links = fx.manager.provision(&fx.store, &task).unwrap();
std::fs::write(
links[0].worktree_path.as_ref().unwrap().join("README.md"),
"edited\n",
)
.unwrap();
let outcome = fx
.manager
.teardown(&fx.store, &task, Branches::Keep)
.unwrap();
assert!(
outcome.is_clean(),
"a dirty worktree must not block teardown"
);
}
#[test]
fn teardown_tolerates_an_already_deleted_worktree() {
let mut fx = Fixture::new();
let repo = fx.repo("api", "main");
let task = fx.task("Vanished", std::slice::from_ref(&repo));
let links = fx.manager.provision(&fx.store, &task).unwrap();
std::fs::remove_dir_all(links[0].worktree_path.as_ref().unwrap()).unwrap();
let outcome = fx
.manager
.teardown(&fx.store, &task, Branches::Keep)
.unwrap();
assert!(outcome.is_clean(), "{:?}", outcome.failed);
assert!(!task.workspace_dir.exists());
}
#[test]
fn teardown_of_an_unprovisioned_task_is_harmless() {
let mut fx = Fixture::new();
let task = fx.task("Never started", &[]);
let outcome = fx
.manager
.teardown(&fx.store, &task, Branches::Keep)
.unwrap();
assert!(outcome.is_clean());
assert!(outcome.removed.is_empty());
}
#[test]
fn only_finished_tasks_are_offered_for_reclaiming() {
let mut fx = Fixture::new();
let repo = fx.repo("api", "main");
let live = fx.task("Still working", std::slice::from_ref(&repo));
let done = fx.task("Finished", std::slice::from_ref(&repo));
fx.manager.provision(&fx.store, &live).unwrap();
fx.manager.provision(&fx.store, &done).unwrap();
fx.store
.transition(live.id, TaskState::Running, Transition::Plain, at(1))
.unwrap();
fx.finish(&done, TaskState::Committed);
let found = reclaimable(&fx.store).unwrap();
assert_eq!(found.len(), 1, "a running task is in use, not litter");
assert_eq!(found[0].task.id, done.id);
assert!(found[0].is_clean());
}
#[test]
fn what_the_agent_left_beside_the_worktrees_counts_as_work_too() {
let mut fx = Fixture::new();
let repo = fx.repo("api", "main");
let task = fx.task("Wrote a report", std::slice::from_ref(&repo));
fx.manager.provision(&fx.store, &task).unwrap();
let report = task.workspace_dir.join("REPORT.md");
std::fs::write(&report, "# what I found\n").unwrap();
fx.finish(&task, TaskState::Committed);
assert_eq!(
dirty_worktrees(&fx.store, &task).unwrap(),
vec![report.clone()],
"the confirmation must fire for it"
);
let found = reclaimable(&fx.store).unwrap();
assert_eq!(found.len(), 1);
assert!(!found[0].is_clean(), "and cleanup must skip it");
}
#[test]
fn marvers_own_brief_is_not_mistaken_for_the_agents_work() {
let mut fx = Fixture::new();
let repo = fx.repo("api", "main");
let task = fx.task("Ordinary", std::slice::from_ref(&repo));
fx.manager.provision(&fx.store, &task).unwrap();
std::fs::write(task.workspace_dir.join(crate::brief::FILE_NAME), "# task\n").unwrap();
fx.finish(&task, TaskState::Committed);
assert!(dirty_worktrees(&fx.store, &task).unwrap().is_empty());
}
#[test]
fn a_task_that_never_reached_a_worktree_is_not_offered() {
let mut fx = Fixture::new();
let repo = fx.repo("api", "main");
let task = fx.task("Never started", std::slice::from_ref(&repo));
fx.finish(&task, TaskState::Cancelled);
assert!(reclaimable(&fx.store).unwrap().is_empty());
}
#[test]
fn a_worktree_holding_changes_is_offered_but_flagged() {
let mut fx = Fixture::new();
let repo = fx.repo("api", "main");
let task = fx.task("Cancelled mid-edit", std::slice::from_ref(&repo));
let links = fx.manager.provision(&fx.store, &task).unwrap();
let wt = links[0].worktree_path.clone().unwrap();
std::fs::write(wt.join("half-done.rs"), "fn f() {}\n").unwrap();
fx.finish(&task, TaskState::Cancelled);
let found = reclaimable(&fx.store).unwrap();
assert_eq!(found.len(), 1);
assert!(!found[0].is_clean(), "an untracked file is unreviewed work");
assert_eq!(found[0].dirty, vec![wt]);
}
#[test]
fn a_worktree_removed_by_hand_is_not_mistaken_for_dirty() {
let mut fx = Fixture::new();
let repo = fx.repo("api", "main");
let task = fx.task("Tidied already", std::slice::from_ref(&repo));
let links = fx.manager.provision(&fx.store, &task).unwrap();
std::fs::remove_dir_all(links[0].worktree_path.as_ref().unwrap()).unwrap();
fx.finish(&task, TaskState::Cancelled);
let found = reclaimable(&fx.store).unwrap();
assert_eq!(found.len(), 1);
assert!(
found[0].is_clean(),
"a directory that is gone holds nothing"
);
}
#[test]
fn deleting_merged_branches_keeps_the_one_holding_the_work() {
let mut fx = Fixture::new();
let repo = fx.repo("api", "main");
let task = fx.task("Committed work", std::slice::from_ref(&repo));
let links = fx.manager.provision(&fx.store, &task).unwrap();
let wt = links[0].worktree_path.clone().unwrap();
std::fs::write(wt.join("new.rs"), "fn f() {}\n").unwrap();
git::stage_all(&wt).unwrap();
git::commit(&wt, "agent work").unwrap();
let outcome = fx
.manager
.teardown(&fx.store, &task, Branches::DeleteMerged)
.unwrap();
assert!(outcome.is_clean(), "the directory still goes");
assert!(
git::branch_exists(&repo.path, &branch_name(&task)).unwrap(),
"deleting this branch would destroy the commit"
);
assert_eq!(outcome.kept_branches, vec![branch_name(&task)]);
}
#[test]
fn deleting_merged_branches_removes_the_one_that_did_nothing() {
let mut fx = Fixture::new();
let repo = fx.repo("api", "main");
let task = fx.task("Nothing came of it", std::slice::from_ref(&repo));
fx.manager.provision(&fx.store, &task).unwrap();
let outcome = fx
.manager
.teardown(&fx.store, &task, Branches::DeleteMerged)
.unwrap();
assert!(!git::branch_exists(&repo.path, &branch_name(&task)).unwrap());
assert!(outcome.kept_branches.is_empty());
}
#[test]
fn a_provisioned_worktree_is_not_seen_as_a_repo_by_the_scanner() {
let mut fx = Fixture::new();
let repo = fx.repo("api", "main");
let task = fx.task("Fix it", std::slice::from_ref(&repo));
fx.manager.provision(&fx.store, &task).unwrap();
let scan = crate::scan::Scanner::new(fx.manager.workspace_root())
.walk()
.unwrap();
assert!(
scan.repos.is_empty(),
"marver must not rediscover its own worktrees: {:?}",
scan.repos
);
}
}