1use crate::agent::Agent;
2use crate::attach::get_current_branch;
3use crate::config::Config;
4use crate::error::{Result, WagnerError};
5use crate::model::{RepoSource, Task, TaskRepo, TrackedPane};
6use crate::plugins::builtin_plugins;
7use crate::store::Store;
8use crate::terminal::{PaneHandle, SessionHandle, Terminal, session_name_for_task};
9use chrono::Utc;
10use std::path::{Path, PathBuf};
11use std::process::Command;
12use tracing::{debug, warn};
13use uuid::Uuid;
14
15pub fn add_pane_shared(
22 terminal: &dyn Terminal,
23 store: &Store,
24 task: &mut Task,
25 repo: &TaskRepo,
26 engine_type: crate::model::Engine,
27 pane_name: Option<&str>,
28) -> Result<TrackedPane> {
29 use crate::model::{Engine, PENDING_DISCOVERY};
30
31 let task_name = &task.name;
32 let session = SessionHandle(session_name_for_task(task_name));
33
34 let pane_cwd = &task.path;
35
36 let created_session = if !terminal.session_exists(task_name)? {
37 terminal.create_session(task_name, pane_cwd)?;
38 true
39 } else {
40 false
41 };
42
43 let pane = if created_session {
44 let panes = terminal.list_panes(&session)?;
45 panes
46 .into_iter()
47 .next()
48 .ok_or_else(|| WagnerError::Terminal("Session created but no panes found".into()))?
49 } else {
50 terminal.create_pane(&session, pane_cwd)?
51 };
52
53 let session_id = Uuid::new_v4().to_string();
54
55 let name = match pane_name {
56 Some(n) => {
57 if task.panes.iter().any(|p| p.name == n) {
58 task.next_pane_name(n)
59 } else {
60 n.to_string()
61 }
62 }
63 None => {
64 let base = match engine_type {
65 Engine::ClaudeCode => format!("claude-{}", repo.name),
66 Engine::Codex => format!("codex-{}", repo.name),
67 Engine::Droid => format!("droid-{}", repo.name),
68 Engine::Terminal => repo.name.clone(),
69 };
70 task.next_pane_name(&base)
71 }
72 };
73
74 if engine_type != Engine::Terminal {
75 terminal.shell_init_delay();
76 let launch_cmd = engine_type.launch_command(&session_id);
77 terminal.send_text_enter(&pane, &launch_cmd, engine_type.enter_delay_ms())?;
78 }
79
80 let jsonl_path = match engine_type {
81 Engine::ClaudeCode => {
82 let project_id = pane_cwd.to_string_lossy().replace(['/', '.'], "-");
83 if let Ok(home) = std::env::var("HOME") {
84 PathBuf::from(home)
85 .join(".claude")
86 .join("projects")
87 .join(project_id)
88 .join(format!("{session_id}.jsonl"))
89 } else {
90 PathBuf::from(PENDING_DISCOVERY)
91 }
92 }
93 Engine::Droid => {
94 let project_id = pane_cwd.to_string_lossy().replace('/', "-");
95 if let Ok(home) = std::env::var("HOME") {
96 PathBuf::from(home)
97 .join(".factory")
98 .join("sessions")
99 .join(project_id)
100 .join(format!("{session_id}.jsonl"))
101 } else {
102 PathBuf::from(PENDING_DISCOVERY)
103 }
104 }
105 Engine::Codex | Engine::Terminal => PathBuf::from(PENDING_DISCOVERY),
106 };
107
108 let tracked = TrackedPane {
109 name,
110 repo_name: repo.name.clone(),
111 engine: engine_type,
112 session_id,
113 pane_id: pane.0.clone(),
114 jsonl_path,
115 launched_at: Utc::now(),
116 };
117
118 task.panes.push(tracked.clone());
119 store.save_task(task)?;
120
121 Ok(tracked)
122}
123
124pub fn resolve_repo(task: &Task, repo_name: Option<&str>) -> Result<TaskRepo> {
127 match repo_name {
128 Some(name) => task
129 .repos
130 .iter()
131 .find(|r| r.name == name)
132 .cloned()
133 .ok_or_else(|| WagnerError::RepoNotFound(name.to_string(), PathBuf::new())),
134 None => task
135 .repos
136 .first()
137 .cloned()
138 .ok_or_else(|| WagnerError::Terminal("Task has no repos".into())),
139 }
140}
141
142pub struct Wagner<T: Terminal, A: Agent> {
143 pub terminal: T,
144 pub agent: A,
145 pub store: Store,
146 pub config: Config,
147}
148
149impl<T: Terminal, A: Agent> Wagner<T, A> {
150 pub fn new(terminal: T, agent: A, config: Config) -> Self {
151 let store = Store::new(config.clone());
152 Self {
153 terminal,
154 agent,
155 store,
156 config,
157 }
158 }
159
160 pub fn create_task(
161 &self,
162 name: &str,
163 repo_specs: &[RepoSpec],
164 base_branch: Option<&str>,
165 ) -> Result<Task> {
166 if self.store.task_exists(name) {
167 return Err(WagnerError::TaskExists(name.to_string()));
168 }
169
170 let task_path = self.config.tasks_root.join(name);
171 std::fs::create_dir_all(&task_path)?;
172
173 let mut repos = Vec::new();
174 let mut created_worktrees: Vec<(PathBuf, PathBuf)> = Vec::new();
175
176 let result = (|| -> Result<()> {
177 for spec in repo_specs {
178 let worktree_path = task_path.join(&spec.name);
179
180 let main_repo = match &spec.source {
181 RepoSource::Local(source_path) => {
182 if !source_path.exists() {
183 return Err(WagnerError::RepoNotFound(
184 spec.name.clone(),
185 source_path.clone(),
186 ));
187 }
188
189 if let Some(base) = base_branch {
190 self.fetch_and_update_branch(source_path, base);
191 }
192
193 self.create_worktree(source_path, &worktree_path, &spec.branch)?;
194 source_path.clone()
195 }
196 RepoSource::Remote(url) => {
197 let clone_path = self.clone_repo(url)?;
198 self.create_worktree(&clone_path, &worktree_path, &spec.branch)?;
199 clone_path
200 }
201 };
202
203 created_worktrees.push((main_repo, worktree_path.clone()));
204
205 repos.push(TaskRepo {
206 name: spec.name.clone(),
207 source: spec.source.clone(),
208 worktree: worktree_path,
209 branch: spec.branch.clone(),
210 });
211 }
212 Ok(())
213 })();
214
215 if let Err(e) = result {
216 self.cleanup_partial_task(&task_path, &created_worktrees);
217 return Err(e);
218 }
219
220 let mut task = Task::new(
221 name,
222 task_path.clone(),
223 repos,
224 base_branch.map(String::from),
225 );
226 if let Err(e) = self.store.save_task(&task) {
227 self.cleanup_partial_task(&task_path, &created_worktrees);
228 return Err(e);
229 }
230
231 self.setup_plugin_symlinks(&task)?;
232
233 let session = self.create_session_with_panes(name, &mut task)?;
234 let _ = session; self.store.save_task(&task)?;
237 Ok(task)
238 }
239
240 pub fn attach_task(&self, name: &str, repo_paths: Vec<PathBuf>) -> Result<Task> {
241 if self.store.task_exists(name) {
242 return Err(WagnerError::TaskExists(name.to_string()));
243 }
244
245 let mut repos = Vec::new();
246 for path in &repo_paths {
247 let canonical = path.canonicalize().map_err(|e| {
248 WagnerError::Io(std::io::Error::new(
249 e.kind(),
250 format!("Cannot resolve path {}: {}", path.display(), e),
251 ))
252 })?;
253
254 let repo_name = canonical
255 .file_name()
256 .map(|n| n.to_string_lossy().to_string())
257 .unwrap_or_else(|| "repo".to_string());
258
259 let branch = get_current_branch(&canonical).unwrap_or_else(|| "HEAD".to_string());
260
261 repos.push(TaskRepo {
262 name: repo_name,
263 source: RepoSource::Local(canonical.clone()),
264 worktree: canonical,
265 branch,
266 });
267 }
268
269 let task_path = if repos.len() == 1 {
270 repos[0].worktree.clone()
271 } else {
272 repos
273 .first()
274 .and_then(|r| r.worktree.parent())
275 .map(|p| p.to_path_buf())
276 .unwrap_or_else(|| repos[0].worktree.clone())
277 };
278
279 let mut task = Task::new_attached(name, task_path, repos);
280 self.store.save_task(&task)?;
281 self.setup_plugin_symlinks_attached(&task)?;
282
283 let session = self.create_session_with_panes(name, &mut task)?;
284 let _ = session;
285
286 self.store.save_task(&task)?;
287 Ok(task)
288 }
289
290 pub fn quick_launch(&self, engine: crate::model::Engine, name: Option<&str>) -> Result<()> {
291 let cwd = std::env::current_dir()?.canonicalize()?;
292 let dir_name = cwd
293 .file_name()
294 .map(|n| n.to_string_lossy().to_string())
295 .unwrap_or_else(|| "task".to_string());
296 let task_name = name.unwrap_or(&dir_name);
297
298 if self.store.task_exists(task_name) {
299 let session_name = session_name_for_task(task_name);
300 if self.terminal.session_exists(task_name)? {
301 match self.resume_dead_agents(task_name) {
303 Ok(n) if n > 0 => {
304 debug!(count = n, "Resumed dead agents");
305 }
306 _ => {}
307 }
308 return self.terminal.attach(&SessionHandle(session_name));
309 }
310
311 let mut task = self.store.load_task(task_name)?;
313 let task_path = task.path.clone();
314 let repo = task
315 .repos
316 .first()
317 .cloned()
318 .ok_or_else(|| WagnerError::Terminal("Task has no repos".into()))?;
319
320 let session = self.terminal.create_session(task_name, &task_path)?;
321
322 task.panes.clear();
324 if let Ok(panes) = self.terminal.list_panes(&session)
325 && let Some(pane) = panes.first()
326 {
327 self.prepare_agent_in_pane_with_engine(
328 &mut task, pane, &repo, None, engine, &task_path,
329 )?;
330 }
331
332 self.store.save_task(&task)?;
333 return self.terminal.attach(&session);
334 }
335
336 let branch = crate::attach::get_current_branch(&cwd).unwrap_or_else(|| "none".to_string());
337 let repo = TaskRepo {
338 name: dir_name.clone(),
339 source: RepoSource::Local(cwd.clone()),
340 worktree: cwd.clone(),
341 branch,
342 };
343
344 let mut task = Task::new_attached(task_name, cwd.clone(), vec![repo.clone()]);
345 self.store.save_task(&task)?;
346
347 let session = self.terminal.create_session(task_name, &cwd)?;
348 if let Ok(panes) = self.terminal.list_panes(&session)
349 && let Some(pane) = panes.first()
350 {
351 self.prepare_agent_in_pane_with_engine(&mut task, pane, &repo, None, engine, &cwd)?;
352 }
353
354 self.store.save_task(&task)?;
355 self.terminal.attach(&session)
356 }
357
358 pub fn detach_task(&self, name: &str) -> Result<()> {
359 let task = self.store.load_task(name)?;
360
361 if task.kind == crate::model::TaskKind::Managed {
362 return Err(WagnerError::DetachManagedTask(name.to_string()));
363 }
364
365 if self.terminal.session_exists(name)? {
366 self.terminal
367 .kill_session(&SessionHandle(session_name_for_task(name)))?;
368 }
369
370 self.store.delete_task(name)
371 }
372
373 fn setup_plugin_symlinks_attached(&self, task: &Task) -> Result<()> {
374 let enabled_plugins: Vec<_> = builtin_plugins()
375 .into_iter()
376 .filter(|p| p.is_enabled(&self.config))
377 .collect();
378
379 if enabled_plugins.is_empty() {
380 return Ok(());
381 }
382
383 for repo in &task.repos {
384 let repo_wagner_dir = repo.worktree.join(".wagner");
385 let repo_plugins_dir = repo_wagner_dir.join("plugins");
386 std::fs::create_dir_all(&repo_plugins_dir)?;
387
388 self.ensure_gitignore_has_wagner(&repo.worktree)?;
389
390 for plugin in &enabled_plugins {
391 let plugin_data_dir = repo_plugins_dir.join(plugin.data_dir());
392 std::fs::create_dir_all(&plugin_data_dir)?;
393 debug!(plugin = %plugin.id(), dir = %plugin_data_dir.display(), "Created plugin data dir");
394 }
395
396 let claude_dir = repo.worktree.join(".claude");
397 std::fs::create_dir_all(&claude_dir)?;
398
399 for plugin in &enabled_plugins {
400 if plugin.id() == "chains" {
401 let chains_link = claude_dir.join("chains");
402 if !chains_link.exists() {
403 let target = repo_plugins_dir.join("chains");
404 #[cfg(unix)]
405 std::os::unix::fs::symlink(&target, &chains_link)?;
406 debug!(link = %chains_link.display(), target = %target.display(), "Created chains symlink");
407 }
408 }
409 }
410 }
411
412 Ok(())
413 }
414
415 fn fetch_and_update_branch(&self, repo: &Path, branch: &str) {
416 match Command::new("git")
417 .args(["-C", &repo.to_string_lossy(), "fetch", "origin", branch])
418 .output()
419 {
420 Ok(output) if !output.status.success() => {
421 let stderr = String::from_utf8_lossy(&output.stderr);
422 warn!(
423 repo = %repo.display(),
424 branch = %branch,
425 stderr = %stderr.trim(),
426 "git fetch failed"
427 );
428 }
429 Err(e) => {
430 warn!(
431 repo = %repo.display(),
432 branch = %branch,
433 error = %e,
434 "git fetch command failed to execute"
435 );
436 }
437 _ => {}
438 }
439
440 match Command::new("git")
441 .args([
442 "-C",
443 &repo.to_string_lossy(),
444 "branch",
445 "-f",
446 branch,
447 &format!("origin/{}", branch),
448 ])
449 .output()
450 {
451 Ok(output) if !output.status.success() => {
452 let stderr = String::from_utf8_lossy(&output.stderr);
453 warn!(
454 repo = %repo.display(),
455 branch = %branch,
456 stderr = %stderr.trim(),
457 "git branch update failed"
458 );
459 }
460 Err(e) => {
461 warn!(
462 repo = %repo.display(),
463 branch = %branch,
464 error = %e,
465 "git branch update command failed to execute"
466 );
467 }
468 _ => {}
469 }
470 }
471
472 pub fn list_tasks(&self) -> Result<Vec<Task>> {
473 self.store.list_tasks()
474 }
475
476 pub fn get_task(&self, name: &str) -> Result<Task> {
477 self.store.load_task(name)
478 }
479
480 pub fn delete_task(&self, name: &str, force: bool) -> Result<()> {
481 let task = self.store.load_task(name)?;
482
483 if task.is_attached() {
484 return Err(WagnerError::CannotDeleteAttached(name.to_string()));
485 }
486
487 if self.terminal.session_exists(name)? {
488 self.terminal
489 .kill_session(&SessionHandle(session_name_for_task(name)))?;
490 }
491
492 for repo in &task.repos {
493 let main_repo = self.get_main_repo(&repo.worktree, &repo.source);
494
495 if repo.worktree.exists() {
496 self.remove_worktree(&main_repo, &repo.worktree)?;
497 }
498
499 self.prune_worktrees(&main_repo);
500
501 if force {
502 self.delete_branch(&main_repo, &repo.branch)?;
503 }
504 }
505
506 self.store.delete_task(name)
507 }
508
509 fn get_main_repo(&self, worktree: &Path, source: &RepoSource) -> PathBuf {
510 if worktree.exists() {
511 let output = Command::new("git")
512 .args([
513 "-C",
514 &worktree.to_string_lossy(),
515 "rev-parse",
516 "--git-common-dir",
517 ])
518 .output();
519
520 if let Ok(output) = output
521 && output.status.success()
522 {
523 let git_dir = String::from_utf8_lossy(&output.stdout).trim().to_string();
524 let git_path = PathBuf::from(&git_dir);
525
526 let git_path = if git_path.is_relative() {
527 worktree.join(&git_path).canonicalize().unwrap_or(git_path)
528 } else {
529 git_path
530 };
531
532 if git_path.join("HEAD").exists() {
533 return git_path;
534 }
535 if let Some(parent) = git_path.parent()
536 && (parent.join(".git").exists() || parent.join("HEAD").exists())
537 {
538 return parent.to_path_buf();
539 }
540 }
541 }
542
543 match source {
544 RepoSource::Local(path) => path.clone(),
545 RepoSource::Remote(url) => self.config.repos_root.join(url_to_repo_path(url)),
546 }
547 }
548
549 pub fn add_pane_with_engine(
550 &self,
551 task_name: &str,
552 repo_name: Option<&str>,
553 pane_name: Option<&str>,
554 engine: Option<crate::model::Engine>,
555 ) -> Result<PaneHandle> {
556 let mut task = self.store.load_task(task_name)?;
557 let repo = resolve_repo(&task, repo_name)?;
558
559 let engine_type = engine.unwrap_or_else(|| self.agent.engine());
560 let tracked = add_pane_shared(
561 &self.terminal,
562 &self.store,
563 &mut task,
564 &repo,
565 engine_type,
566 pane_name,
567 )?;
568
569 let pane = PaneHandle(tracked.pane_id.clone(), tracked.name.clone());
570 Ok(pane)
571 }
572
573 pub fn add_pane(
574 &self,
575 task_name: &str,
576 repo_name: Option<&str>,
577 pane_name: Option<&str>,
578 ) -> Result<PaneHandle> {
579 self.add_pane_with_engine(task_name, repo_name, pane_name, None)
580 }
581
582 fn create_session_with_panes(&self, name: &str, task: &mut Task) -> Result<SessionHandle> {
583 let session = self.terminal.create_session(name, &task.path)?;
584
585 if task.repos.len() > 1 {
586 let repos: Vec<_> = task.repos.clone();
587 let task_path = task.path.clone();
588 if let Ok(panes) = self.terminal.list_panes(&session)
590 && let Some(pane) = panes.first()
591 {
592 let _ = self.prepare_agent_in_pane(task, pane, &repos[0], None, &task_path);
593 }
594 for repo in &repos[1..] {
596 let pane = self.terminal.create_pane(&session, &repo.worktree)?;
597 let _ = self.prepare_agent_in_pane(task, &pane, repo, None, &repo.worktree);
598 }
599 } else if let Ok(panes) = self.terminal.list_panes(&session)
600 && let Some(pane) = panes.first()
601 {
602 let first_repo = task.repos[0].clone();
603 let task_path = task.path.clone();
604 let _ = self.prepare_agent_in_pane(task, pane, &first_repo, None, &task_path);
605 }
606
607 Ok(session)
608 }
609
610 fn prepare_agent_in_pane_with_engine(
611 &self,
612 task: &mut Task,
613 pane: &PaneHandle,
614 repo: &TaskRepo,
615 name_override: Option<&str>,
616 engine_type: crate::model::Engine,
617 pane_cwd: &Path,
618 ) -> Result<TrackedPane> {
619 use crate::model::{Engine, PENDING_DISCOVERY};
620 let session_id = Uuid::new_v4().to_string();
621
622 let pane_name = match name_override {
623 Some(n) => n.to_string(),
624 None => {
625 let base = match engine_type {
626 Engine::ClaudeCode => format!("claude-{}", repo.name),
627 Engine::Codex => format!("codex-{}", repo.name),
628 Engine::Droid => format!("droid-{}", repo.name),
629 Engine::Terminal => repo.name.clone(),
630 };
631 task.next_pane_name(&base)
632 }
633 };
634
635 if engine_type != Engine::Terminal {
636 self.terminal.shell_init_delay();
637 let cmd = engine_type.launch_command(&session_id);
638 self.terminal
639 .send_text_enter(pane, &cmd, engine_type.enter_delay_ms())?;
640 }
641
642 let jsonl_path = match engine_type {
643 Engine::ClaudeCode => {
644 let project_id = pane_cwd.to_string_lossy().replace(['/', '.'], "-");
645 if let Ok(home) = std::env::var("HOME") {
646 std::path::PathBuf::from(home)
647 .join(".claude")
648 .join("projects")
649 .join(project_id)
650 .join(format!("{session_id}.jsonl"))
651 } else {
652 PathBuf::from(PENDING_DISCOVERY)
653 }
654 }
655 Engine::Droid => {
656 let project_id = pane_cwd.to_string_lossy().replace('/', "-");
657 if let Ok(home) = std::env::var("HOME") {
658 std::path::PathBuf::from(home)
659 .join(".factory")
660 .join("sessions")
661 .join(project_id)
662 .join(format!("{session_id}.jsonl"))
663 } else {
664 PathBuf::from(PENDING_DISCOVERY)
665 }
666 }
667 Engine::Codex | Engine::Terminal => PathBuf::from(PENDING_DISCOVERY),
668 };
669
670 let tracked = TrackedPane {
671 name: pane_name,
672 repo_name: repo.name.clone(),
673 engine: engine_type,
674 session_id,
675 pane_id: pane.0.clone(),
676 jsonl_path,
677 launched_at: Utc::now(),
678 };
679
680 task.panes.push(tracked.clone());
681 Ok(tracked)
682 }
683
684 fn prepare_agent_in_pane(
685 &self,
686 task: &mut Task,
687 pane: &PaneHandle,
688 repo: &TaskRepo,
689 name_override: Option<&str>,
690 pane_cwd: &Path,
691 ) -> Result<TrackedPane> {
692 let session_id = Uuid::new_v4().to_string();
693 let engine = self.agent.engine();
694 let cwd = pane_cwd;
695
696 let pane_name = match name_override {
697 Some(n) => n.to_string(),
698 None => task.next_pane_name(&repo.name),
699 };
700
701 self.terminal.shell_init_delay();
702 let cmd = self.agent.launch_command(&session_id);
703 self.terminal
704 .send_text_enter(pane, &cmd, engine.enter_delay_ms())?;
705
706 let jsonl_path = self
707 .agent
708 .predict_jsonl_path(&session_id, cwd)
709 .unwrap_or_else(|| PathBuf::from("pending-discovery"));
710
711 let tracked = TrackedPane {
712 name: pane_name,
713 repo_name: repo.name.clone(),
714 engine,
715 session_id,
716 pane_id: pane.0.clone(),
717 jsonl_path,
718 launched_at: Utc::now(),
719 };
720
721 task.panes.push(tracked.clone());
722 Ok(tracked)
723 }
724
725 pub fn resume_dead_agents(&self, task_name: &str) -> Result<usize> {
726 let task = self.store.load_task(task_name)?;
727 let session = SessionHandle(session_name_for_task(task_name));
728 let panes = self.terminal.list_panes(&session).unwrap_or_default();
729 let mut resumed = 0;
730
731 for tracked in &task.panes {
732 let Some(pane) = panes.iter().find(|p| p.0 == tracked.pane_id) else {
733 continue;
734 };
735
736 let pane_cmd = self
737 .terminal
738 .get_pane_command(pane)
739 .unwrap_or_default()
740 .to_ascii_lowercase();
741
742 if pane_cmd.contains(tracked.engine.process_name()) {
743 continue;
744 }
745
746 let resume_cmd = tracked.engine.resume_command(&tracked.session_id);
747 self.terminal
748 .send_text_enter(pane, &resume_cmd, tracked.engine.enter_delay_ms())?;
749 resumed += 1;
750 }
751
752 Ok(resumed)
753 }
754
755 pub fn attach(&self, task_name: &str, pane_id: Option<&str>) -> Result<()> {
756 let session = SessionHandle(session_name_for_task(task_name));
757 if let Some(id) = pane_id {
758 let pane = PaneHandle(id.to_string(), String::new());
759 self.terminal.select_pane(&pane)?;
760 }
761 self.terminal.attach(&session)
762 }
763
764 pub fn add_repo_to_task(&self, task_name: &str, spec: &RepoSpec) -> Result<()> {
765 let mut task = self.store.load_task(task_name)?;
766
767 if task.repos.iter().any(|r| r.name == spec.name) {
768 return Err(WagnerError::Git(format!(
769 "Repo '{}' already exists in task",
770 spec.name
771 )));
772 }
773
774 let worktree_path = task.path.join(&spec.name);
775
776 match &spec.source {
777 RepoSource::Local(source_path) => {
778 if !source_path.exists() {
779 return Err(WagnerError::RepoNotFound(
780 spec.name.clone(),
781 source_path.clone(),
782 ));
783 }
784 self.create_worktree(source_path, &worktree_path, &spec.branch)?;
785 }
786 RepoSource::Remote(url) => {
787 let clone_path = self.clone_repo(url)?;
788 self.create_worktree(&clone_path, &worktree_path, &spec.branch)?;
789 }
790 }
791
792 task.repos.push(TaskRepo {
793 name: spec.name.clone(),
794 source: spec.source.clone(),
795 worktree: worktree_path,
796 branch: spec.branch.clone(),
797 });
798
799 self.store.save_task(&task)
800 }
801
802 pub fn remove_repo_from_task(&self, task_name: &str, repo_name: &str) -> Result<()> {
803 let mut task = self.store.load_task(task_name)?;
804
805 let repo_idx = task
806 .repos
807 .iter()
808 .position(|r| r.name == repo_name)
809 .ok_or_else(|| WagnerError::RepoNotFound(repo_name.to_string(), PathBuf::new()))?;
810
811 let repo = &task.repos[repo_idx];
812 let main_repo = self.get_main_repo(&repo.worktree, &repo.source);
813
814 if repo.worktree.exists() {
815 self.remove_worktree(&main_repo, &repo.worktree)?;
816 }
817 self.prune_worktrees(&main_repo);
818
819 task.repos.remove(repo_idx);
820 self.store.save_task(&task)
821 }
822
823 fn create_worktree(&self, repo: &Path, worktree: &Path, branch: &str) -> Result<()> {
824 let start_point = self.get_default_ref(repo);
825 let repo_str = repo.to_string_lossy();
826 let worktree_str = worktree.to_string_lossy();
827
828 let mut args = vec![
829 "-C",
830 repo_str.as_ref(),
831 "worktree",
832 "add",
833 "-b",
834 branch,
835 worktree_str.as_ref(),
836 ];
837 if let Some(ref sp) = start_point {
838 args.push(sp);
839 }
840
841 let output = Command::new("git").args(&args).output()?;
842
843 if !output.status.success() {
844 let output = Command::new("git")
845 .args([
846 "-C",
847 repo_str.as_ref(),
848 "worktree",
849 "add",
850 worktree_str.as_ref(),
851 branch,
852 ])
853 .output()?;
854
855 if !output.status.success() {
856 let stderr = String::from_utf8_lossy(&output.stderr);
857 return Err(WagnerError::Git(stderr.to_string()));
858 }
859 }
860
861 Ok(())
862 }
863
864 fn get_default_ref(&self, repo: &Path) -> Option<String> {
865 let is_bare = Command::new("git")
866 .args([
867 "-C",
868 &repo.to_string_lossy(),
869 "rev-parse",
870 "--is-bare-repository",
871 ])
872 .output()
873 .ok()
874 .map(|o| o.status.success() && String::from_utf8_lossy(&o.stdout).trim() == "true")
875 .unwrap_or(false);
876
877 if is_bare {
878 for branch in ["origin/main", "origin/master"] {
879 let output = Command::new("git")
880 .args([
881 "-C",
882 &repo.to_string_lossy(),
883 "rev-parse",
884 "--verify",
885 branch,
886 ])
887 .output()
888 .ok()?;
889 if output.status.success() {
890 return Some(branch.to_string());
891 }
892 }
893 return None;
894 }
895
896 let output = Command::new("git")
897 .args(["-C", &repo.to_string_lossy(), "symbolic-ref", "HEAD"])
898 .output()
899 .ok()?;
900
901 if output.status.success() {
902 return None;
903 }
904
905 for branch in ["origin/main", "origin/master"] {
906 let output = Command::new("git")
907 .args([
908 "-C",
909 &repo.to_string_lossy(),
910 "rev-parse",
911 "--verify",
912 branch,
913 ])
914 .output()
915 .ok()?;
916 if output.status.success() {
917 return Some(branch.to_string());
918 }
919 }
920
921 None
922 }
923
924 fn remove_worktree(&self, main_repo: &Path, worktree: &Path) -> Result<()> {
925 let output = Command::new("git")
926 .args([
927 "-C",
928 &main_repo.to_string_lossy(),
929 "worktree",
930 "remove",
931 "--force",
932 &worktree.to_string_lossy(),
933 ])
934 .output()?;
935
936 if !output.status.success() && worktree.exists() {
937 std::fs::remove_dir_all(worktree)?;
938 }
939
940 Ok(())
941 }
942
943 fn prune_worktrees(&self, main_repo: &Path) {
944 let _ = Command::new("git")
945 .args(["-C", &main_repo.to_string_lossy(), "worktree", "prune"])
946 .output();
947 }
948
949 fn delete_branch(&self, main_repo: &Path, branch: &str) -> Result<()> {
950 let output = Command::new("git")
951 .args(["-C", &main_repo.to_string_lossy(), "branch", "-D", branch])
952 .output()?;
953
954 if !output.status.success() {
955 let stderr = String::from_utf8_lossy(&output.stderr);
956 if !stderr.contains("not found") {
957 return Err(WagnerError::Git(format!(
958 "Failed to delete branch '{}': {}",
959 branch, stderr
960 )));
961 }
962 }
963
964 Ok(())
965 }
966
967 fn clone_repo(&self, url: &str) -> Result<PathBuf> {
968 let clone_path = self.config.repos_root.join(url_to_repo_path(url));
969
970 if clone_path.exists() {
971 self.fetch_repo(&clone_path)?;
972 return Ok(clone_path);
973 }
974
975 if let Some(parent) = clone_path.parent() {
976 std::fs::create_dir_all(parent)?;
977 }
978
979 let output = Command::new("git")
980 .args(["clone", "--bare", url, &clone_path.to_string_lossy()])
981 .output()?;
982
983 if !output.status.success() {
984 let stderr = String::from_utf8_lossy(&output.stderr);
985 return Err(WagnerError::Git(stderr.to_string()));
986 }
987
988 Ok(clone_path)
989 }
990
991 fn fetch_repo(&self, repo_path: &Path) -> Result<()> {
992 let output = Command::new("git")
993 .args([
994 "-C",
995 &repo_path.to_string_lossy(),
996 "fetch",
997 "--all",
998 "--prune",
999 ])
1000 .output()?;
1001
1002 if !output.status.success() {
1003 let stderr = String::from_utf8_lossy(&output.stderr);
1004 return Err(WagnerError::Git(format!("fetch failed: {}", stderr)));
1005 }
1006
1007 Ok(())
1008 }
1009
1010 fn cleanup_partial_task(&self, task_path: &Path, created_worktrees: &[(PathBuf, PathBuf)]) {
1011 for (main_repo, worktree) in created_worktrees {
1012 let _ = self.remove_worktree(main_repo, worktree);
1013 self.prune_worktrees(main_repo);
1014 }
1015
1016 if task_path.exists() {
1017 let _ = std::fs::remove_dir_all(task_path);
1018 }
1019 }
1020
1021 fn setup_plugin_symlinks(&self, task: &Task) -> Result<()> {
1022 let enabled_plugins: Vec<_> = builtin_plugins()
1023 .into_iter()
1024 .filter(|p| p.is_enabled(&self.config))
1025 .collect();
1026
1027 if enabled_plugins.is_empty() {
1028 return Ok(());
1029 }
1030
1031 let first_repo = match task.repos.first() {
1032 Some(r) => r,
1033 None => return Ok(()),
1034 };
1035
1036 let worktree_path = &first_repo.worktree;
1039
1040 let repo_wagner_dir = worktree_path.join(".wagner");
1041 let repo_plugins_dir = repo_wagner_dir.join("plugins");
1042
1043 std::fs::create_dir_all(&repo_plugins_dir)?;
1044
1045 self.ensure_gitignore_has_wagner(worktree_path)?;
1046
1047 for plugin in &enabled_plugins {
1048 let plugin_data_dir = repo_plugins_dir.join(plugin.data_dir());
1049 std::fs::create_dir_all(&plugin_data_dir)?;
1050 debug!(plugin = %plugin.id(), dir = %plugin_data_dir.display(), "Created plugin data dir");
1051 }
1052
1053 let task_wagner_dir = task.path.join(".wagner");
1054 std::fs::create_dir_all(&task_wagner_dir)?;
1055
1056 let task_plugins_link = task_wagner_dir.join("plugins");
1057 if !task_plugins_link.exists() {
1058 #[cfg(unix)]
1059 std::os::unix::fs::symlink(&repo_plugins_dir, &task_plugins_link)?;
1060 debug!(link = %task_plugins_link.display(), target = %repo_plugins_dir.display(), "Created plugins symlink");
1061 }
1062
1063 for repo in &task.repos {
1064 let claude_dir = repo.worktree.join(".claude");
1065 std::fs::create_dir_all(&claude_dir)?;
1066
1067 for plugin in &enabled_plugins {
1068 if plugin.id() == "chains" {
1069 let chains_link = claude_dir.join("chains");
1070 if !chains_link.exists() {
1071 let target = task_plugins_link.join("chains");
1072 #[cfg(unix)]
1073 std::os::unix::fs::symlink(&target, &chains_link)?;
1074 debug!(link = %chains_link.display(), target = %target.display(), "Created chains symlink");
1075 }
1076 }
1077 }
1078 }
1079
1080 Ok(())
1081 }
1082
1083 fn ensure_gitignore_has_wagner(&self, repo_path: &Path) -> Result<()> {
1084 let gitignore_path = repo_path.join(".gitignore");
1085
1086 let content = if gitignore_path.exists() {
1087 std::fs::read_to_string(&gitignore_path)?
1088 } else {
1089 String::new()
1090 };
1091
1092 if content
1093 .lines()
1094 .any(|line| line.trim() == ".wagner/" || line.trim() == ".wagner")
1095 {
1096 return Ok(());
1097 }
1098
1099 let new_content = if content.is_empty() || content.ends_with('\n') {
1100 format!("{}.wagner/\n", content)
1101 } else {
1102 format!("{}\n.wagner/\n", content)
1103 };
1104
1105 std::fs::write(&gitignore_path, new_content)?;
1106 debug!(path = %gitignore_path.display(), "Added .wagner/ to .gitignore");
1107
1108 Ok(())
1109 }
1110}
1111
1112fn url_to_repo_path(url: &str) -> PathBuf {
1113 let url = url.strip_suffix(".git").unwrap_or(url);
1114
1115 if let Some(rest) = url.strip_prefix("ssh://") {
1116 let without_user = rest.split('@').next_back().unwrap_or(rest);
1117 let normalized = if let Some(colon_pos) = without_user.find(':') {
1118 let after_colon = &without_user[colon_pos + 1..];
1119 if after_colon.starts_with(|c: char| c.is_ascii_digit()) {
1120 if let Some(slash_pos) = after_colon.find('/') {
1121 format!(
1122 "{}{}",
1123 &without_user[..colon_pos],
1124 &after_colon[slash_pos..]
1125 )
1126 } else {
1127 without_user.to_string()
1128 }
1129 } else {
1130 without_user.replace(':', "/")
1131 }
1132 } else {
1133 without_user.to_string()
1134 };
1135 return PathBuf::from(normalized.trim_start_matches('/'));
1136 }
1137
1138 if let Some(rest) = url.strip_prefix("git@") {
1139 let normalized = rest.replace(':', "/");
1140 return PathBuf::from(normalized);
1141 }
1142
1143 if let Some(rest) = url.strip_prefix("https://") {
1144 return PathBuf::from(rest);
1145 }
1146
1147 if let Some(rest) = url.strip_prefix("http://") {
1148 return PathBuf::from(rest);
1149 }
1150
1151 PathBuf::from(url)
1152}
1153
1154pub fn default_branch_for_task(task_name: &str) -> String {
1155 format!("feature/{}", task_name)
1156}
1157
1158#[derive(Debug, Clone)]
1159pub struct RepoSpec {
1160 pub name: String,
1161 pub source: RepoSource,
1162 pub branch: String,
1163}
1164
1165impl RepoSpec {
1166 pub fn parse(s: &str, default_branch: Option<&str>) -> Result<Self> {
1167 let Some((name, rest)) = s.split_once(':') else {
1169 return Err(WagnerError::InvalidRepoSpec(format!(
1170 "Expected format: name:source[:branch], got: {}",
1171 s
1172 )));
1173 };
1174
1175 if name.is_empty() || rest.is_empty() {
1176 return Err(WagnerError::InvalidRepoSpec(format!(
1177 "Expected format: name:source[:branch], got: {}",
1178 s
1179 )));
1180 }
1181
1182 let (source_str, branch) = Self::split_source_and_branch(rest);
1184
1185 Ok(Self {
1186 name: name.to_string(),
1187 source: RepoSource::parse(source_str),
1188 branch: branch
1189 .unwrap_or_else(|| default_branch.unwrap_or("main"))
1190 .to_string(),
1191 })
1192 }
1193
1194 fn split_source_and_branch(rest: &str) -> (&str, Option<&str>) {
1202 if rest.starts_with("https://") || rest.starts_with("http://") || rest.starts_with("git://")
1203 {
1204 let scheme_end = rest.find("://").unwrap() + 3; let path_start = rest[scheme_end..]
1210 .find('/')
1211 .map(|i| scheme_end + i)
1212 .unwrap_or(rest.len());
1213 if let Some(last_colon) = rest[path_start..].rfind(':') {
1215 let split_pos = path_start + last_colon;
1216 let source = &rest[..split_pos];
1217 let branch = &rest[split_pos + 1..];
1218 if !branch.is_empty() {
1219 return (source, Some(branch));
1220 }
1221 }
1222 (rest, None)
1223 } else if let Some(after_git_at) = rest.strip_prefix("git@") {
1224 let prefix_len = rest.len() - after_git_at.len(); if let Some(first_colon) = after_git_at.find(':') {
1229 let after_first_colon = &after_git_at[first_colon + 1..];
1230 if let Some(second_colon) = after_first_colon.rfind(':') {
1232 let split_pos = prefix_len + first_colon + 1 + second_colon;
1233 let source = &rest[..split_pos];
1234 let branch = &rest[split_pos + 1..];
1235 if !branch.is_empty() {
1236 return (source, Some(branch));
1237 }
1238 }
1239 }
1240 (rest, None)
1241 } else {
1242 if let Some(last_colon) = rest.rfind(':') {
1244 let source = &rest[..last_colon];
1245 let branch = &rest[last_colon + 1..];
1246 if !source.is_empty() && !branch.is_empty() {
1247 return (source, Some(branch));
1248 }
1249 }
1250 (rest, None)
1251 }
1252 }
1253}
1254
1255#[cfg(test)]
1256mod tests {
1257 use super::*;
1258
1259 #[test]
1260 fn url_to_repo_path_ssh_with_git_suffix() {
1261 let result = url_to_repo_path("git@github.com:user/repo.git");
1262 assert_eq!(result, PathBuf::from("github.com/user/repo"));
1263 }
1264
1265 #[test]
1266 fn url_to_repo_path_ssh_without_git_suffix() {
1267 let result = url_to_repo_path("git@github.com:user/repo");
1268 assert_eq!(result, PathBuf::from("github.com/user/repo"));
1269 }
1270
1271 #[test]
1272 fn url_to_repo_path_https_with_git_suffix() {
1273 let result = url_to_repo_path("https://github.com/user/repo.git");
1274 assert_eq!(result, PathBuf::from("github.com/user/repo"));
1275 }
1276
1277 #[test]
1278 fn url_to_repo_path_https_without_git_suffix() {
1279 let result = url_to_repo_path("https://github.com/user/repo");
1280 assert_eq!(result, PathBuf::from("github.com/user/repo"));
1281 }
1282
1283 #[test]
1284 fn url_to_repo_path_http_with_git_suffix() {
1285 let result = url_to_repo_path("http://gitlab.com/user/repo.git");
1286 assert_eq!(result, PathBuf::from("gitlab.com/user/repo"));
1287 }
1288
1289 #[test]
1290 fn url_to_repo_path_http_without_git_suffix() {
1291 let result = url_to_repo_path("http://gitlab.com/user/repo");
1292 assert_eq!(result, PathBuf::from("gitlab.com/user/repo"));
1293 }
1294
1295 #[test]
1296 fn url_to_repo_path_nested_path() {
1297 let result = url_to_repo_path("git@github.com:org/subgroup/repo.git");
1298 assert_eq!(result, PathBuf::from("github.com/org/subgroup/repo"));
1299 }
1300
1301 #[test]
1302 fn url_to_repo_path_https_nested_path() {
1303 let result = url_to_repo_path("https://gitlab.com/org/subgroup/repo.git");
1304 assert_eq!(result, PathBuf::from("gitlab.com/org/subgroup/repo"));
1305 }
1306
1307 #[test]
1308 fn url_to_repo_path_self_hosted() {
1309 let result = url_to_repo_path("git@git.company.com:team/project.git");
1310 assert_eq!(result, PathBuf::from("git.company.com/team/project"));
1311 }
1312
1313 #[test]
1314 fn url_to_repo_path_unknown_format_passthrough() {
1315 let result = url_to_repo_path("some/local/path");
1316 assert_eq!(result, PathBuf::from("some/local/path"));
1317 }
1318
1319 #[test]
1320 fn url_to_repo_path_strips_single_git_suffix() {
1321 let result = url_to_repo_path("https://github.com/user/repo.git.git");
1322 assert_eq!(result, PathBuf::from("github.com/user/repo.git"));
1323 }
1324
1325 #[test]
1326 fn url_to_repo_path_ssh_protocol() {
1327 let result = url_to_repo_path("ssh://git@github.com/user/repo.git");
1328 assert_eq!(result, PathBuf::from("github.com/user/repo"));
1329 }
1330
1331 #[test]
1332 fn url_to_repo_path_ssh_protocol_with_port() {
1333 let result = url_to_repo_path("ssh://git@github.com:22/user/repo.git");
1334 assert_eq!(result, PathBuf::from("github.com/user/repo"));
1335 }
1336
1337 #[test]
1340 fn repo_spec_parse_https_url() {
1341 let spec = RepoSpec::parse("myrepo:https://github.com/org/repo", None).unwrap();
1342 assert_eq!(spec.name, "myrepo");
1343 assert_eq!(spec.branch, "main");
1344 match &spec.source {
1345 RepoSource::Remote(url) => {
1346 assert_eq!(url, "https://github.com/org/repo");
1347 }
1348 _ => panic!("Expected remote source"),
1349 }
1350 }
1351
1352 #[test]
1353 fn repo_spec_parse_ssh_url() {
1354 let spec = RepoSpec::parse("myrepo:git@github.com:org/repo", None).unwrap();
1355 assert_eq!(spec.name, "myrepo");
1356 assert_eq!(spec.branch, "main");
1357 match &spec.source {
1358 RepoSource::Remote(url) => {
1359 assert_eq!(url, "git@github.com:org/repo");
1360 }
1361 _ => panic!("Expected remote source"),
1362 }
1363 }
1364
1365 #[test]
1366 fn repo_spec_parse_https_with_branch() {
1367 let spec = RepoSpec::parse("myrepo:https://github.com/org/repo:feat", None).unwrap();
1368 assert_eq!(spec.name, "myrepo");
1369 assert_eq!(spec.branch, "feat");
1370 match &spec.source {
1371 RepoSource::Remote(url) => {
1372 assert_eq!(url, "https://github.com/org/repo");
1373 }
1374 _ => panic!("Expected remote source"),
1375 }
1376 }
1377
1378 #[test]
1379 fn repo_spec_parse_ssh_with_branch() {
1380 let spec = RepoSpec::parse("myrepo:git@github.com:org/repo:main", None).unwrap();
1381 assert_eq!(spec.name, "myrepo");
1382 assert_eq!(spec.branch, "main");
1383 match &spec.source {
1384 RepoSource::Remote(url) => {
1385 assert_eq!(url, "git@github.com:org/repo");
1386 }
1387 _ => panic!("Expected remote source"),
1388 }
1389 }
1390
1391 #[test]
1392 fn repo_spec_parse_local_path() {
1393 let spec = RepoSpec::parse("myrepo:/path/to/repo:feature/branch", None).unwrap();
1395 assert_eq!(spec.name, "myrepo");
1396 assert_eq!(spec.branch, "feature/branch");
1397 match &spec.source {
1398 RepoSource::Local(path) => {
1399 assert_eq!(path, &PathBuf::from("/path/to/repo"));
1400 }
1401 _ => panic!("Expected local source"),
1402 }
1403 }
1404
1405 #[test]
1406 fn repo_spec_parse_simple_name_path() {
1407 let spec = RepoSpec::parse("myrepo:/path/to/repo", None).unwrap();
1409 assert_eq!(spec.name, "myrepo");
1410 assert_eq!(spec.branch, "main");
1411 match &spec.source {
1412 RepoSource::Local(path) => {
1413 assert_eq!(path, &PathBuf::from("/path/to/repo"));
1414 }
1415 _ => panic!("Expected local source"),
1416 }
1417 }
1418
1419 #[test]
1420 fn repo_spec_parse_local_path_with_default_branch() {
1421 let spec = RepoSpec::parse("myrepo:/path/to/repo", Some("develop")).unwrap();
1422 assert_eq!(spec.name, "myrepo");
1423 assert_eq!(spec.branch, "develop");
1424 }
1425
1426 #[test]
1427 fn repo_spec_parse_invalid_no_colon() {
1428 let result = RepoSpec::parse("invalid", None);
1429 assert!(result.is_err());
1430 }
1431
1432 #[test]
1433 fn repo_spec_parse_http_url() {
1434 let spec = RepoSpec::parse("myrepo:http://gitlab.com/org/repo", None).unwrap();
1435 assert_eq!(spec.name, "myrepo");
1436 assert_eq!(spec.branch, "main");
1437 match &spec.source {
1438 RepoSource::Remote(url) => {
1439 assert_eq!(url, "http://gitlab.com/org/repo");
1440 }
1441 _ => panic!("Expected remote source"),
1442 }
1443 }
1444
1445 #[test]
1446 fn repo_spec_parse_http_url_with_branch() {
1447 let spec = RepoSpec::parse("myrepo:http://gitlab.com/org/repo:develop", None).unwrap();
1448 assert_eq!(spec.name, "myrepo");
1449 assert_eq!(spec.branch, "develop");
1450 match &spec.source {
1451 RepoSource::Remote(url) => {
1452 assert_eq!(url, "http://gitlab.com/org/repo");
1453 }
1454 _ => panic!("Expected remote source"),
1455 }
1456 }
1457
1458 #[test]
1459 fn repo_spec_parse_https_url_with_port() {
1460 let spec = RepoSpec::parse("myrepo:https://host:8443/org/repo", None).unwrap();
1461 assert_eq!(spec.name, "myrepo");
1462 assert_eq!(spec.branch, "main");
1463 match &spec.source {
1464 RepoSource::Remote(url) => {
1465 assert_eq!(url, "https://host:8443/org/repo");
1466 }
1467 _ => panic!("Expected remote source"),
1468 }
1469 }
1470
1471 #[test]
1472 fn repo_spec_parse_https_url_with_port_and_branch() {
1473 let spec = RepoSpec::parse("myrepo:https://host:8443/org/repo:develop", None).unwrap();
1474 assert_eq!(spec.name, "myrepo");
1475 assert_eq!(spec.branch, "develop");
1476 match &spec.source {
1477 RepoSource::Remote(url) => {
1478 assert_eq!(url, "https://host:8443/org/repo");
1479 }
1480 _ => panic!("Expected remote source"),
1481 }
1482 }
1483
1484 #[test]
1485 fn repo_spec_parse_http_url_with_port() {
1486 let spec = RepoSpec::parse("myrepo:http://gitlab.local:3000/org/repo", None).unwrap();
1487 assert_eq!(spec.name, "myrepo");
1488 assert_eq!(spec.branch, "main");
1489 match &spec.source {
1490 RepoSource::Remote(url) => {
1491 assert_eq!(url, "http://gitlab.local:3000/org/repo");
1492 }
1493 _ => panic!("Expected remote source"),
1494 }
1495 }
1496
1497 #[test]
1498 fn repo_spec_parse_http_url_with_port_and_branch() {
1499 let spec =
1500 RepoSpec::parse("myrepo:http://gitlab.local:3000/org/repo:feat/x", None).unwrap();
1501 assert_eq!(spec.name, "myrepo");
1502 assert_eq!(spec.branch, "feat/x");
1503 match &spec.source {
1504 RepoSource::Remote(url) => {
1505 assert_eq!(url, "http://gitlab.local:3000/org/repo");
1506 }
1507 _ => panic!("Expected remote source"),
1508 }
1509 }
1510
1511 #[test]
1512 fn repo_spec_parse_git_url_with_port() {
1513 let spec = RepoSpec::parse("myrepo:git://host:9418/org/repo", None).unwrap();
1514 assert_eq!(spec.name, "myrepo");
1515 assert_eq!(spec.branch, "main");
1516 match &spec.source {
1517 RepoSource::Remote(url) => {
1518 assert_eq!(url, "git://host:9418/org/repo");
1519 }
1520 _ => panic!("Expected remote source"),
1521 }
1522 }
1523
1524 #[test]
1525 fn repo_spec_parse_https_url_port_only_no_path() {
1526 let spec = RepoSpec::parse("myrepo:https://host:8443", None).unwrap();
1528 assert_eq!(spec.name, "myrepo");
1529 assert_eq!(spec.branch, "main");
1530 match &spec.source {
1531 RepoSource::Remote(url) => {
1532 assert_eq!(url, "https://host:8443");
1533 }
1534 _ => panic!("Expected remote source"),
1535 }
1536 }
1537
1538 #[test]
1539 fn repo_spec_parse_ssh_nested_path_with_branch() {
1540 let spec = RepoSpec::parse("myrepo:git@gitlab.com:org/sub/repo:release/v1", None).unwrap();
1541 assert_eq!(spec.name, "myrepo");
1542 assert_eq!(spec.branch, "release/v1");
1543 match &spec.source {
1544 RepoSource::Remote(url) => {
1545 assert_eq!(url, "git@gitlab.com:org/sub/repo");
1546 }
1547 _ => panic!("Expected remote source"),
1548 }
1549 }
1550
1551 #[test]
1554 fn fetch_and_update_branch_nonexistent_repo_no_panic() {
1555 let config = crate::config::Config {
1558 tasks_root: std::env::temp_dir().join("wagner-test-fetch"),
1559 ..crate::config::Config::default()
1560 };
1561 let w = Wagner::new(
1562 crate::terminal::MockTerminal::new(),
1563 crate::agent::TestAgent::echo(),
1564 config,
1565 );
1566 let bad_path = PathBuf::from("/nonexistent/repo/path");
1568 w.fetch_and_update_branch(&bad_path, "main");
1569 }
1571
1572 #[test]
1573 fn fetch_and_update_branch_no_remote_no_panic() {
1574 let temp = tempfile::TempDir::new().unwrap();
1576 let repo = temp.path().join("repo");
1577 std::fs::create_dir_all(&repo).unwrap();
1578 std::process::Command::new("git")
1579 .args(["init"])
1580 .current_dir(&repo)
1581 .output()
1582 .unwrap();
1583
1584 let config = crate::config::Config {
1585 tasks_root: std::env::temp_dir().join("wagner-test-fetch2"),
1586 ..crate::config::Config::default()
1587 };
1588 let w = Wagner::new(
1589 crate::terminal::MockTerminal::new(),
1590 crate::agent::TestAgent::echo(),
1591 config,
1592 );
1593 w.fetch_and_update_branch(&repo, "nonexistent-branch");
1594 }
1596}