Skip to main content

scud/commands/
salvo.rs

1//! Salvo worktree management for parallel tag execution.
2//!
3//! When `scud swarm --tag <tag>` is invoked, SCUD automatically provisions
4//! a git worktree for that tag, generates a filtered task file, runs the
5//! swarm in isolation, and syncs results back.
6//!
7//! Convention path: `../<project-name>.salvo.<tag>/`
8
9use anyhow::{bail, Result};
10use std::path::{Path, PathBuf};
11use std::process::Command;
12
13use crate::db::Database;
14use crate::formats::serialize_scg;
15use crate::storage::Storage;
16
17/// Resolve or create the worktree for a tag.
18/// Returns the worktree path (which becomes the swarm's working directory).
19pub fn ensure_worktree(
20    project_root: &Path,
21    tag: &str,
22    custom_path: Option<&Path>,
23) -> Result<PathBuf> {
24    let db = Database::new(project_root);
25    db.initialize()?;
26
27    // Check if worktree already exists in database
28    let existing = {
29        let guard = db.connection()?;
30        let conn = guard.as_ref().unwrap();
31        conn.query_row(
32            "SELECT worktree_path FROM salvo_worktrees WHERE tag = ?",
33            [tag],
34            |row| row.get::<_, String>(0),
35        )
36        .ok()
37    };
38
39    if let Some(existing_path) = existing {
40        let wt_path = PathBuf::from(&existing_path);
41        if wt_path.exists() {
42            // Refresh the filtered task file with latest state
43            refresh_filtered_tasks(project_root, &wt_path, tag)?;
44            // Sync agent and spawn definitions
45            sync_scud_subdirs(project_root, &wt_path)?;
46            println!("Using existing salvo worktree at {}", wt_path.display());
47            return Ok(wt_path);
48        }
49        // Path recorded but directory gone - clean up stale record and recreate
50        let guard = db.connection()?;
51        let conn = guard.as_ref().unwrap();
52        conn.execute("DELETE FROM salvo_worktrees WHERE tag = ?", [tag])?;
53    }
54
55    // Determine worktree path
56    let worktree_path = if let Some(p) = custom_path {
57        p.to_path_buf()
58    } else {
59        default_worktree_path(project_root, tag)
60    };
61
62    create_worktree(project_root, tag, &worktree_path)?;
63    Ok(worktree_path)
64}
65
66/// Convention: ../<project-name>.salvo.<tag>/
67fn default_worktree_path(project_root: &Path, tag: &str) -> PathBuf {
68    let project_name = project_root
69        .file_name()
70        .and_then(|n| n.to_str())
71        .unwrap_or("project");
72    let parent = project_root.parent().unwrap_or(project_root);
73    parent.join(format!("{}.salvo.{}", project_name, tag))
74}
75
76/// Create a new worktree for a tag
77fn create_worktree(project_root: &Path, tag: &str, worktree_path: &Path) -> Result<()> {
78    let storage = Storage::new(Some(project_root.to_path_buf()));
79
80    // Verify tag exists
81    let phases = storage.load_tasks()?;
82    if !phases.contains_key(tag) {
83        bail!(
84            "Tag '{}' not found. Available tags: {:?}",
85            tag,
86            phases.keys().collect::<Vec<_>>()
87        );
88    }
89
90    // Create git worktree with branch salvo/<tag>
91    let branch_name = format!("salvo/{}", tag);
92    let output = Command::new("git")
93        .args(["worktree", "add", "-b", &branch_name])
94        .arg(worktree_path)
95        .current_dir(project_root)
96        .output()?;
97
98    if !output.status.success() {
99        // Branch may already exist, try attaching to it
100        let output = Command::new("git")
101            .args(["worktree", "add"])
102            .arg(worktree_path)
103            .arg(&branch_name)
104            .current_dir(project_root)
105            .output()?;
106
107        if !output.status.success() {
108            bail!(
109                "Failed to create worktree: {}",
110                String::from_utf8_lossy(&output.stderr)
111            );
112        }
113    }
114
115    // Bootstrap .scud in worktree
116    let worktree_scud = worktree_path.join(".scud");
117    std::fs::create_dir_all(worktree_scud.join("tasks"))?;
118    std::fs::create_dir_all(worktree_scud.join("swarm"))?;
119
120    // Generate filtered task file
121    generate_filtered_tasks(project_root, worktree_path, tag)?;
122
123    // Set active tag
124    std::fs::write(worktree_scud.join("active-tag"), tag)?;
125
126    // Copy config
127    let main_config = project_root.join(".scud").join("config.toml");
128    if main_config.exists() {
129        std::fs::copy(&main_config, worktree_scud.join("config.toml"))?;
130    }
131
132    // Copy guidance files if they exist
133    let main_guidance = project_root.join(".scud").join("guidance");
134    if main_guidance.exists() {
135        let wt_guidance = worktree_scud.join("guidance");
136        std::fs::create_dir_all(&wt_guidance)?;
137        for entry in std::fs::read_dir(&main_guidance)? {
138            let entry = entry?;
139            if entry.path().is_file() {
140                std::fs::copy(entry.path(), wt_guidance.join(entry.file_name()))?;
141            }
142        }
143    }
144
145    // Copy agent definitions if they exist
146    let main_agents = project_root.join(".scud").join("agents");
147    if main_agents.exists() {
148        let wt_agents = worktree_scud.join("agents");
149        std::fs::create_dir_all(&wt_agents)?;
150        for entry in std::fs::read_dir(&main_agents)? {
151            let entry = entry?;
152            if entry.path().is_file() {
153                std::fs::copy(entry.path(), wt_agents.join(entry.file_name()))?;
154            }
155        }
156    }
157
158    // Copy spawn agent definitions if they exist
159    let main_spawn = project_root.join(".scud").join("spawn");
160    if main_spawn.exists() {
161        let wt_spawn = worktree_scud.join("spawn");
162        std::fs::create_dir_all(&wt_spawn)?;
163        for entry in std::fs::read_dir(&main_spawn)? {
164            let entry = entry?;
165            if entry.path().is_file() {
166                std::fs::copy(entry.path(), wt_spawn.join(entry.file_name()))?;
167            }
168        }
169    }
170
171    // Record in database (use main project's database, not worktree's)
172    let db = Database::new(project_root);
173    let guard = db.connection()?;
174    let conn = guard.as_ref().unwrap();
175    conn.execute(
176        "INSERT OR REPLACE INTO salvo_worktrees
177         (tag, worktree_path, branch_name, created_at)
178         VALUES (?1, ?2, ?3, datetime('now'))",
179        [tag, worktree_path.to_str().unwrap_or(""), &branch_name],
180    )?;
181
182    println!(
183        "Created salvo worktree for '{}' at {}",
184        tag,
185        worktree_path.display()
186    );
187    println!("Branch: {}", branch_name);
188
189    Ok(())
190}
191
192/// Generate filtered task file: full detail for target tag, collapsed stubs for others
193fn generate_filtered_tasks(
194    project_root: &Path,
195    worktree_path: &Path,
196    target_tag: &str,
197) -> Result<()> {
198    let storage = Storage::new(Some(project_root.to_path_buf()));
199    let phases = storage.load_tasks()?;
200
201    let worktree_tasks = worktree_path.join(".scud").join("tasks").join("tasks.scg");
202    let mut output = String::new();
203
204    // Target phase gets full serialization
205    if let Some(phase) = phases.get(target_tag) {
206        output.push_str(&serialize_scg(phase));
207    }
208
209    // Other phases shown as collapsed stubs
210    for (tag, phase) in &phases {
211        if tag != target_tag {
212            if !output.is_empty() {
213                output.push_str("\n---\n\n");
214            }
215            output.push_str("# SCUD Graph v1\n");
216            output.push_str(&format!("# Phase: {}\n", tag));
217            output.push_str(&format!(
218                "# [Collapsed - {} tasks, work in main branch]\n\n",
219                phase.tasks.len()
220            ));
221            output.push_str(&format!("@meta {{\n  name {}\n}}\n", phase.name));
222            output.push_str("\n@nodes\n");
223            output.push_str("# Tasks hidden. Run `scud salvo sync` to merge changes.\n");
224        }
225    }
226
227    std::fs::write(&worktree_tasks, output)?;
228    Ok(())
229}
230
231/// Sync .scud subdirectories (agents, spawn) from main to worktree
232fn sync_scud_subdirs(project_root: &Path, worktree_path: &Path) -> Result<()> {
233    for dir_name in &["agents", "spawn"] {
234        let main_dir = project_root.join(".scud").join(dir_name);
235        if main_dir.exists() {
236            let wt_dir = worktree_path.join(".scud").join(dir_name);
237            std::fs::create_dir_all(&wt_dir)?;
238            for entry in std::fs::read_dir(&main_dir)? {
239                let entry = entry?;
240                if entry.path().is_file() {
241                    std::fs::copy(entry.path(), wt_dir.join(entry.file_name()))?;
242                }
243            }
244        }
245    }
246    Ok(())
247}
248
249/// Refresh filtered tasks (update worktree with latest from main)
250fn refresh_filtered_tasks(project_root: &Path, worktree_path: &Path, tag: &str) -> Result<()> {
251    let worktree_storage = Storage::new(Some(worktree_path.to_path_buf()));
252    let worktree_phases = worktree_storage.load_tasks().ok();
253
254    let main_storage = Storage::new(Some(project_root.to_path_buf()));
255    let main_phases = main_storage.load_tasks()?;
256
257    let worktree_tasks = worktree_path.join(".scud").join("tasks").join("tasks.scg");
258    let mut output = String::new();
259
260    // For target tag: prefer worktree version (has in-progress status changes)
261    // Fall back to main if worktree doesn't have it yet
262    if let Some(phase) = worktree_phases
263        .as_ref()
264        .and_then(|p| p.get(tag))
265        .or_else(|| main_phases.get(tag))
266    {
267        output.push_str(&serialize_scg(phase));
268    }
269
270    // Collapsed stubs for other tags (always from main)
271    for (other_tag, phase) in &main_phases {
272        if other_tag != tag {
273            if !output.is_empty() {
274                output.push_str("\n---\n\n");
275            }
276            output.push_str("# SCUD Graph v1\n");
277            output.push_str(&format!("# Phase: {}\n", other_tag));
278            output.push_str(&format!("# [Collapsed - {} tasks]\n\n", phase.tasks.len()));
279            output.push_str(&format!("@meta {{\n  name {}\n}}\n", phase.name));
280            output.push_str("\n@nodes\n");
281            output.push_str("# Tasks hidden. Run `scud salvo sync` to merge changes.\n");
282        }
283    }
284
285    std::fs::write(&worktree_tasks, output)?;
286    Ok(())
287}
288
289/// Sync task status changes from worktree back to main branch's tasks.scg
290pub fn sync_to_main(project_root: &Path, worktree_path: &Path, tag: &str) -> Result<()> {
291    let worktree_storage = Storage::new(Some(worktree_path.to_path_buf()));
292    let worktree_phases = worktree_storage.load_tasks()?;
293
294    let worktree_phase = worktree_phases
295        .get(tag)
296        .ok_or_else(|| anyhow::anyhow!("Tag '{}' not found in worktree", tag))?;
297
298    let main_storage = Storage::new(Some(project_root.to_path_buf()));
299
300    // Use update_group for atomic read-modify-write on main
301    main_storage.update_group(tag, worktree_phase)?;
302
303    // Record sync time
304    let db = Database::new(project_root);
305    let guard = db.connection()?;
306    let conn = guard.as_ref().unwrap();
307    conn.execute(
308        "UPDATE salvo_worktrees SET last_sync_at = datetime('now') WHERE tag = ?",
309        [tag],
310    )?;
311
312    println!("Synced salvo '{}' back to main", tag);
313    Ok(())
314}
315
316/// List all salvo worktrees
317pub fn list_worktrees(project_root: &Path) -> Result<()> {
318    let db = Database::new(project_root);
319    db.initialize()?;
320    let guard = db.connection()?;
321    let conn = guard.as_ref().unwrap();
322
323    let mut stmt = conn.prepare(
324        "SELECT tag, worktree_path, branch_name, created_at, last_sync_at
325         FROM salvo_worktrees ORDER BY created_at DESC",
326    )?;
327
328    let worktrees: Vec<(String, String, String, String, Option<String>)> = stmt
329        .query_map([], |row| {
330            Ok((
331                row.get::<_, String>(0)?,
332                row.get::<_, String>(1)?,
333                row.get::<_, String>(2)?,
334                row.get::<_, String>(3)?,
335                row.get::<_, Option<String>>(4)?,
336            ))
337        })?
338        .collect::<Result<Vec<_>, _>>()?;
339
340    if worktrees.is_empty() {
341        println!("No salvo worktrees found.");
342        println!("Create one by running: scud swarm --tag <tag>");
343        return Ok(());
344    }
345
346    println!("Salvo Worktrees:");
347    println!("{:<15} {:<40} {:<20} Last Sync", "Tag", "Path", "Branch");
348    println!("{}", "-".repeat(90));
349
350    for (tag, path, branch, _created, synced) in &worktrees {
351        let sync_display = synced.as_deref().unwrap_or("never");
352        let exists = Path::new(path).exists();
353        let status = if exists { "" } else { " (missing)" };
354        println!(
355            "{:<15} {:<40} {:<20} {}{}",
356            tag, path, branch, sync_display, status
357        );
358    }
359
360    Ok(())
361}
362
363/// Remove a salvo worktree and its git branch
364pub fn remove_worktree(project_root: &Path, tag: &str) -> Result<()> {
365    let db = Database::new(project_root);
366    db.initialize()?;
367    let guard = db.connection()?;
368    let conn = guard.as_ref().unwrap();
369
370    let row: Option<(String, String)> = conn
371        .query_row(
372            "SELECT worktree_path, branch_name FROM salvo_worktrees WHERE tag = ?",
373            [tag],
374            |row| Ok((row.get(0)?, row.get(1)?)),
375        )
376        .ok();
377
378    if let Some((path, _branch)) = row {
379        // Remove git worktree (--force needed because .scud/ files are untracked)
380        let _ = Command::new("git")
381            .args(["worktree", "remove", "--force", &path])
382            .current_dir(project_root)
383            .output();
384    }
385
386    conn.execute("DELETE FROM salvo_worktrees WHERE tag = ?", [tag])?;
387    println!("Removed salvo worktree for '{}'", tag);
388    Ok(())
389}