1use 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
59pub 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 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
129pub 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#[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
171pub 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
191pub 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}