1use 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
17pub 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 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_filtered_tasks(project_root, &wt_path, tag)?;
44 sync_scud_subdirs(project_root, &wt_path)?;
46 println!("Using existing salvo worktree at {}", wt_path.display());
47 return Ok(wt_path);
48 }
49 let guard = db.connection()?;
51 let conn = guard.as_ref().unwrap();
52 conn.execute("DELETE FROM salvo_worktrees WHERE tag = ?", [tag])?;
53 }
54
55 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
66fn 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
76fn create_worktree(project_root: &Path, tag: &str, worktree_path: &Path) -> Result<()> {
78 let storage = Storage::new(Some(project_root.to_path_buf()));
79
80 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 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 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 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_tasks(project_root, worktree_path, tag)?;
122
123 std::fs::write(worktree_scud.join("active-tag"), tag)?;
125
126 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 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 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 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 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
192fn 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 if let Some(phase) = phases.get(target_tag) {
206 output.push_str(&serialize_scg(phase));
207 }
208
209 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
231fn 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
249fn 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 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 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
289pub 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 main_storage.update_group(tag, worktree_phase)?;
302
303 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
316pub 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
363pub 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 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}