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>;
}
pub struct RealGitBackend;
impl GitBackend for RealGitBackend {
fn clone_repo(
&self,
repo_url: &str,
git_ref: &str,
target_dir: &Path,
) -> Result<(), ProvisionError> {
use std::process::Command;
let out = Command::new("git")
.args([
"clone",
"--depth",
"1",
"--branch",
git_ref,
repo_url,
&target_dir.to_string_lossy(),
])
.output()
.map_err(|e| ProvisionError::Git(format!("git clone exec failed: {e}")))?;
if out.status.success() {
Ok(())
} else {
let stderr = String::from_utf8_lossy(&out.stderr);
Err(ProvisionError::Git(format!(
"git clone failed (exit {}): {stderr}",
out.status
)))
}
}
}
pub struct FakeGitBackend {
pub calls: std::sync::Mutex<Vec<(String, String, PathBuf)>>,
}
impl FakeGitBackend {
pub fn new() -> Self {
Self {
calls: std::sync::Mutex::new(Vec::new()),
}
}
}
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(),
));
std::fs::create_dir_all(target_dir)?;
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 workspace_path = project_dir.join(session_id.to_string());
debug!(
session = %session_id,
path = %workspace_path.display(),
repo = %repo_url,
git_ref = %git_ref,
"provisioning workspace"
);
self.git.clone_repo(repo_url, git_ref, &workspace_path)?;
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::default();
match crate::core::session_launch::prepare_session(&fw, &workspace_path) {
Ok(report) => {
info!(
session = %session_id,
deployed = report.deploy.deployed.len(),
path = %workspace_path.display(),
"workspace provisioned and session prepared"
);
}
Err(e) => {
tracing::warn!(
session = %session_id,
path = %workspace_path.display(),
"workspace provisioned but session prep failed (best-effort): {e}"
);
}
}
Ok(PreparedWorkspace {
path: workspace_path,
repo_url: repo_url.to_owned(),
branch: git_ref.to_owned(),
})
}
}
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()
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
fn make_provisioner(root: &TempDir) -> WorkspaceProvisioner<FakeGitBackend> {
WorkspaceProvisioner::without_prepare(FakeGitBackend::new(), root.path().to_owned())
}
#[test]
fn repo_slug_extraction() {
assert_eq!(
repo_slug("https://github.com/owner/trusty-tools"),
"trusty-tools"
);
assert_eq!(
repo_slug("https://github.com/owner/trusty-tools.git"),
"trusty-tools"
);
assert_eq!(repo_slug("git@github.com:owner/my-repo.git"), "my-repo");
}
#[test]
fn provisioner_isolation_path() {
let root = TempDir::new().unwrap();
let prov = make_provisioner(&root);
let id = ManagedSessionId::new();
let ws = prov
.provision(&id, "https://github.com/owner/trusty-tools", "main", "task")
.unwrap();
assert!(ws.path.starts_with(root.path()));
assert!(ws.path.to_string_lossy().contains("trusty-tools"));
assert!(ws.path.to_string_lossy().contains(&id.to_string()));
}
#[test]
fn provisioner_path_not_in_existing_project() {
let root = TempDir::new().unwrap();
let prov = make_provisioner(&root);
let id = ManagedSessionId::new();
let ws = prov
.provision(&id, "https://github.com/owner/myrepo.git", "feat/x", "task")
.unwrap();
assert!(ws.path.starts_with(root.path()));
assert_ne!(&ws.path, root.path());
}
#[test]
fn provisioner_uses_session_id_subdir() {
let root = TempDir::new().unwrap();
let prov = make_provisioner(&root);
let id = ManagedSessionId::new();
let ws = prov
.provision(&id, "https://github.com/owner/repo", "main", "task")
.unwrap();
let leaf = ws.path.file_name().unwrap().to_string_lossy();
assert_eq!(leaf.as_ref(), id.to_string());
}
#[test]
fn provision_in_uses_explicit_project_dir() {
let root = TempDir::new().unwrap();
let prov = make_provisioner(&root);
let id = ManagedSessionId::new();
let project_dir = root.path().join("bobmatnyc").join("trusty-tools");
let ws = prov
.provision_in(
&project_dir,
&id,
"https://github.com/bobmatnyc/trusty-tools",
"main",
"task",
)
.unwrap();
assert_eq!(ws.path, project_dir.join(id.to_string()));
assert!(ws.path.starts_with(&project_dir));
}
#[test]
fn provisioner_records_repo_url_and_branch() {
let root = TempDir::new().unwrap();
let prov = make_provisioner(&root);
let id = ManagedSessionId::new();
let ws = prov
.provision(
&id,
"https://github.com/owner/repo",
"feat/my-branch",
"task",
)
.unwrap();
assert_eq!(ws.repo_url, "https://github.com/owner/repo");
assert_eq!(ws.branch, "feat/my-branch");
}
}