Skip to main content

mj_core/
local_git.rs

1//! Controller-side support for repositories configured with `local` sources.
2
3use std::path::{Path, PathBuf};
4use std::process::Command;
5
6use anyhow::{Context, Result, bail};
7use serde::{Deserialize, Serialize};
8
9use crate::config::ProjectBundle;
10use crate::remote_git::{NetworkGitSource, display_url, validate_network_url};
11use crate::targets::{CommandExecutor, CommandOutput, CommandSpec};
12
13#[derive(Debug, Clone, PartialEq, Eq)]
14pub struct DirtyLocalRepository {
15    pub id: String,
16    pub path: PathBuf,
17    pub summary: String,
18}
19
20pub fn dirty_local_repositories(bundle: &ProjectBundle) -> Result<Vec<DirtyLocalRepository>> {
21    bundle
22        .repositories
23        .iter()
24        .filter_map(|repository| repository.local.as_ref().map(|path| (repository, path)))
25        .filter_map(|(repository, path)| match local_status(path) {
26            Ok(Some(summary)) => Some(Ok(DirtyLocalRepository {
27                id: repository.id.clone(),
28                path: path.clone(),
29                summary,
30            })),
31            Ok(None) => None,
32            Err(error) => Some(Err(
33                error.context(format!("inspect local repository {:?}", repository.id))
34            )),
35        })
36        .collect()
37}
38
39pub fn canonical_repository(path: &Path) -> Result<PathBuf> {
40    let output = Command::new("git")
41        .args(["rev-parse", "--show-toplevel"])
42        .current_dir(path)
43        .output()
44        .with_context(|| format!("start git in {}", path.display()))?;
45    if !output.status.success() {
46        bail!(
47            "{} is not a Git repository with a readable worktree: {}",
48            path.display(),
49            String::from_utf8_lossy(&output.stderr).trim()
50        );
51    }
52    let root = String::from_utf8(output.stdout).context("decode Git repository root")?;
53    let root = PathBuf::from(root.trim());
54    let root = std::fs::canonicalize(&root)
55        .with_context(|| format!("canonicalize local repository {}", root.display()))?;
56    main_worktree_root(&root)
57}
58
59/// Map a repository top level to the top level of its main working tree.
60///
61/// A linked worktree created by `git worktree add` reports its own top level,
62/// but Hel treats it as the same repository as the main working tree.
63pub fn main_worktree_root(root: &Path) -> Result<PathBuf> {
64    let output = Command::new("git")
65        .args(["rev-parse", "--path-format=absolute", "--git-common-dir"])
66        .current_dir(root)
67        .output()
68        .with_context(|| format!("start git in {}", root.display()))?;
69    if !output.status.success() {
70        bail!(
71            "could not read the common Git directory for {}: {}",
72            root.display(),
73            String::from_utf8_lossy(&output.stderr).trim()
74        );
75    }
76    let common = String::from_utf8(output.stdout).context("decode common Git directory")?;
77    let common = PathBuf::from(common.trim());
78    // A bare or otherwise unusual layout has no `.git` parent to fall back to.
79    if common.file_name() != Some(std::ffi::OsStr::new(".git")) {
80        return Ok(root.to_path_buf());
81    }
82    let Some(parent) = common.parent().filter(|parent| parent.is_dir()) else {
83        return Ok(root.to_path_buf());
84    };
85    let parent = std::fs::canonicalize(parent)
86        .with_context(|| format!("canonicalize main worktree {}", parent.display()))?;
87    if parent == root {
88        return Ok(root.to_path_buf());
89    }
90    Ok(parent)
91}
92
93fn local_status(path: &Path) -> Result<Option<String>> {
94    let root = canonical_repository(path)?;
95    let head = Command::new("git")
96        .args(["rev-parse", "--verify", "HEAD"])
97        .current_dir(&root)
98        .output()
99        .with_context(|| format!("read HEAD in {}", root.display()))?;
100    if !head.status.success() {
101        bail!("local repository {} has no commit at HEAD", root.display());
102    }
103    let output = Command::new("git")
104        .args(["status", "--porcelain=v1", "--untracked-files=normal"])
105        .current_dir(&root)
106        .output()
107        .with_context(|| format!("read Git status in {}", root.display()))?;
108    if !output.status.success() {
109        bail!(
110            "git status failed in {}: {}",
111            root.display(),
112            String::from_utf8_lossy(&output.stderr).trim()
113        );
114    }
115    let status = String::from_utf8(output.stdout).context("decode Git status")?;
116    let mut lines = status.lines();
117    let Some(first) = lines.next() else {
118        return Ok(None);
119    };
120    let remaining = lines.count();
121    let summary = if remaining == 0 {
122        first.to_owned()
123    } else {
124        format!("{first} (and {remaining} more)")
125    };
126    Ok(Some(summary))
127}
128
129/// Resolve the fetch and push endpoints Git would use for a host checkout.
130///
131/// Git performs URL rewriting while answering `remote get-url`, so the
132/// values returned here are the actual network endpoints that should be
133/// persisted for the target checkout. The local checkout may be dirty or
134/// have commits that are not published; neither affects endpoint selection.
135pub fn resolve_local_repository(
136    path: &Path,
137    executor: &impl CommandExecutor,
138) -> Result<NetworkGitSource> {
139    resolve_local_repository_with_remote(path, executor, None)
140}
141
142/// A concrete, read-only proposal. Applying it requires a separate user action.
143#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
144#[serde(deny_unknown_fields)]
145pub struct LocalRemoteRepair {
146    pub path: PathBuf,
147    pub branch: String,
148    pub missing_remote: String,
149    pub replacement_remote: String,
150    pub fetch_url: String,
151    pub push_urls: Vec<String>,
152}
153
154impl std::fmt::Display for LocalRemoteRepair {
155    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
156        write!(
157            f,
158            "{}: branch {:?} tracks missing remote {:?}. Repair tracking to {:?}? Fetch: {}; push: {}",
159            self.path.display(),
160            self.branch,
161            self.missing_remote,
162            self.replacement_remote,
163            self.fetch_url,
164            self.push_urls.join(", ")
165        )
166    }
167}
168
169impl std::error::Error for LocalRemoteRepair {}
170
171/// Inspect every local source without changing host Git configuration.
172pub fn repository_remote_repairs(
173    bundle: &ProjectBundle,
174    executor: &impl CommandExecutor,
175) -> Result<Vec<LocalRemoteRepair>> {
176    let mut repairs = Vec::new();
177    for repository in &bundle.repositories {
178        if let Some(path) = &repository.local {
179            match resolve_local_repository(path, executor) {
180                Ok(_) => {}
181                Err(error) => match error.downcast_ref::<LocalRemoteRepair>() {
182                    Some(repair) => repairs.push(repair.clone()),
183                    None => return Err(error.context(format!("repository {:?}", repository.id))),
184                },
185            }
186        }
187    }
188    Ok(repairs)
189}
190
191/// Apply only reviewed proposals belonging to this bundle, after rechecking
192/// their branch, stale setting, and destination against the current checkout.
193pub fn apply_repository_remote_repairs(
194    bundle: &ProjectBundle,
195    repairs: &[LocalRemoteRepair],
196    executor: &impl CommandExecutor,
197) -> Result<()> {
198    for repair in repairs {
199        anyhow::ensure!(
200            bundle
201                .repositories
202                .iter()
203                .any(|repository| repository.local.as_ref() == Some(&repair.path)),
204            "remote repair repository is not part of the selected bundle"
205        );
206        let current = resolve_local_repository(&repair.path, executor);
207        anyhow::ensure!(
208            current
209                .as_ref()
210                .err()
211                .and_then(|error| error.downcast_ref::<LocalRemoteRepair>())
212                == Some(repair),
213            "repository configuration changed; check the repository again before repairing tracking"
214        );
215        git_output(
216            &repair.path,
217            [
218                "config",
219                "--local",
220                "--replace-all",
221                &format!("branch.{}.remote", repair.branch),
222                &repair.replacement_remote,
223            ],
224            executor,
225            "repair branch remote tracking",
226        )?;
227    }
228    Ok(())
229}
230
231fn default_fetch_remote(remotes: &[String]) -> Result<String> {
232    match remotes {
233        [] => bail!("repository has no configured Git remotes"),
234        [remote] => Ok(remote.clone()),
235        _ if remotes.iter().any(|remote| remote == "origin") => Ok("origin".to_owned()),
236        _ => bail!(
237            "repository has multiple Git remotes but no current-branch remote or `origin` to select"
238        ),
239    }
240}
241
242fn resolve_local_repository_with_remote(
243    path: &Path,
244    executor: &impl CommandExecutor,
245    replacement: Option<&str>,
246) -> Result<NetworkGitSource> {
247    let branch = git_text(
248        path,
249        ["branch", "--show-current"],
250        executor,
251        "read current branch",
252    )?;
253    let branch = branch.trim();
254    let branch = (!branch.is_empty()).then_some(branch);
255
256    let remote_output = git_output(path, ["remote"], executor, "list Git remotes")?;
257    let remotes = parse_lines(&remote_output.stdout, "Git remote names")?;
258    let branch_remote = branch
259        .map(|branch| format!("branch.{branch}.remote"))
260        .map(|key| git_config(path, &key, executor));
261    let branch_remote = match branch_remote {
262        Some(result) => result?,
263        None => None,
264    };
265
266    let fetch_remote = if let Some(remote) = replacement.map(str::to_owned).or(branch_remote) {
267        ensure_remote_name(&remote, "current branch")?;
268        if !remotes.iter().any(|name| name == &remote) {
269            if let Ok(replacement_remote) = default_fetch_remote(&remotes) {
270                let source = resolve_local_repository_with_remote(
271                    path,
272                    executor,
273                    Some(&replacement_remote),
274                )?;
275                return Err(LocalRemoteRepair {
276                    path: path.to_path_buf(),
277                    branch: branch
278                        .context("missing branch for remote tracking repair")?
279                        .to_owned(),
280                    missing_remote: remote,
281                    replacement_remote,
282                    fetch_url: display_url(&source.fetch_url),
283                    push_urls: source
284                        .push_urls
285                        .iter()
286                        .map(|url| display_url(url))
287                        .collect(),
288                }
289                .into());
290            }
291            bail!("current branch names Git remote {remote:?}, but that remote is not configured");
292        }
293        remote
294    } else {
295        default_fetch_remote(&remotes)?
296    };
297
298    let push_remote = if let Some(branch) = branch {
299        match git_config(path, &format!("branch.{branch}.pushRemote"), executor)? {
300            Some(remote) => remote,
301            None => git_config(path, "remote.pushDefault", executor)?
302                .unwrap_or_else(|| fetch_remote.clone()),
303        }
304    } else {
305        git_config(path, "remote.pushDefault", executor)?.unwrap_or_else(|| fetch_remote.clone())
306    };
307    ensure_remote_name(&push_remote, "push")?;
308    if !remotes.iter().any(|name| name == &push_remote) {
309        bail!(
310            "push configuration names Git remote {push_remote:?}, but that remote is not configured"
311        );
312    }
313
314    let fetch_url = remote_url(path, &fetch_remote, false, executor)?;
315    let push_urls = remote_urls(path, &push_remote, true, executor)?;
316    validate_network_url(&fetch_url).with_context(|| {
317        format!(
318            "fetch URL {} for Git remote {fetch_remote:?}",
319            display_url(&fetch_url)
320        )
321    })?;
322    for push_url in &push_urls {
323        validate_network_url(push_url).with_context(|| {
324            format!(
325                "push URL {} for Git remote {push_remote:?}",
326                display_url(push_url)
327            )
328        })?;
329    }
330    Ok(NetworkGitSource {
331        fetch_url,
332        push_urls,
333    })
334}
335
336fn git_command(path: &Path, args: impl IntoIterator<Item = impl Into<String>>) -> CommandSpec {
337    let mut all = vec!["-C".to_owned(), path.to_string_lossy().into_owned()];
338    all.extend(args.into_iter().map(Into::into));
339    let mut command = CommandSpec::new("git", all);
340    command
341        .env
342        .insert("GIT_TERMINAL_PROMPT".to_owned(), "0".to_owned());
343    command
344        .env
345        .insert("GIT_NO_LAZY_FETCH".to_owned(), "1".to_owned());
346    if std::env::var_os("GIT_SSH_COMMAND").is_none() {
347        command.env.insert(
348            "GIT_SSH_COMMAND".to_owned(),
349            "ssh -o BatchMode=yes -o StrictHostKeyChecking=accept-new -o ConnectTimeout=15"
350                .to_owned(),
351        );
352    }
353    command
354}
355
356fn git_output(
357    path: &Path,
358    args: impl IntoIterator<Item = impl Into<String>>,
359    executor: &impl CommandExecutor,
360    purpose: &str,
361) -> Result<CommandOutput> {
362    if executor.cancellation_requested() {
363        bail!("operation cancelled while {purpose}");
364    }
365    let command = git_command(path, args);
366    let output = executor
367        .execute(&command)
368        .with_context(|| format!("{purpose} in {}", path.display()))?;
369    if executor.cancellation_requested() {
370        bail!("operation cancelled while {purpose}");
371    }
372    if output.status != 0 {
373        let detail = String::from_utf8_lossy(&output.stderr);
374        let detail = detail.trim();
375        if detail.is_empty() {
376            bail!(
377                "{purpose} in {} failed with status {}",
378                path.display(),
379                output.status
380            );
381        }
382        bail!("{purpose} in {} failed: {detail}", path.display());
383    }
384    Ok(output)
385}
386
387fn git_text(
388    path: &Path,
389    args: impl IntoIterator<Item = impl Into<String>>,
390    executor: &impl CommandExecutor,
391    purpose: &str,
392) -> Result<String> {
393    let output = git_output(path, args, executor, purpose)?;
394    String::from_utf8(output.stdout).with_context(|| format!("decode {purpose} output"))
395}
396
397fn git_config(path: &Path, key: &str, executor: &impl CommandExecutor) -> Result<Option<String>> {
398    let output = git_command(path, ["config", "--get", key]);
399    if executor.cancellation_requested() {
400        bail!("operation cancelled while read Git configuration");
401    }
402    let output = executor
403        .execute(&output)
404        .with_context(|| format!("read Git configuration key {key:?}"))?;
405    if executor.cancellation_requested() {
406        bail!("operation cancelled while read Git configuration");
407    }
408    match output.status {
409        0 => Ok(Some(
410            String::from_utf8(output.stdout)
411                .with_context(|| format!("decode Git configuration key {key:?}"))?
412                .trim()
413                .to_owned(),
414        )),
415        1 => Ok(None),
416        status => {
417            let detail = String::from_utf8_lossy(&output.stderr);
418            let detail = detail.trim();
419            if detail.is_empty() {
420                bail!("read Git configuration key {key:?} failed with status {status}");
421            }
422            bail!("read Git configuration key {key:?} failed: {detail}");
423        }
424    }
425}
426
427fn parse_lines(bytes: &[u8], kind: &str) -> Result<Vec<String>> {
428    let text = String::from_utf8(bytes.to_vec()).with_context(|| format!("decode {kind}"))?;
429    text.lines()
430        .map(str::trim)
431        .filter(|line| !line.is_empty())
432        .map(|line| {
433            if line.chars().any(char::is_whitespace) {
434                bail!("{kind} contains an invalid whitespace-bearing entry");
435            }
436            Ok(line.to_owned())
437        })
438        .collect()
439}
440
441fn ensure_remote_name(name: &str, role: &str) -> Result<()> {
442    if name.is_empty() || name == "." || name.chars().any(char::is_whitespace) {
443        bail!("{role} Git remote selection is empty or refers to the local repository");
444    }
445    Ok(())
446}
447
448fn remote_url(
449    path: &Path,
450    remote: &str,
451    push: bool,
452    executor: &impl CommandExecutor,
453) -> Result<String> {
454    let urls = remote_urls(path, remote, push, executor)?;
455    urls.into_iter()
456        .next()
457        .ok_or_else(|| anyhow::anyhow!("Git remote {remote:?} has no configured URL"))
458}
459
460fn remote_urls(
461    path: &Path,
462    remote: &str,
463    push: bool,
464    executor: &impl CommandExecutor,
465) -> Result<Vec<String>> {
466    let args = if push {
467        vec![
468            "remote".to_owned(),
469            "get-url".to_owned(),
470            "--push".to_owned(),
471            "--all".to_owned(),
472            remote.to_owned(),
473        ]
474    } else {
475        vec!["remote".to_owned(), "get-url".to_owned(), remote.to_owned()]
476    };
477    let output = git_output(
478        path,
479        args,
480        executor,
481        if push {
482            "read Git push URL"
483        } else {
484            "read Git fetch URL"
485        },
486    )?;
487    let urls = parse_lines(
488        &output.stdout,
489        if push {
490            "Git push URLs"
491        } else {
492            "Git fetch URL"
493        },
494    )?;
495    if urls.is_empty() {
496        bail!(
497            "Git remote {remote:?} has no {} URL",
498            if push { "push" } else { "fetch" }
499        );
500    }
501    Ok(urls)
502}
503
504#[cfg(test)]
505mod tests {
506    use super::*;
507    use crate::targets::ProcessExecutor;
508    use std::fs;
509
510    fn git(path: &Path, args: &[&str]) {
511        let output = Command::new("git")
512            .args(args)
513            .current_dir(path)
514            .output()
515            .unwrap();
516        assert!(
517            output.status.success(),
518            "{}",
519            String::from_utf8_lossy(&output.stderr)
520        );
521    }
522
523    #[test]
524    fn local_status_distinguishes_clean_and_dirty_repositories() {
525        let directory = tempfile::tempdir().unwrap();
526        git(directory.path(), &["init", "-q", "-b", "main"]);
527        git(directory.path(), &["config", "user.name", "Hel Test"]);
528        git(
529            directory.path(),
530            &["config", "user.email", "hel@example.test"],
531        );
532        fs::write(directory.path().join("tracked"), "clean").unwrap();
533        git(directory.path(), &["add", "."]);
534        git(directory.path(), &["commit", "-qm", "base"]);
535        assert_eq!(local_status(directory.path()).unwrap(), None);
536        fs::write(directory.path().join("untracked"), "dirty").unwrap();
537        assert!(
538            local_status(directory.path())
539                .unwrap()
540                .unwrap()
541                .contains("untracked")
542        );
543    }
544
545    #[test]
546    fn canonical_repository_maps_a_linked_worktree_to_its_main_repository() {
547        let directory = tempfile::tempdir().unwrap();
548        let main = directory.path().join("main");
549        fs::create_dir_all(&main).unwrap();
550        git(&main, &["init", "-q", "-b", "main"]);
551        git(&main, &["config", "user.name", "Hel Test"]);
552        git(&main, &["config", "user.email", "hel@example.test"]);
553        fs::write(main.join("tracked"), "clean").unwrap();
554        git(&main, &["add", "."]);
555        git(&main, &["commit", "-qm", "base"]);
556        let worktree = directory.path().join("main2");
557        git(
558            &main,
559            &[
560                "worktree",
561                "add",
562                "-q",
563                "-b",
564                "side",
565                worktree.to_str().unwrap(),
566            ],
567        );
568
569        let expected = fs::canonicalize(&main).unwrap();
570        assert_eq!(canonical_repository(&main).unwrap(), expected);
571        assert_eq!(canonical_repository(&worktree).unwrap(), expected);
572    }
573
574    fn initialized_repository() -> tempfile::TempDir {
575        let directory = tempfile::tempdir().unwrap();
576        git(directory.path(), &["init", "-q", "-b", "main"]);
577        directory
578    }
579
580    fn repair_bundle(path: &Path) -> ProjectBundle {
581        ProjectBundle {
582            primary_repo: "repo".into(),
583            repositories: vec![crate::config::ProjectRepository {
584                id: "repo".into(),
585                local: Some(path.to_path_buf()),
586                github: None,
587                destination: "repo".into(),
588                git_ref: None,
589            }],
590        }
591    }
592
593    #[test]
594    fn stale_tracking_repair_is_reviewable_and_preserves_fetch_and_push_intent() {
595        for remote in ["origin", "sole"] {
596            let directory = initialized_repository();
597            let path = directory.path();
598            git(
599                path,
600                &["remote", "add", remote, "https://example.com/fetch.git"],
601            );
602            git(path, &["config", "branch.main.remote", "missing"]);
603            git(path, &["config", "branch.main.merge", "refs/heads/main"]);
604            git(
605                path,
606                &[
607                    "config",
608                    &format!("remote.{remote}.pushurl"),
609                    "ssh://git@example.com/push.git",
610                ],
611            );
612            if remote == "origin" {
613                git(
614                    path,
615                    &[
616                        "remote",
617                        "add",
618                        "publish",
619                        "https://example.com/publish.git",
620                    ],
621                );
622                git(path, &["config", "branch.main.pushRemote", "publish"]);
623            }
624            let bundle = repair_bundle(path);
625            let repairs = repository_remote_repairs(&bundle, &ProcessExecutor).unwrap();
626            assert_eq!(repairs.len(), 1);
627            assert_eq!(repairs[0].replacement_remote, remote);
628            assert_eq!(repairs[0].fetch_url, "https://example.com/fetch.git");
629            let push_url = if remote == "origin" {
630                "https://example.com/publish.git"
631            } else {
632                "ssh://git@example.com/push.git"
633            };
634            assert_eq!(repairs[0].push_urls, [display_url(push_url)]);
635            assert_eq!(
636                git_config(path, "branch.main.remote", &ProcessExecutor)
637                    .unwrap()
638                    .as_deref(),
639                Some("missing")
640            );
641            apply_repository_remote_repairs(&bundle, &repairs, &ProcessExecutor).unwrap();
642            assert_eq!(
643                git_config(path, "branch.main.remote", &ProcessExecutor)
644                    .unwrap()
645                    .as_deref(),
646                Some(remote)
647            );
648            assert_eq!(
649                git_config(path, "branch.main.merge", &ProcessExecutor)
650                    .unwrap()
651                    .as_deref(),
652                Some("refs/heads/main")
653            );
654            let source = resolve_local_repository(path, &ProcessExecutor).unwrap();
655            assert_eq!(source.fetch_url, repairs[0].fetch_url);
656            assert_eq!(source.push_urls, [push_url]);
657            assert!(
658                repository_remote_repairs(&bundle, &ProcessExecutor)
659                    .unwrap()
660                    .is_empty()
661            );
662        }
663    }
664
665    #[test]
666    fn tracking_repair_rejects_changed_destinations_and_unrelated_repositories() {
667        let directory = initialized_repository();
668        let path = directory.path();
669        git(
670            path,
671            &["remote", "add", "origin", "https://example.com/fetch.git"],
672        );
673        git(path, &["config", "branch.main.remote", "missing"]);
674        let bundle = repair_bundle(path);
675        let repairs = repository_remote_repairs(&bundle, &ProcessExecutor).unwrap();
676        let other = initialized_repository();
677        assert!(
678            apply_repository_remote_repairs(
679                &repair_bundle(other.path()),
680                &repairs,
681                &ProcessExecutor
682            )
683            .is_err()
684        );
685        git(
686            path,
687            &[
688                "remote",
689                "set-url",
690                "origin",
691                "https://example.com/changed.git",
692            ],
693        );
694        assert!(apply_repository_remote_repairs(&bundle, &repairs, &ProcessExecutor).is_err());
695        assert_eq!(
696            git_config(path, "branch.main.remote", &ProcessExecutor)
697                .unwrap()
698                .as_deref(),
699            Some("missing")
700        );
701    }
702
703    #[test]
704    fn tracking_repair_does_not_offer_an_unusable_or_ambiguous_destination() {
705        let directory = initialized_repository();
706        let path = directory.path();
707        git(path, &["config", "branch.main.remote", "missing"]);
708        git(path, &["remote", "add", "origin", "../local"]);
709        let bundle = repair_bundle(path);
710        assert!(repository_remote_repairs(&bundle, &ProcessExecutor).is_err());
711        git(path, &["remote", "rename", "origin", "first"]);
712        git(
713            path,
714            &[
715                "remote",
716                "set-url",
717                "first",
718                "https://example.com/first.git",
719            ],
720        );
721        git(
722            path,
723            &["remote", "add", "second", "https://example.com/second.git"],
724        );
725        assert!(repository_remote_repairs(&bundle, &ProcessExecutor).is_err());
726    }
727
728    #[test]
729    fn resolver_uses_a_nonorigin_sole_remote_for_fetch_and_push() {
730        let directory = initialized_repository();
731        git(
732            directory.path(),
733            &[
734                "remote",
735                "add",
736                "upstream",
737                "https://example.com/upstream.git",
738            ],
739        );
740
741        let source = resolve_local_repository(directory.path(), &ProcessExecutor).unwrap();
742        assert_eq!(source.fetch_url, "https://example.com/upstream.git");
743        assert_eq!(source.push_urls, ["https://example.com/upstream.git"]);
744    }
745
746    #[test]
747    fn resolver_honors_branch_push_remote_and_multiple_push_urls() {
748        let directory = initialized_repository();
749        git(
750            directory.path(),
751            &["remote", "add", "fetch", "https://example.com/fetch.git"],
752        );
753        git(
754            directory.path(),
755            &[
756                "remote",
757                "add",
758                "publish",
759                "https://example.com/publish.git",
760            ],
761        );
762        git(directory.path(), &["config", "branch.main.remote", "fetch"]);
763        git(
764            directory.path(),
765            &["config", "branch.main.pushRemote", "publish"],
766        );
767        git(
768            directory.path(),
769            &[
770                "config",
771                "--add",
772                "remote.publish.pushurl",
773                "ssh://git@example.com/one.git",
774            ],
775        );
776        git(
777            directory.path(),
778            &[
779                "config",
780                "--add",
781                "remote.publish.pushurl",
782                "ssh://git@example.com/two.git",
783            ],
784        );
785
786        let source = resolve_local_repository(directory.path(), &ProcessExecutor).unwrap();
787        assert_eq!(source.fetch_url, "https://example.com/fetch.git");
788        assert_eq!(
789            source.push_urls,
790            [
791                "ssh://git@example.com/one.git",
792                "ssh://git@example.com/two.git"
793            ]
794        );
795    }
796
797    #[test]
798    fn resolver_uses_remote_push_default_when_branch_has_no_push_remote() {
799        let directory = initialized_repository();
800        git(
801            directory.path(),
802            &["remote", "add", "fetch", "https://example.com/fetch.git"],
803        );
804        git(
805            directory.path(),
806            &[
807                "remote",
808                "add",
809                "publish",
810                "https://example.com/publish.git",
811            ],
812        );
813        git(directory.path(), &["config", "branch.main.remote", "fetch"]);
814        git(
815            directory.path(),
816            &["config", "remote.pushDefault", "publish"],
817        );
818
819        let source = resolve_local_repository(directory.path(), &ProcessExecutor).unwrap();
820        assert_eq!(source.fetch_url, "https://example.com/fetch.git");
821        assert_eq!(source.push_urls, ["https://example.com/publish.git"]);
822    }
823
824    #[test]
825    fn resolver_applies_fetch_and_push_url_rewrites() {
826        let directory = initialized_repository();
827        git(
828            directory.path(),
829            &["remote", "add", "fetch", "fetch:org/repo.git"],
830        );
831        git(
832            directory.path(),
833            &["remote", "add", "publish", "publish:org/repo.git"],
834        );
835        git(directory.path(), &["config", "branch.main.remote", "fetch"]);
836        git(
837            directory.path(),
838            &["config", "branch.main.pushRemote", "publish"],
839        );
840        git(
841            directory.path(),
842            &["config", "url.https://example.com/.insteadOf", "fetch:"],
843        );
844        git(
845            directory.path(),
846            &[
847                "config",
848                "url.ssh://git@example.com/.pushInsteadOf",
849                "publish:",
850            ],
851        );
852
853        let source = resolve_local_repository(directory.path(), &ProcessExecutor).unwrap();
854        assert_eq!(source.fetch_url, "https://example.com/org/repo.git");
855        assert_eq!(source.push_urls, ["ssh://git@example.com/org/repo.git"]);
856    }
857
858    #[test]
859    fn resolver_rejects_local_paths_and_ignores_dirty_or_unpublished_state() {
860        let directory = initialized_repository();
861        fs::write(directory.path().join("untracked"), "work").unwrap();
862        git(
863            directory.path(),
864            &["remote", "add", "origin", "../another-repository"],
865        );
866        let error = resolve_local_repository(directory.path(), &ProcessExecutor).unwrap_err();
867        assert!(
868            format!("{error:#}").contains("local repository path"),
869            "{error:#}"
870        );
871    }
872
873    #[test]
874    fn resolver_reports_missing_and_ambiguous_remote_selection() {
875        let no_remote = initialized_repository();
876        let error = resolve_local_repository(no_remote.path(), &ProcessExecutor).unwrap_err();
877        assert!(error.to_string().contains("no configured Git remotes"));
878
879        let ambiguous = initialized_repository();
880        git(
881            ambiguous.path(),
882            &["remote", "add", "first", "https://example.com/first.git"],
883        );
884        git(
885            ambiguous.path(),
886            &["remote", "add", "second", "https://example.com/second.git"],
887        );
888        let error = resolve_local_repository(ambiguous.path(), &ProcessExecutor).unwrap_err();
889        assert!(error.to_string().contains("multiple Git remotes"));
890    }
891}