use anyhow::{bail, Result};
use std::path::{Path, PathBuf};
use std::process::Command;
use crate::db::Database;
use crate::formats::serialize_scg;
use crate::storage::Storage;
pub fn ensure_worktree(
project_root: &Path,
tag: &str,
custom_path: Option<&Path>,
) -> Result<PathBuf> {
let db = Database::new(project_root);
db.initialize()?;
let existing = {
let guard = db.connection()?;
let conn = guard.as_ref().unwrap();
conn.query_row(
"SELECT worktree_path FROM salvo_worktrees WHERE tag = ?",
[tag],
|row| row.get::<_, String>(0),
)
.ok()
};
if let Some(existing_path) = existing {
let wt_path = PathBuf::from(&existing_path);
if wt_path.exists() {
refresh_filtered_tasks(project_root, &wt_path, tag)?;
sync_scud_subdirs(project_root, &wt_path)?;
println!("Using existing salvo worktree at {}", wt_path.display());
return Ok(wt_path);
}
let guard = db.connection()?;
let conn = guard.as_ref().unwrap();
conn.execute("DELETE FROM salvo_worktrees WHERE tag = ?", [tag])?;
}
let worktree_path = if let Some(p) = custom_path {
p.to_path_buf()
} else {
default_worktree_path(project_root, tag)
};
create_worktree(project_root, tag, &worktree_path)?;
Ok(worktree_path)
}
fn default_worktree_path(project_root: &Path, tag: &str) -> PathBuf {
let project_name = project_root
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("project");
let parent = project_root.parent().unwrap_or(project_root);
parent.join(format!("{}.salvo.{}", project_name, tag))
}
fn create_worktree(project_root: &Path, tag: &str, worktree_path: &Path) -> Result<()> {
let storage = Storage::new(Some(project_root.to_path_buf()));
let phases = storage.load_tasks()?;
if !phases.contains_key(tag) {
bail!(
"Tag '{}' not found. Available tags: {:?}",
tag,
phases.keys().collect::<Vec<_>>()
);
}
let branch_name = format!("salvo/{}", tag);
let output = Command::new("git")
.args(["worktree", "add", "-b", &branch_name])
.arg(worktree_path)
.current_dir(project_root)
.output()?;
if !output.status.success() {
let output = Command::new("git")
.args(["worktree", "add"])
.arg(worktree_path)
.arg(&branch_name)
.current_dir(project_root)
.output()?;
if !output.status.success() {
bail!(
"Failed to create worktree: {}",
String::from_utf8_lossy(&output.stderr)
);
}
}
let worktree_scud = worktree_path.join(".scud");
std::fs::create_dir_all(worktree_scud.join("tasks"))?;
std::fs::create_dir_all(worktree_scud.join("swarm"))?;
generate_filtered_tasks(project_root, worktree_path, tag)?;
std::fs::write(worktree_scud.join("active-tag"), tag)?;
let main_config = project_root.join(".scud").join("config.toml");
if main_config.exists() {
std::fs::copy(&main_config, worktree_scud.join("config.toml"))?;
}
let main_guidance = project_root.join(".scud").join("guidance");
if main_guidance.exists() {
let wt_guidance = worktree_scud.join("guidance");
std::fs::create_dir_all(&wt_guidance)?;
for entry in std::fs::read_dir(&main_guidance)? {
let entry = entry?;
if entry.path().is_file() {
std::fs::copy(entry.path(), wt_guidance.join(entry.file_name()))?;
}
}
}
let main_agents = project_root.join(".scud").join("agents");
if main_agents.exists() {
let wt_agents = worktree_scud.join("agents");
std::fs::create_dir_all(&wt_agents)?;
for entry in std::fs::read_dir(&main_agents)? {
let entry = entry?;
if entry.path().is_file() {
std::fs::copy(entry.path(), wt_agents.join(entry.file_name()))?;
}
}
}
let main_spawn = project_root.join(".scud").join("spawn");
if main_spawn.exists() {
let wt_spawn = worktree_scud.join("spawn");
std::fs::create_dir_all(&wt_spawn)?;
for entry in std::fs::read_dir(&main_spawn)? {
let entry = entry?;
if entry.path().is_file() {
std::fs::copy(entry.path(), wt_spawn.join(entry.file_name()))?;
}
}
}
let db = Database::new(project_root);
let guard = db.connection()?;
let conn = guard.as_ref().unwrap();
conn.execute(
"INSERT OR REPLACE INTO salvo_worktrees
(tag, worktree_path, branch_name, created_at)
VALUES (?1, ?2, ?3, datetime('now'))",
[tag, worktree_path.to_str().unwrap_or(""), &branch_name],
)?;
println!(
"Created salvo worktree for '{}' at {}",
tag,
worktree_path.display()
);
println!("Branch: {}", branch_name);
Ok(())
}
fn generate_filtered_tasks(
project_root: &Path,
worktree_path: &Path,
target_tag: &str,
) -> Result<()> {
let storage = Storage::new(Some(project_root.to_path_buf()));
let phases = storage.load_tasks()?;
let worktree_tasks = worktree_path.join(".scud").join("tasks").join("tasks.scg");
let mut output = String::new();
if let Some(phase) = phases.get(target_tag) {
output.push_str(&serialize_scg(phase));
}
for (tag, phase) in &phases {
if tag != target_tag {
if !output.is_empty() {
output.push_str("\n---\n\n");
}
output.push_str("# SCUD Graph v1\n");
output.push_str(&format!("# Phase: {}\n", tag));
output.push_str(&format!(
"# [Collapsed - {} tasks, work in main branch]\n\n",
phase.tasks.len()
));
output.push_str(&format!("@meta {{\n name {}\n}}\n", phase.name));
output.push_str("\n@nodes\n");
output.push_str("# Tasks hidden. Run `scud salvo sync` to merge changes.\n");
}
}
std::fs::write(&worktree_tasks, output)?;
Ok(())
}
fn sync_scud_subdirs(project_root: &Path, worktree_path: &Path) -> Result<()> {
for dir_name in &["agents", "spawn"] {
let main_dir = project_root.join(".scud").join(dir_name);
if main_dir.exists() {
let wt_dir = worktree_path.join(".scud").join(dir_name);
std::fs::create_dir_all(&wt_dir)?;
for entry in std::fs::read_dir(&main_dir)? {
let entry = entry?;
if entry.path().is_file() {
std::fs::copy(entry.path(), wt_dir.join(entry.file_name()))?;
}
}
}
}
Ok(())
}
fn refresh_filtered_tasks(project_root: &Path, worktree_path: &Path, tag: &str) -> Result<()> {
let worktree_storage = Storage::new(Some(worktree_path.to_path_buf()));
let worktree_phases = worktree_storage.load_tasks().ok();
let main_storage = Storage::new(Some(project_root.to_path_buf()));
let main_phases = main_storage.load_tasks()?;
let worktree_tasks = worktree_path.join(".scud").join("tasks").join("tasks.scg");
let mut output = String::new();
if let Some(phase) = worktree_phases
.as_ref()
.and_then(|p| p.get(tag))
.or_else(|| main_phases.get(tag))
{
output.push_str(&serialize_scg(phase));
}
for (other_tag, phase) in &main_phases {
if other_tag != tag {
if !output.is_empty() {
output.push_str("\n---\n\n");
}
output.push_str("# SCUD Graph v1\n");
output.push_str(&format!("# Phase: {}\n", other_tag));
output.push_str(&format!("# [Collapsed - {} tasks]\n\n", phase.tasks.len()));
output.push_str(&format!("@meta {{\n name {}\n}}\n", phase.name));
output.push_str("\n@nodes\n");
output.push_str("# Tasks hidden. Run `scud salvo sync` to merge changes.\n");
}
}
std::fs::write(&worktree_tasks, output)?;
Ok(())
}
pub fn sync_to_main(project_root: &Path, worktree_path: &Path, tag: &str) -> Result<()> {
let worktree_storage = Storage::new(Some(worktree_path.to_path_buf()));
let worktree_phases = worktree_storage.load_tasks()?;
let worktree_phase = worktree_phases
.get(tag)
.ok_or_else(|| anyhow::anyhow!("Tag '{}' not found in worktree", tag))?;
let main_storage = Storage::new(Some(project_root.to_path_buf()));
main_storage.update_group(tag, worktree_phase)?;
let db = Database::new(project_root);
let guard = db.connection()?;
let conn = guard.as_ref().unwrap();
conn.execute(
"UPDATE salvo_worktrees SET last_sync_at = datetime('now') WHERE tag = ?",
[tag],
)?;
println!("Synced salvo '{}' back to main", tag);
Ok(())
}
pub fn list_worktrees(project_root: &Path) -> Result<()> {
let db = Database::new(project_root);
db.initialize()?;
let guard = db.connection()?;
let conn = guard.as_ref().unwrap();
let mut stmt = conn.prepare(
"SELECT tag, worktree_path, branch_name, created_at, last_sync_at
FROM salvo_worktrees ORDER BY created_at DESC",
)?;
let worktrees: Vec<(String, String, String, String, Option<String>)> = stmt
.query_map([], |row| {
Ok((
row.get::<_, String>(0)?,
row.get::<_, String>(1)?,
row.get::<_, String>(2)?,
row.get::<_, String>(3)?,
row.get::<_, Option<String>>(4)?,
))
})?
.collect::<Result<Vec<_>, _>>()?;
if worktrees.is_empty() {
println!("No salvo worktrees found.");
println!("Create one by running: scud swarm --tag <tag>");
return Ok(());
}
println!("Salvo Worktrees:");
println!("{:<15} {:<40} {:<20} Last Sync", "Tag", "Path", "Branch");
println!("{}", "-".repeat(90));
for (tag, path, branch, _created, synced) in &worktrees {
let sync_display = synced.as_deref().unwrap_or("never");
let exists = Path::new(path).exists();
let status = if exists { "" } else { " (missing)" };
println!(
"{:<15} {:<40} {:<20} {}{}",
tag, path, branch, sync_display, status
);
}
Ok(())
}
pub fn remove_worktree(project_root: &Path, tag: &str) -> Result<()> {
let db = Database::new(project_root);
db.initialize()?;
let guard = db.connection()?;
let conn = guard.as_ref().unwrap();
let row: Option<(String, String)> = conn
.query_row(
"SELECT worktree_path, branch_name FROM salvo_worktrees WHERE tag = ?",
[tag],
|row| Ok((row.get(0)?, row.get(1)?)),
)
.ok();
if let Some((path, _branch)) = row {
let _ = Command::new("git")
.args(["worktree", "remove", "--force", &path])
.current_dir(project_root)
.output();
}
conn.execute("DELETE FROM salvo_worktrees WHERE tag = ?", [tag])?;
println!("Removed salvo worktree for '{}'", tag);
Ok(())
}