Skip to main content

battlecommand_forge/
workspace.rs

1//! Git workspace management for isolated mission execution.
2//!
3//! Each mission gets an isolated git repo in `.battlecommand/workspaces/<id>/`.
4//! Per-subtask commits provide rollback safety and audit trail.
5
6use anyhow::{Context, Result};
7use std::fs;
8use std::path::{Path, PathBuf};
9use std::process::Command;
10
11const WORKSPACES_DIR: &str = ".battlecommand/workspaces";
12
13/// A managed workspace with its own git repo.
14pub struct Workspace {
15    pub path: PathBuf,
16    pub mission_id: String,
17}
18
19impl Workspace {
20    /// Create a new isolated workspace for a mission.
21    pub fn create(mission_id: &str) -> Result<Self> {
22        let path = PathBuf::from(WORKSPACES_DIR).join(mission_id);
23        fs::create_dir_all(&path)
24            .with_context(|| format!("Failed to create workspace at {}", path.display()))?;
25
26        // Initialize git repo
27        let output = Command::new("git")
28            .args(["init", "--quiet"])
29            .current_dir(&path)
30            .output();
31
32        match output {
33            Ok(o) if o.status.success() => {}
34            Ok(o) => {
35                let stderr = String::from_utf8_lossy(&o.stderr);
36                eprintln!("   git init warning: {}", stderr.trim());
37            }
38            Err(e) => {
39                eprintln!("   git not available: {}", e);
40                // Continue without git — workspace still works for file storage
41            }
42        }
43
44        Ok(Self {
45            path,
46            mission_id: mission_id.to_string(),
47        })
48    }
49
50    /// Open an existing workspace.
51    pub fn open(mission_id: &str) -> Result<Self> {
52        let path = PathBuf::from(WORKSPACES_DIR).join(mission_id);
53        if !path.exists() {
54            anyhow::bail!("Workspace {} does not exist", mission_id);
55        }
56        Ok(Self {
57            path,
58            mission_id: mission_id.to_string(),
59        })
60    }
61
62    /// Commit all current changes with a message.
63    pub fn commit(&self, message: &str) -> Result<String> {
64        // Stage all files
65        run_git(&self.path, &["add", "-A"])?;
66
67        // Check if there are changes to commit
68        let status = run_git(&self.path, &["status", "--porcelain"])?;
69        if status.trim().is_empty() {
70            return Ok("no changes".to_string());
71        }
72
73        // Commit
74        run_git(&self.path, &["commit", "-m", message, "--allow-empty"])?;
75
76        // Get commit hash
77        let hash = run_git(&self.path, &["rev-parse", "--short", "HEAD"])?;
78        Ok(hash.trim().to_string())
79    }
80
81    /// Rollback to a specific commit.
82    pub fn rollback(&self, commit: &str) -> Result<()> {
83        run_git(&self.path, &["checkout", commit, "--", "."])?;
84        Ok(())
85    }
86
87    /// Get the git log (last N entries).
88    pub fn log(&self, count: usize) -> Result<String> {
89        let n = format!("-{}", count);
90        run_git(&self.path, &["log", "--oneline", &n])
91    }
92
93    /// Copy all files from the workspace to an output directory.
94    pub fn export_to(&self, output_dir: &Path) -> Result<()> {
95        fs::create_dir_all(output_dir)?;
96        copy_dir_contents(&self.path, output_dir)?;
97        Ok(())
98    }
99}
100
101/// Generate a mission ID from a prompt.
102pub fn mission_id_from_prompt(prompt: &str) -> String {
103    let slug: String = prompt
104        .to_lowercase()
105        .chars()
106        .map(|c| if c.is_alphanumeric() { c } else { '_' })
107        .collect::<String>()
108        .chars()
109        .take(30)
110        .collect();
111
112    let timestamp = chrono::Utc::now().format("%Y%m%d_%H%M%S");
113    format!("{}_{}", slug.trim_matches('_'), timestamp)
114}
115
116/// List all workspaces.
117pub fn list_workspaces() -> Result<Vec<String>> {
118    let dir = Path::new(WORKSPACES_DIR);
119    if !dir.exists() {
120        return Ok(vec![]);
121    }
122
123    let mut workspaces = Vec::new();
124    for entry in fs::read_dir(dir)? {
125        let entry = entry?;
126        if entry.path().is_dir() {
127            workspaces.push(entry.file_name().to_string_lossy().to_string());
128        }
129    }
130
131    workspaces.sort();
132    Ok(workspaces)
133}
134
135fn run_git(cwd: &Path, args: &[&str]) -> Result<String> {
136    let output = Command::new("git")
137        .args(args)
138        .current_dir(cwd)
139        .output()
140        .with_context(|| format!("Failed to run git {:?}", args))?;
141
142    if !output.status.success() {
143        let stderr = String::from_utf8_lossy(&output.stderr);
144        anyhow::bail!("git {:?} failed: {}", args, stderr.trim());
145    }
146
147    Ok(String::from_utf8_lossy(&output.stdout).to_string())
148}
149
150/// Clone a git repository (shallow) to a target directory.
151pub fn clone_repo(url: &str, target: &Path) -> Result<()> {
152    println!("[REPO] Cloning {} ...", url);
153    let output = Command::new("git")
154        .args(["clone", "--depth", "1", url])
155        .arg(target)
156        .output()
157        .context("Failed to run git clone")?;
158    if !output.status.success() {
159        let stderr = String::from_utf8_lossy(&output.stderr);
160        anyhow::bail!("git clone failed: {}", stderr.trim());
161    }
162    println!("[REPO] Cloned to {}", target.display());
163    Ok(())
164}
165
166pub(crate) fn copy_dir_contents(src: &Path, dst: &Path) -> Result<()> {
167    for entry in fs::read_dir(src)? {
168        let entry = entry?;
169        let src_path = entry.path();
170        let dst_path = dst.join(entry.file_name());
171
172        // Skip .git directory
173        if src_path.file_name().map(|n| n == ".git").unwrap_or(false) {
174            continue;
175        }
176
177        if src_path.is_dir() {
178            fs::create_dir_all(&dst_path)?;
179            copy_dir_contents(&src_path, &dst_path)?;
180        } else {
181            fs::copy(&src_path, &dst_path)?;
182        }
183    }
184    Ok(())
185}
186
187#[cfg(test)]
188mod tests {
189    use super::*;
190
191    #[test]
192    fn test_mission_id_from_prompt() {
193        let id = mission_id_from_prompt("Build a FastAPI auth endpoint");
194        assert!(id.starts_with("build_a_fastapi_auth_endpoint_"));
195        assert!(id.len() > 30); // has timestamp
196    }
197
198    #[test]
199    fn test_list_workspaces_empty() {
200        // Should not panic if directory doesn't exist
201        let result = list_workspaces();
202        assert!(result.is_ok());
203    }
204}