use std::path::{Path, PathBuf};
use crate::providers::ExecContext;
use crate::runtime::worktree::{AgentWorktree, MergeOutcome};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub(crate) enum Isolation {
#[default]
Shared,
Worktree,
}
impl Isolation {
pub(crate) fn parse(raw: &str) -> Option<Self> {
match raw.trim().to_ascii_lowercase().as_str() {
"shared" | "none" => Some(Self::Shared),
"worktree" | "isolated" => Some(Self::Worktree),
_ => None,
}
}
pub(crate) const NAMES: &'static str = "shared, worktree";
}
#[derive(Debug)]
pub(crate) enum Workspace {
Shared { root: PathBuf },
Isolated(Box<AgentWorktree>),
}
#[derive(Debug, Clone)]
pub(crate) struct MergeContext {
checkpoint: bool,
origin: crate::runtime::CheckpointOrigin,
}
impl MergeContext {
pub(crate) fn from_exec(ctx: &ExecContext) -> Self {
Self {
checkpoint: ctx.config.safety.checkpoint_on_mutation,
origin: ctx.checkpoint_origin(),
}
}
}
#[derive(Debug, Default)]
pub(crate) struct WorkspaceReport {
pub(crate) note: String,
pub(crate) needs_attention: bool,
}
impl Workspace {
pub(crate) async fn create(
isolation: Isolation,
workdir: PathBuf,
agent_id: &str,
) -> Result<Self, String> {
match isolation {
Isolation::Shared => Ok(Self::Shared { root: workdir }),
Isolation::Worktree => {
let agent_id = agent_id.to_string();
blocking(move || AgentWorktree::create(&workdir, &agent_id))
.await
.map(|wt| Self::Isolated(Box::new(wt)))
.map_err(|e| format!("could not isolate this agent: {e:#}"))
},
}
}
pub(crate) fn root(&self) -> &Path {
match self {
Self::Shared { root } => root,
Self::Isolated(wt) => wt.root(),
}
}
pub(crate) fn is_isolated(&self) -> bool {
matches!(self, Self::Isolated(_))
}
pub(crate) async fn merge(self, cx: &MergeContext) -> (Self, WorkspaceReport) {
let Self::Isolated(wt) = self else {
return (self, WorkspaceReport::default());
};
let (wt, pending) = blocking_owned(wt, |wt| wt.pending_files()).await;
let files = match pending {
Ok(files) => files,
Err(e) => {
let note = format!(
"Ran in an isolated worktree, but reading back its changes failed: \
{e:#}. Nothing was applied; the worktree is kept at {}.",
wt.root().display()
);
return (
Self::Isolated(wt),
WorkspaceReport {
note,
needs_attention: true,
},
);
},
};
if files.is_empty() {
return (
Self::Isolated(wt),
WorkspaceReport {
note: "Ran in an isolated worktree and changed no files.".to_string(),
needs_attention: false,
},
);
}
let _guards = super::path_lock::lock_paths(&files).await;
if cx.checkpoint
&& let Err(e) = checkpoint_pending(wt.project_root(), files, cx).await
{
return (
Self::Isolated(wt),
WorkspaceReport {
note: format!(
"Ran in an isolated worktree, but checkpointing the project before \
merging failed: {e:#}. Nothing was applied — merging without a \
restore point would leave the change unrecoverable."
),
needs_attention: true,
},
);
}
let (wt, outcome) = blocking_owned(wt, |wt| wt.merge_into_project()).await;
let report = match outcome {
Ok(MergeOutcome::Empty) => WorkspaceReport {
note: "Ran in an isolated worktree and changed no files.".to_string(),
needs_attention: false,
},
Ok(MergeOutcome::Applied { files }) => WorkspaceReport {
note: format!(
"Ran in an isolated worktree; its changes to {files} \
file{} are now applied to the project.",
if files == 1 { "" } else { "s" }
),
needs_attention: false,
},
Ok(MergeOutcome::Conflicted { patch, reason }) => WorkspaceReport {
note: format!(
"Ran in an isolated worktree, but its changes do NOT apply to the \
project as it now stands ({reason}) — most likely the same lines \
changed underneath it. The project is untouched. The rejected \
patch is saved at:\n{}\nReview it before redoing this work.",
patch.display()
),
needs_attention: true,
},
Err(e) => WorkspaceReport {
note: format!(
"Ran in an isolated worktree, but merging its work failed: {e:#}. \
The project is untouched and the worktree is kept at {}.",
wt.root().display()
),
needs_attention: true,
},
};
(Self::Isolated(wt), report)
}
pub(crate) fn unmerged_note(&self) -> String {
match self {
Self::Shared { .. } => String::new(),
Self::Isolated(wt) => format!(
"Its isolated worktree is kept at {} — the work is NOT in the project. \
Continue this agent to finish it, or apply the worktree by hand.",
wt.root().display()
),
}
}
pub(crate) async fn discard(self) {
if let Self::Isolated(wt) = self {
let _ = tokio::task::spawn_blocking(move || wt.destroy()).await;
}
}
}
async fn checkpoint_pending(
project_root: &Path,
files: Vec<PathBuf>,
cx: &MergeContext,
) -> anyhow::Result<()> {
let project = project_root.to_path_buf();
let origin = cx.origin.clone();
let action = serde_json::json!({ "tool": "agent" });
blocking(move || {
crate::runtime::create_checkpoint_for_task(&project, &files, Some(action), origin)?;
Ok(())
})
.await
}
async fn blocking<T, F>(f: F) -> anyhow::Result<T>
where
F: FnOnce() -> anyhow::Result<T> + Send + 'static,
T: Send + 'static,
{
match tokio::task::spawn_blocking(f).await {
Ok(result) => result,
Err(e) => Err(anyhow::anyhow!("worktree task failed: {e}")),
}
}
async fn blocking_owned<V, R, F>(value: V, f: F) -> (V, R)
where
V: Send + 'static,
R: Send + 'static,
F: FnOnce(&mut V) -> R + Send + 'static,
{
tokio::task::spawn_blocking(move || {
let mut value = value;
let result = f(&mut value);
(value, result)
})
.await
.expect("worktree blocking task panicked")
}
#[cfg(test)]
mod tests {
use super::*;
use crate::domain::{ToolCallId, TurnId};
use crate::providers::ctx::test_exec_context;
#[test]
fn isolation_parses_its_spellings_and_rejects_the_rest() {
assert_eq!(Isolation::parse("shared"), Some(Isolation::Shared));
assert_eq!(Isolation::parse(" Worktree "), Some(Isolation::Worktree));
assert_eq!(Isolation::parse("isolated"), Some(Isolation::Worktree));
assert_eq!(Isolation::parse("yes"), None);
assert_eq!(Isolation::default(), Isolation::Shared);
}
#[tokio::test]
async fn a_shared_workspace_is_the_parents_directory_and_owns_nothing() {
let dir = std::env::temp_dir().join(format!("mermaid_ws_{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let ws = Workspace::create(Isolation::Shared, dir.clone(), "a1")
.await
.unwrap();
assert_eq!(ws.root(), dir);
assert!(!ws.is_isolated());
let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), dir.clone());
let (ws, report) = ws.merge(&MergeContext::from_exec(&ctx)).await;
assert!(report.note.is_empty());
assert!(ws.unmerged_note().is_empty());
ws.discard().await;
assert!(dir.exists(), "discarding must not delete the parent's cwd");
}
fn project(tag: &str) -> Option<(PathBuf, MergeContext)> {
use crate::runtime::git::git;
let dir = std::env::temp_dir().join(format!("mermaid_wsi_{tag}_{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
if git(&dir).args(["init", "-q"]).run().is_err() {
return None;
}
std::fs::write(dir.join("app.rs"), "fn main() {}\n").unwrap();
git(&dir).args(["add", "-A"]).run().unwrap();
git(&dir).args(["commit", "-qm", "init"]).run().unwrap();
let mut config = crate::app::Config::default();
config.safety.checkpoint_on_mutation = false;
let (ctx, _rx) = crate::providers::ctx::test_exec_context_with_config(
TurnId(1),
ToolCallId(1),
dir.clone(),
config,
);
Some((dir, MergeContext::from_exec(&ctx)))
}
fn read(path: &Path) -> String {
std::fs::read_to_string(path).unwrap().replace("\r\n", "\n")
}
#[tokio::test]
async fn an_isolated_child_writes_out_of_sight_then_lands_on_merge() {
let Some((project, cx)) = project("lands") else {
return;
};
let ws = Workspace::create(Isolation::Worktree, project.clone(), "a1")
.await
.unwrap();
assert!(ws.is_isolated());
assert_ne!(
ws.root(),
project,
"an isolated child must not share the cwd"
);
std::fs::write(ws.root().join("app.rs"), "fn main() { work(); }\n").unwrap();
assert_eq!(
read(&project.join("app.rs")),
"fn main() {}\n",
"the parent's copy must not move while the child runs"
);
let (ws, report) = ws.merge(&cx).await;
assert!(!report.needs_attention, "{report:?}");
assert!(report.note.contains("1 file"), "{}", report.note);
assert_eq!(read(&project.join("app.rs")), "fn main() { work(); }\n");
ws.discard().await;
}
#[tokio::test]
async fn a_patch_that_cannot_land_is_flagged_for_the_parent() {
let Some((project, cx)) = project("flagged") else {
return;
};
let ws = Workspace::create(Isolation::Worktree, project.clone(), "a1")
.await
.unwrap();
std::fs::write(ws.root().join("app.rs"), "fn main() { agent(); }\n").unwrap();
std::fs::write(project.join("app.rs"), "fn main() { user(); }\n").unwrap();
let (ws, report) = ws.merge(&cx).await;
assert!(report.needs_attention, "{report:?}");
assert!(report.note.contains("do NOT apply"), "{}", report.note);
assert!(report.note.contains(".patch"), "{}", report.note);
assert_eq!(read(&project.join("app.rs")), "fn main() { user(); }\n");
ws.discard().await;
}
#[tokio::test]
async fn a_checkout_that_vanished_is_reported_not_read_as_empty() {
let Some((project, cx)) = project("vanished") else {
return;
};
let ws = Workspace::create(Isolation::Worktree, project.clone(), "a1")
.await
.unwrap();
std::fs::write(ws.root().join("app.rs"), "fn main() { work(); }\n").unwrap();
std::fs::remove_dir_all(ws.root()).unwrap();
let (ws, report) = ws.merge(&cx).await;
assert!(report.needs_attention, "{report:?}");
assert!(
report.note.contains("reading back its changes failed"),
"{}",
report.note
);
assert!(
!report.note.contains("changed no files"),
"a lost checkout must not read as an empty one: {}",
report.note
);
assert_eq!(
read(&project.join("app.rs")),
"fn main() {}\n",
"the project must be untouched"
);
ws.discard().await;
}
#[cfg(unix)]
#[tokio::test]
async fn a_merge_that_cannot_be_checkpointed_applies_nothing() {
use std::os::unix::fs::PermissionsExt;
let Some((project, _)) = project("nocheckpoint") else {
return;
};
let mut config = crate::app::Config::default();
config.safety.checkpoint_on_mutation = true;
let (ctx, _rx) = crate::providers::ctx::test_exec_context_with_config(
TurnId(9),
ToolCallId(9),
project.clone(),
config,
);
let cx = MergeContext::from_exec(&ctx);
let ws = Workspace::create(Isolation::Worktree, project.clone(), "a1")
.await
.unwrap();
std::fs::write(ws.root().join("app.rs"), "fn main() { work(); }\n").unwrap();
let target = project.join("app.rs");
let original = std::fs::metadata(&target).unwrap().permissions();
std::fs::set_permissions(&target, std::fs::Permissions::from_mode(0o000)).unwrap();
let (ws, report) = ws.merge(&cx).await;
std::fs::set_permissions(&target, original).unwrap();
assert!(report.needs_attention, "{report:?}");
assert!(
report
.note
.contains("checkpointing the project before merging failed"),
"{}",
report.note
);
assert_eq!(
read(&target),
"fn main() {}\n",
"a merge with no restore point behind it must not land"
);
ws.discard().await;
}
#[tokio::test]
async fn discarding_an_isolated_workspace_removes_its_checkout() {
let Some((project, _cx)) = project("discard") else {
return;
};
let ws = Workspace::create(Isolation::Worktree, project.clone(), "a1")
.await
.unwrap();
let root = ws.root().to_path_buf();
assert!(root.exists());
ws.discard().await;
assert!(!root.exists(), "a discarded checkout must not linger");
assert!(project.exists(), "the project itself is never touched");
}
#[tokio::test]
async fn isolation_outside_a_repo_reports_instead_of_falling_back() {
let dir = std::env::temp_dir().join(format!("mermaid_ws_norepo_{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let err = Workspace::create(Isolation::Worktree, dir, "a1")
.await
.unwrap_err();
assert!(err.contains("could not isolate"), "{err}");
assert!(err.contains("git repository"), "{err}");
}
}