Skip to main content

wagner/
wagner.rs

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
15/// Shared pane-creation logic used by both `Wagner::add_pane_with_engine` and
16/// `command_executor::execute` (AddPane handler). This eliminates the duplication
17/// that previously existed in `command_executor.rs`.
18///
19/// Handles: session check/creation, pane creation, agent launch, JSONL path
20/// prediction, TrackedPane construction, and task persistence.
21pub 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
124/// Resolve a repo from a task given an optional repo_name.
125/// Uses the named repo if specified, otherwise falls back to the first repo.
126pub 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; // session handle not needed after pane setup
235
236        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                // Session is alive — resume any dead agents and attach
302                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            // Session is dead but task exists — recreate session, preserve task data
312            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            // Clear stale pane tracking and relaunch agent in recreated session
323            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            // First pane (already created with session) gets first repo
589            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            // Additional repos get new panes
595            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        // Use worktree path (not source repo) so .wagner/ and .gitignore
1037        // modifications go into the worktree, not the user's original repo.
1038        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        // Split into name and rest (at most 2 parts)
1168        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        // Parse rest into (source, optional branch) depending on URL type
1183        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    /// Split a source string (after the name: prefix) into (source, optional branch).
1195    ///
1196    /// Handles:
1197    /// - HTTPS/HTTP URLs: `https://host/path` or `https://host/path:branch`
1198    /// - HTTPS/HTTP URLs with ports: `https://host:8443/path` or `https://host:8443/path:branch`
1199    /// - SSH URLs: `git@host:path` or `git@host:path:branch`
1200    /// - Local paths: `/path/to/repo` or `/path/to/repo:branch`
1201    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            // For scheme-based URLs, find the authority section first.
1205            let scheme_end = rest.find("://").unwrap() + 3; // past "://"
1206            // The authority ends at the first '/' after the scheme. The
1207            // authority may contain a port (e.g., host:8443), so we must
1208            // skip past it before looking for the branch delimiter ':'.
1209            let path_start = rest[scheme_end..]
1210                .find('/')
1211                .map(|i| scheme_end + i)
1212                .unwrap_or(rest.len());
1213            // Only search for branch delimiter ':' in the path portion
1214            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            // SSH URLs: git@host:path — first ':' after 'git@' is part of the URL.
1225            // If there's a second ':', it's the branch delimiter.
1226            let prefix_len = rest.len() - after_git_at.len(); // length of "git@"
1227            // Find the first ':' (host:path separator, part of the URL)
1228            if let Some(first_colon) = after_git_at.find(':') {
1229                let after_first_colon = &after_git_at[first_colon + 1..];
1230                // Look for another ':' — that's the branch delimiter
1231                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            // Local path: the last ':' is the branch delimiter
1243            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    // --- RepoSpec::parse tests ---
1338
1339    #[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        // Regression test: local paths continue to work
1394        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        // name:path without branch
1408        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        // Edge case: URL with port but no path beyond authority
1527        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    // --- fetch_and_update_branch error handling tests ---
1552
1553    #[test]
1554    fn fetch_and_update_branch_nonexistent_repo_no_panic() {
1555        // Before the fix, errors were silently discarded with `let _ = ...`.
1556        // After the fix, errors are logged at warn! level but don't panic.
1557        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        // Non-existent path — both git commands will fail
1567        let bad_path = PathBuf::from("/nonexistent/repo/path");
1568        w.fetch_and_update_branch(&bad_path, "main");
1569        // If we reach here without panic, the test passes
1570    }
1571
1572    #[test]
1573    fn fetch_and_update_branch_no_remote_no_panic() {
1574        // Real repo but no remote configured — fetch will fail but shouldn't panic
1575        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        // Should not panic
1595    }
1596}