use std::path::{Path, PathBuf};
use std::process::Command;
use crate::error::{CruiseError, Result};
use crate::session::{SessionManager, SessionState};
pub fn validate_repo_spec(spec: &str) -> Result<()> {
fn valid_part(part: &str) -> bool {
!part.is_empty()
&& !part.starts_with('-')
&& part.chars().any(|c| c != '.')
&& part
.chars()
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.'))
}
let valid = spec
.split_once('/')
.is_some_and(|(owner, name)| valid_part(owner) && valid_part(name));
if valid {
Ok(())
} else {
Err(CruiseError::Other(format!(
"invalid repository '{spec}': expected <owner>/<repository>"
)))
}
}
pub fn clone_repo(repo: &str, clone_path: &Path) -> Result<()> {
if let Some(parent) = clone_path.parent() {
std::fs::create_dir_all(parent)?;
}
let output = Command::new("gh")
.args(["repo", "clone", repo])
.arg(clone_path)
.output()
.map_err(|e| CruiseError::Other(format!("failed to run gh repo clone: {e}")))?;
if !output.status.success() {
let _ = std::fs::remove_dir_all(clone_path);
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(CruiseError::Other(format!(
"gh repo clone {} failed: {}",
repo,
stderr.trim()
)));
}
Ok(())
}
#[must_use]
pub fn persistent_config_path(
source: &crate::resolver::ConfigSource,
clone_path: &Path,
) -> Option<PathBuf> {
source
.path()
.filter(|p| !p.starts_with(clone_path))
.cloned()
}
pub fn ensure_session_clone(
manager: &SessionManager,
session: &SessionState,
) -> Result<(PathBuf, bool)> {
let repo = session.repo.as_deref().ok_or_else(|| {
CruiseError::Other(format!("session {} has no repository to clone", session.id))
})?;
let clone_path = manager.clones_dir().join(&session.id);
if clone_path.is_dir() {
if clone_path.join(".git").exists() {
return Ok((clone_path, true));
}
std::fs::remove_dir_all(&clone_path)?;
}
clone_repo(repo, &clone_path)?;
Ok((clone_path, false))
}
pub fn ensure_repo_session_workspace(
manager: &SessionManager,
session: &mut SessionState,
) -> Result<bool> {
if session.repo.is_none() {
return Ok(false);
}
let (clone_path, reused) = ensure_session_clone(manager, session)?;
session.base_dir = clone_path;
Ok(!reused)
}
pub fn cleanup_session_workspace(manager: &SessionManager, session: &SessionState) {
if let Some(ctx) = session.worktree_context() {
if ctx.original_dir.is_dir()
&& let Err(e) = crate::worktree::cleanup_worktree(&ctx)
{
eprintln!(
"warning: failed to remove worktree for {}: {}",
session.id, e
);
}
if ctx.path.exists()
&& let Err(e) = std::fs::remove_dir_all(&ctx.path)
{
eprintln!(
"warning: failed to remove worktree directory for {}: {}",
session.id, e
);
}
}
let clone_path = manager.clones_dir().join(&session.id);
if clone_path.exists()
&& let Err(e) = std::fs::remove_dir_all(&clone_path)
{
eprintln!("warning: failed to remove clone for {}: {}", session.id, e);
}
}
pub fn cleanup_after_approval(manager: &SessionManager, session: &mut SessionState) {
if session.repo.is_none() {
return;
}
cleanup_session_workspace(manager, session);
session.worktree_path = None;
}
#[cfg(test)]
mod tests {
use super::*;
use crate::test_support::make_session;
use tempfile::TempDir;
#[test]
fn test_validate_repo_spec_accepts_owner_slash_repo() {
for spec in [
"owner/repo",
"smart-crab.ai/some_repo",
"a/b",
"Owner123/repo.name",
] {
assert!(
validate_repo_spec(spec).is_ok(),
"expected '{spec}' to be valid"
);
}
}
#[test]
fn test_validate_repo_spec_rejects_invalid_specs() {
for spec in [
"",
"owner",
"/repo",
"owner/",
"owner/repo/extra",
"owner repo/x",
"owner/repo name",
"https://github.com/owner/repo",
"-u/repo",
"owner/-repo",
"./repo",
"owner/..",
"../repo",
] {
assert!(
validate_repo_spec(spec).is_err(),
"expected '{spec}' to be rejected"
);
}
}
#[test]
fn test_ensure_session_clone_requires_repo() {
let tmp = TempDir::new().unwrap_or_else(|e| panic!("{e:?}"));
let manager = SessionManager::new(tmp.path().to_path_buf());
let session = make_session("20260607100000", tmp.path());
let err = ensure_session_clone(&manager, &session)
.map_or_else(|e| e, |v| panic!("expected Err, got Ok({v:?})"));
assert!(err.to_string().contains("no repository"));
}
#[test]
fn test_ensure_session_clone_reuses_existing_directory() {
let tmp = TempDir::new().unwrap_or_else(|e| panic!("{e:?}"));
let manager = SessionManager::new(tmp.path().to_path_buf());
let mut session = make_session("20260607100001", tmp.path());
session.repo = Some("owner/repo".to_string());
let clone_path = manager.clones_dir().join(&session.id);
std::fs::create_dir_all(clone_path.join(".git")).unwrap_or_else(|e| panic!("{e:?}"));
let (path, reused) =
ensure_session_clone(&manager, &session).unwrap_or_else(|e| panic!("{e:?}"));
assert!(reused);
assert_eq!(path, clone_path);
}
#[test]
fn test_cleanup_session_workspace_removes_clone_dir() {
let tmp = TempDir::new().unwrap_or_else(|e| panic!("{e:?}"));
let manager = SessionManager::new(tmp.path().to_path_buf());
let mut session = make_session("20260607100002", tmp.path());
session.repo = Some("owner/repo".to_string());
let clone_path = manager.clones_dir().join(&session.id);
std::fs::create_dir_all(&clone_path).unwrap_or_else(|e| panic!("{e:?}"));
std::fs::write(clone_path.join("file.txt"), "x").unwrap_or_else(|e| panic!("{e:?}"));
cleanup_session_workspace(&manager, &session);
assert!(!clone_path.exists());
}
#[test]
fn test_cleanup_after_approval_noop_without_repo() {
let tmp = TempDir::new().unwrap_or_else(|e| panic!("{e:?}"));
let manager = SessionManager::new(tmp.path().to_path_buf());
let mut session = make_session("20260607100003", tmp.path());
session.worktree_path = Some(tmp.path().join("wt"));
cleanup_after_approval(&manager, &mut session);
assert!(session.worktree_path.is_some());
}
#[test]
fn test_cleanup_after_approval_removes_clone_and_clears_worktree_path() {
let tmp = TempDir::new().unwrap_or_else(|e| panic!("{e:?}"));
let manager = SessionManager::new(tmp.path().to_path_buf());
let mut session = make_session("20260607100004", tmp.path());
session.repo = Some("owner/repo".to_string());
session.worktree_branch = Some("cruise/20260607100004-task".to_string());
let clone_path = manager.clones_dir().join(&session.id);
std::fs::create_dir_all(&clone_path).unwrap_or_else(|e| panic!("{e:?}"));
cleanup_after_approval(&manager, &mut session);
assert!(!clone_path.exists());
assert!(session.worktree_path.is_none());
assert_eq!(
session.worktree_branch.as_deref(),
Some("cruise/20260607100004-task")
);
}
}