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, Default, PartialEq, Eq)]
pub struct Teardown {
pub removed: Vec<PathBuf>,
pub failed: Vec<(PathBuf, String)>,
pub workspace_removed: bool,
}
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, delete_branches: bool) -> 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 delete_branches && let Some(branch) = &link.branch {
let _ = git::branch_delete(&repo.path, branch, true);
}
}
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)
}
}
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;
use crate::git::testing::init_repo;
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()
}
}
#[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, false).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, false).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, true).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, false).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, false).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, false).unwrap();
assert!(outcome.is_clean());
assert!(outcome.removed.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
);
}
}