use std::path::{Path, PathBuf};
use thiserror::Error;
use tracing::{debug, info};
use crate::session_manager::ManagedSessionId;
#[derive(Debug, Error)]
pub enum ProvisionError {
#[error("git error: {0}")]
Git(String),
#[error("I/O error: {0}")]
Io(#[from] std::io::Error),
#[error("session preparation failed: {0}")]
PrepareSession(String),
}
#[derive(Debug, Clone)]
pub struct PreparedWorkspace {
pub path: PathBuf,
pub repo_url: String,
pub branch: String,
}
pub trait GitBackend: Send + Sync {
fn clone_repo(
&self,
repo_url: &str,
git_ref: &str,
target_dir: &Path,
) -> Result<(), ProvisionError>;
fn is_git_repo(&self, dir: &Path) -> bool;
fn remote_url(&self, dir: &Path) -> Result<String, ProvisionError>;
fn fetch_and_reset(&self, dir: &Path, git_ref: &str) -> Result<(), ProvisionError>;
fn ensure_base_checkout(&self, repo_url: &str, base_dir: &Path) -> Result<(), ProvisionError>;
fn worktree_add(
&self,
base_dir: &Path,
git_ref: &str,
worktree_path: &Path,
branch_name: &str,
) -> Result<(), ProvisionError>;
}
#[derive(Debug, Clone, Default)]
pub struct RealGitBackend {
identity: crate::core::git_identity::GitIdentity,
}
impl RealGitBackend {
pub fn new(identity: crate::core::git_identity::GitIdentity) -> Self {
Self { identity }
}
fn command(&self) -> std::process::Command {
let mut cmd = std::process::Command::new("git");
cmd.args(self.identity.commit_config_args());
cmd.envs(self.identity.env.iter().cloned());
cmd
}
}
use base_lock::{
acquire_base_checkout_lock, base_checkout_lock_path, fake_is_established_checkout,
is_established_checkout, stale_base_dir_error, write_fake_checkout,
};
impl GitBackend for RealGitBackend {
fn clone_repo(
&self,
repo_url: &str,
git_ref: &str,
target_dir: &Path,
) -> Result<(), ProvisionError> {
let dir_str = target_dir.to_string_lossy().into_owned();
let mut args: Vec<&str> = vec!["clone", "--progress", "--depth", "1"];
if !git_ref.trim().is_empty() {
args.push("--branch");
args.push(git_ref);
}
args.push(repo_url);
args.push(&dir_str);
let cwd = target_dir.parent().unwrap_or(std::path::Path::new("/"));
let mut cmd = self.command();
cmd.args(&args).current_dir(cwd);
let outcome = super::clone_progress::clone_with_progress(cmd)
.map_err(|e| ProvisionError::Git(format!("git clone exec failed: {e}")))?;
if outcome.success {
Ok(())
} else {
Err(ProvisionError::Git(format!(
"git clone failed: {}",
outcome.stderr
)))
}
}
fn is_git_repo(&self, dir: &Path) -> bool {
let dot_git = dir.join(".git");
dot_git.is_dir() || dot_git.is_file()
}
fn remote_url(&self, dir: &Path) -> Result<String, ProvisionError> {
let dir_s = dir.to_string_lossy();
let out = self
.command()
.args(["-C", &dir_s, "remote", "get-url", "origin"])
.output()
.map_err(|e| ProvisionError::Git(format!("git remote get-url exec failed: {e}")))?;
if out.status.success() {
Ok(String::from_utf8_lossy(&out.stdout).trim().to_owned())
} else {
let stderr = String::from_utf8_lossy(&out.stderr);
Err(ProvisionError::Git(format!(
"git remote get-url failed: {stderr}"
)))
}
}
fn fetch_and_reset(&self, dir: &Path, git_ref: &str) -> Result<(), ProvisionError> {
let dir_s = dir.to_string_lossy();
let fetch = self
.command()
.args(["-C", &dir_s, "fetch", "origin", git_ref])
.output()
.map_err(|e| ProvisionError::Git(format!("git fetch exec failed: {e}")))?;
if !fetch.status.success() {
let stderr = String::from_utf8_lossy(&fetch.stderr);
return Err(ProvisionError::Git(format!("git fetch failed: {stderr}")));
}
let reset = self
.command()
.args(["-C", &dir_s, "reset", "--hard", "FETCH_HEAD"])
.output()
.map_err(|e| ProvisionError::Git(format!("git reset exec failed: {e}")))?;
if reset.status.success() {
Ok(())
} else {
let stderr = String::from_utf8_lossy(&reset.stderr);
Err(ProvisionError::Git(format!("git reset failed: {stderr}")))
}
}
fn ensure_base_checkout(&self, repo_url: &str, base_dir: &Path) -> Result<(), ProvisionError> {
if is_established_checkout(base_dir) {
debug!(path = %base_dir.display(), "base checkout already present, reusing");
return exclude_worktrees(base_dir);
}
if let Some(parent) = base_dir.parent() {
std::fs::create_dir_all(parent)?;
}
let lock_path = base_checkout_lock_path(base_dir);
let _lock = acquire_base_checkout_lock(&lock_path)?;
if is_established_checkout(base_dir) {
debug!(
path = %base_dir.display(),
"base checkout completed by a concurrent caller while waiting for the lock; reusing"
);
return Ok(());
}
if let Some(err) = stale_base_dir_error(base_dir) {
return Err(err);
}
let cwd = base_dir.parent().unwrap_or(std::path::Path::new("/"));
let mut cmd = self.command();
cmd.args(["clone", "--no-local", "--progress", repo_url])
.arg(base_dir)
.current_dir(cwd);
let outcome = super::clone_progress::clone_with_progress(cmd)
.map_err(|e| ProvisionError::Git(format!("git clone exec failed: {e}")))?;
if outcome.success {
info!(url = %repo_url, dest = %base_dir.display(), "base checkout cloned");
crate::core::push_guard::install_and_log(base_dir);
exclude_worktrees(base_dir)
} else {
Err(ProvisionError::Git(format!(
"git clone failed: {}",
outcome.stderr
)))
}
}
fn worktree_add(
&self,
base_dir: &Path,
git_ref: &str,
worktree_path: &Path,
branch_name: &str,
) -> Result<(), ProvisionError> {
let base_s = base_dir.to_string_lossy();
let src_ref = if git_ref.trim().is_empty() {
"HEAD"
} else {
git_ref
};
let refspec = format!("+{src_ref}:refs/heads/{branch_name}");
let fetch = self
.command()
.args(["-C", &base_s, "fetch", "origin", &refspec])
.output()
.map_err(|e| ProvisionError::Git(format!("git fetch exec failed: {e}")))?;
if !fetch.status.success() {
let stderr = String::from_utf8_lossy(&fetch.stderr);
return Err(ProvisionError::Git(format!("git fetch failed: {stderr}")));
}
if let Some(parent) = worktree_path.parent() {
std::fs::create_dir_all(parent)?;
}
let out = self
.command()
.args(["-C", &base_s, "worktree", "add"])
.arg(worktree_path)
.arg(branch_name)
.output()
.map_err(|e| ProvisionError::Git(format!("git worktree add exec failed: {e}")))?;
if out.status.success() {
info!(
base = %base_dir.display(),
worktree = %worktree_path.display(),
branch = %branch_name,
"per-session worktree created"
);
crate::core::worktree_index::index_new_worktree_in_background(worktree_path.into());
Ok(())
} else {
let stderr = String::from_utf8_lossy(&out.stderr);
Err(ProvisionError::Git(format!(
"git worktree add failed (exit {}): {stderr}",
out.status
)))
}
}
}
pub struct FakeGitBackend {
pub calls: std::sync::Mutex<Vec<(String, String, PathBuf)>>,
strict: bool,
}
impl FakeGitBackend {
pub fn new() -> Self {
Self {
calls: std::sync::Mutex::new(Vec::new()),
strict: false,
}
}
pub fn new_strict() -> Self {
Self {
calls: std::sync::Mutex::new(Vec::new()),
strict: true,
}
}
}
impl Default for FakeGitBackend {
fn default() -> Self {
Self::new()
}
}
impl GitBackend for FakeGitBackend {
fn clone_repo(
&self,
repo_url: &str,
git_ref: &str,
target_dir: &Path,
) -> Result<(), ProvisionError> {
self.calls.lock().unwrap().push((
repo_url.to_owned(),
git_ref.to_owned(),
target_dir.to_owned(),
));
if self.strict && target_dir.exists() {
return Err(ProvisionError::Git(format!(
"git clone failed (exit 128): destination path '{}' already exists and is not an empty directory",
target_dir.display()
)));
}
let git_dir = target_dir.join(".git");
std::fs::create_dir_all(&git_dir)?;
let config = format!(
"[core]\n\trepositoryformatversion = 0\n[remote \"origin\"]\n\turl = {repo_url}\n"
);
std::fs::write(git_dir.join("config"), config)?;
Ok(())
}
fn is_git_repo(&self, dir: &Path) -> bool {
dir.join(".git").is_dir()
}
fn remote_url(&self, dir: &Path) -> Result<String, ProvisionError> {
let config_path = dir.join(".git").join("config");
let content = std::fs::read_to_string(&config_path)
.map_err(|e| ProvisionError::Git(format!("no .git/config: {e}")))?;
let mut in_origin = false;
for line in content.lines() {
let t = line.trim();
if t == "[remote \"origin\"]" {
in_origin = true;
} else if t.starts_with('[') {
in_origin = false;
} else if in_origin && let Some(url) = t.strip_prefix("url = ") {
return Ok(url.to_owned());
}
}
Err(ProvisionError::Git(
"no origin remote in .git/config".to_owned(),
))
}
fn fetch_and_reset(&self, _dir: &Path, _git_ref: &str) -> Result<(), ProvisionError> {
Ok(())
}
fn ensure_base_checkout(&self, repo_url: &str, base_dir: &Path) -> Result<(), ProvisionError> {
self.calls.lock().unwrap().push((
repo_url.to_owned(),
"ensure_base_checkout".to_owned(),
base_dir.to_owned(),
));
if fake_is_established_checkout(base_dir) {
return Ok(());
}
if let Some(err) = stale_base_dir_error(base_dir) {
return Err(err);
}
write_fake_checkout(base_dir, repo_url)?;
Ok(())
}
fn worktree_add(
&self,
base_dir: &Path,
git_ref: &str,
worktree_path: &Path,
branch_name: &str,
) -> Result<(), ProvisionError> {
self.calls.lock().unwrap().push((
git_ref.to_owned(),
branch_name.to_owned(),
worktree_path.to_owned(),
));
std::fs::create_dir_all(worktree_path)?;
std::fs::write(
worktree_path.join(".git"),
format!("gitdir: {}/worktrees/{branch_name}\n", base_dir.display()),
)?;
Ok(())
}
}
pub struct WorkspaceProvisioner<G: GitBackend> {
git: G,
workspace_root: PathBuf,
prepare: bool,
}
impl<G: GitBackend> WorkspaceProvisioner<G> {
pub fn new(git: G, workspace_root: PathBuf) -> Self {
Self {
git,
workspace_root,
prepare: true,
}
}
#[doc(hidden)]
pub fn without_prepare(git: G, workspace_root: PathBuf) -> Self {
Self {
git,
workspace_root,
prepare: false,
}
}
pub fn provision(
&self,
session_id: &ManagedSessionId,
repo_url: &str,
git_ref: &str,
task: &str,
) -> Result<PreparedWorkspace, ProvisionError> {
let project_slug = repo_slug(repo_url);
let project_dir = self.workspace_root.join(&project_slug);
self.provision_in(&project_dir, session_id, repo_url, git_ref, task)
}
pub fn provision_in(
&self,
project_dir: &Path,
session_id: &ManagedSessionId,
repo_url: &str,
git_ref: &str,
task: &str,
) -> Result<PreparedWorkspace, ProvisionError> {
let base_dir = project_dir.to_path_buf();
let workspace_path = project_dir
.join(crate::session_manager::decommission::worktrees_dirname())
.join(session_id.to_string());
let branch_name = session_id.to_string();
debug!(
session = %session_id,
base = %base_dir.display(),
path = %workspace_path.display(),
repo = %repo_url,
git_ref = %git_ref,
"provisioning workspace"
);
crate::core::provisioning_stage::emit(
crate::core::provisioning_stage::ProvisioningStage::CloningRepo,
);
self.git.ensure_base_checkout(repo_url, &base_dir)?;
self.git
.worktree_add(&base_dir, git_ref, &workspace_path, &branch_name)?;
let sentinel =
workspace_path.join(crate::session_manager::decommission::WORKTREE_SENTINEL_FILE);
if let Err(e) = std::fs::write(
&sentinel,
crate::session_manager::worktree_ownership::sentinel_payload_bytes(*session_id),
) {
tracing::warn!(
session = %session_id,
path = %workspace_path.display(),
"failed to write worktree ownership sentinel (non-fatal): {e}"
);
}
if !task.is_empty() {
let task_file = workspace_path.join("TASK.md");
if let Err(e) = std::fs::write(&task_file, task) {
tracing::warn!(
session = %session_id,
path = %task_file.display(),
"failed to write TASK.md: {e}"
);
}
}
if let Err(e) = crate::core::home_trust_seed::preseed_home_trust(&workspace_path) {
tracing::warn!(
session = %session_id,
path = %workspace_path.display(),
"home trust pre-seed failed (non-fatal): {e}"
);
}
if !self.prepare {
return Ok(PreparedWorkspace {
path: workspace_path,
repo_url: repo_url.to_owned(),
branch: git_ref.to_owned(),
});
}
let fw = crate::core::paths::FrameworkPaths::for_managed_workspace(&workspace_path);
match crate::core::session_launch::prepare_session_for_managed(
&fw,
&workspace_path,
Some(repo_url),
&session_id.to_string(),
) {
Ok(report) => {
info!(
session = %session_id,
deployed = report.deploy.deployed.len(),
path = %workspace_path.display(),
"workspace provisioned and session prepared"
);
for err in &report.roster_errors {
tracing::error!(
session = %session_id,
path = %workspace_path.display(),
"roster provisioning gap (session still launches with its \
trusty-mpm identity): {err}"
);
}
}
Err(e) if e.is_fatal() => {
return Err(ProvisionError::PrepareSession(e.to_string()));
}
Err(e) => {
tracing::warn!(
session = %session_id,
path = %workspace_path.display(),
"workspace provisioned but session prep failed (best-effort): {e}"
);
}
}
if self.prepare {
let memory_url =
trusty_common::mcp::memory_rpc::resolve_memory_base_url_or_unreachable();
let override_value = trusty_common::palace_override_from_env();
match trusty_common::derive_palace_id(
&workspace_path,
Some(repo_url),
override_value.as_deref(),
) {
Some(palace_id) => {
super::identity_seed::seed_identity_prompt_fact_blocking(
&memory_url,
&palace_id,
);
}
None => {
tracing::debug!(
session = %session_id,
"identity-seed: skipping — no palace could be derived for this workspace"
);
}
}
}
Ok(PreparedWorkspace {
path: workspace_path,
repo_url: repo_url.to_owned(),
branch: git_ref.to_owned(),
})
}
}
fn exclude_worktrees(base_dir: &Path) -> Result<(), ProvisionError> {
crate::core::worktree_naming::ensure_worktrees_gitignored(base_dir).map_err(ProvisionError::Git)
}
fn repo_slug(repo_url: &str) -> String {
let name = repo_url
.trim_end_matches('/')
.rsplit('/')
.next()
.unwrap_or("unknown");
name.trim_end_matches(".git").to_lowercase()
}
mod base_lock;
#[cfg(test)]
mod tests;