Skip to content

Commit def4997

Browse files
committed
Improve multi-remote handling
- fix: Skip adding remotes when repo already exists during clone - feat: Respect remote names and default to origin when cloning
2 parents ee82e95 + 0d4a840 commit def4997

4 files changed

Lines changed: 230 additions & 11 deletions

File tree

src/git.rs

Lines changed: 23 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,13 @@ pub trait Git {
99
fn read_url(&self, path: String, remote_name: String) -> Result<String, GitopolisError>;
1010
fn read_all_remotes(&self, path: String) -> Result<BTreeMap<String, String>, GitopolisError>;
1111
fn add_remote(&self, path: &str, remote_name: &str, url: &str);
12-
fn clone(&self, path: &str, url: &str) -> Result<(), GitopolisError>;
12+
/// Clone a repo. Returns Ok(true) if cloned, Ok(false) if already exists (skipped).
13+
fn clone(
14+
&self,
15+
path: &str,
16+
url: &str,
17+
remote_name: Option<&str>,
18+
) -> Result<bool, GitopolisError>;
1319
}
1420

1521
pub struct GitImpl {}
@@ -69,14 +75,26 @@ impl Git for GitImpl {
6975
}
7076
}
7177

72-
fn clone(&self, path: &str, url: &str) -> Result<(), GitopolisError> {
78+
fn clone(
79+
&self,
80+
path: &str,
81+
url: &str,
82+
remote_name: Option<&str>,
83+
) -> Result<bool, GitopolisError> {
7384
if Path::new(path).exists() {
7485
println!("🏢 {path}> Already exists, skipped.");
75-
return Ok(());
86+
return Ok(false);
7687
}
7788
println!("🏢 {path}> Cloning {url} ...");
89+
let mut args = vec!["clone"];
90+
if let Some(name) = remote_name {
91+
args.push("--origin");
92+
args.push(name);
93+
}
94+
args.push(url);
95+
args.push(path);
7896
let output = Command::new("git")
79-
.args(["clone".to_string(), url.to_string(), path.to_string()].to_vec())
97+
.args(args)
8098
.output()
8199
.expect("Error running git clone");
82100
let stdout = String::from_utf8(output.stdout).expect("Error converting stdout to string");
@@ -90,6 +108,6 @@ impl Git for GitImpl {
90108
});
91109
}
92110

93-
Ok(())
111+
Ok(true)
94112
}
95113
}

src/gitopolis.rs

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -101,15 +101,22 @@ impl Gitopolis {
101101

102102
if let Some(clone_remote) = repo.remotes.get(clone_remote_name) {
103103
// Clone the repo
104-
match self.git.clone(repo.path.as_str(), &clone_remote.url) {
105-
Ok(()) => {
106-
// Add all other remotes
104+
match self.git.clone(
105+
repo.path.as_str(),
106+
&clone_remote.url,
107+
Some(clone_remote_name),
108+
) {
109+
Ok(true) => {
110+
// Clone succeeded, add all other remotes
107111
for (name, remote) in &repo.remotes {
108112
if name != clone_remote_name {
109113
self.git.add_remote(&repo.path, name, &remote.url);
110114
}
111115
}
112116
}
117+
Ok(false) => {
118+
// Repo already exists, skip adding remotes
119+
}
113120
Err(_) => {
114121
eprintln!("Warning: Could not clone {}", repo.path);
115122
error_count += 1;
@@ -238,7 +245,7 @@ impl Gitopolis {
238245
};
239246

240247
// Clone the repository
241-
self.git.clone(&folder_name, url)?;
248+
self.git.clone(&folder_name, url, None)?;
242249

243250
// Add the repository to gitopolis
244251
self.add(folder_name.clone())?;

tests/end_to_end_tests.rs

Lines changed: 189 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2552,3 +2552,192 @@ url = \"git://example.org/repo3_origin\"
25522552
let remotes3 = String::from_utf8(output3.stdout).expect("utf8 conversion failed");
25532553
assert!(remotes3.is_empty());
25542554
}
2555+
2556+
#[test]
2557+
fn clone_multiple_remotes_prefers_origin() {
2558+
// Test that when cloning a repo with remotes aaa, origin, zzz:
2559+
// - It clones using the origin remote
2560+
// - It adds aaa and zzz as additional remotes
2561+
let temp = temp_folder();
2562+
2563+
// Create source repos for each remote
2564+
create_local_repo(&temp, "source_aaa");
2565+
create_local_repo(&temp, "source_origin");
2566+
create_local_repo(&temp, "source_zzz");
2567+
2568+
// Create state with multiple remotes including origin
2569+
let initial_state_toml = r#"[[repos]]
2570+
path = "cloned_repo"
2571+
tags = []
2572+
2573+
[repos.remotes.aaa]
2574+
name = "aaa"
2575+
url = "source_aaa"
2576+
2577+
[repos.remotes.origin]
2578+
name = "origin"
2579+
url = "source_origin"
2580+
2581+
[repos.remotes.zzz]
2582+
name = "zzz"
2583+
url = "source_zzz"
2584+
"#;
2585+
write_gitopolis_state_toml(&temp, initial_state_toml);
2586+
2587+
// Run clone
2588+
gitopolis_executable()
2589+
.current_dir(&temp)
2590+
.args(vec!["clone"])
2591+
.assert()
2592+
.success()
2593+
.stdout(predicate::str::contains("Cloning source_origin"));
2594+
2595+
// Verify all three remotes exist in cloned repo with correct names
2596+
let cloned_path = temp.path().join("cloned_repo");
2597+
2598+
let remotes_output = Command::new("git")
2599+
.current_dir(&cloned_path)
2600+
.args(vec!["remote", "--verbose"])
2601+
.output()
2602+
.expect("git remote --verbose failed");
2603+
let remotes = String::from_utf8(remotes_output.stdout).expect("utf8 conversion failed");
2604+
2605+
// git clone converts relative local paths to absolute, so strip the temp path prefix
2606+
// Use canonicalize to resolve symlinks (e.g., /var -> /private/var on macOS)
2607+
let temp_path_canonical = temp.path().canonicalize().unwrap();
2608+
let temp_path_str = temp_path_canonical.to_str().unwrap();
2609+
let remotes = remotes.replace(&format!("{}/", temp_path_str), "");
2610+
2611+
let expected_remotes = r#"aaa source_aaa (fetch)
2612+
aaa source_aaa (push)
2613+
origin source_origin (fetch)
2614+
origin source_origin (push)
2615+
zzz source_zzz (fetch)
2616+
zzz source_zzz (push)
2617+
"#;
2618+
assert_eq!(expected_remotes, remotes);
2619+
}
2620+
2621+
#[test]
2622+
fn clone_multiple_remotes_uses_first_when_no_origin() {
2623+
// Test that when cloning a repo with remotes aaa, bbb (no origin):
2624+
// - It clones using the first remote (aaa) alphabetically
2625+
// - The remote is named "aaa" (not "origin")
2626+
// - It adds bbb as an additional remote
2627+
let temp = temp_folder();
2628+
2629+
// Create source repos for each remote
2630+
create_local_repo(&temp, "source_aaa");
2631+
create_local_repo(&temp, "source_bbb");
2632+
2633+
// Create state with multiple remotes, none named origin
2634+
let initial_state_toml = r#"[[repos]]
2635+
path = "cloned_repo"
2636+
tags = []
2637+
2638+
[repos.remotes.aaa]
2639+
name = "aaa"
2640+
url = "source_aaa"
2641+
2642+
[repos.remotes.bbb]
2643+
name = "bbb"
2644+
url = "source_bbb"
2645+
"#;
2646+
write_gitopolis_state_toml(&temp, initial_state_toml);
2647+
2648+
// Run clone
2649+
gitopolis_executable()
2650+
.current_dir(&temp)
2651+
.args(vec!["clone"])
2652+
.assert()
2653+
.success()
2654+
.stdout(predicate::str::contains("Cloning source_aaa"));
2655+
2656+
// Verify remotes in cloned repo
2657+
let cloned_path = temp.path().join("cloned_repo");
2658+
2659+
let remotes_output = Command::new("git")
2660+
.current_dir(&cloned_path)
2661+
.args(vec!["remote", "--verbose"])
2662+
.output()
2663+
.expect("git remote --verbose failed");
2664+
let remotes = String::from_utf8(remotes_output.stdout).expect("utf8 conversion failed");
2665+
2666+
// git clone converts relative local paths to absolute, so strip the temp path prefix
2667+
// Use canonicalize to resolve symlinks (e.g., /var -> /private/var on macOS)
2668+
let temp_path_canonical = temp.path().canonicalize().unwrap();
2669+
let temp_path_str = temp_path_canonical.to_str().unwrap();
2670+
let remotes = remotes.replace(&format!("{}/", temp_path_str), "");
2671+
2672+
// Should have aaa and bbb remotes, NOT origin
2673+
let expected_remotes = r#"aaa source_aaa (fetch)
2674+
aaa source_aaa (push)
2675+
bbb source_bbb (fetch)
2676+
bbb source_bbb (push)
2677+
"#;
2678+
assert_eq!(expected_remotes, remotes);
2679+
}
2680+
2681+
#[test]
2682+
fn clone_skips_adding_remotes_when_repo_exists() {
2683+
// Test that when a repo already exists, clone skips it entirely
2684+
// and doesn't try to add remotes (which would fail with "remote already exists")
2685+
let temp = temp_folder();
2686+
2687+
// Create source repos
2688+
create_local_repo(&temp, "source_origin");
2689+
create_local_repo(&temp, "source_upstream");
2690+
2691+
// Pre-create the target repo with origin remote already configured
2692+
let cloned_path = temp.path().join("cloned_repo");
2693+
fs::create_dir_all(&cloned_path).expect("create repo dir failed");
2694+
Command::new("git")
2695+
.current_dir(&cloned_path)
2696+
.args(vec!["init", "--initial-branch", "main"])
2697+
.output()
2698+
.expect("git init failed");
2699+
Command::new("git")
2700+
.current_dir(&cloned_path)
2701+
.args(vec!["remote", "add", "origin", "existing_origin_url"])
2702+
.output()
2703+
.expect("git remote add failed");
2704+
2705+
// Create state with multiple remotes
2706+
let initial_state_toml = r#"[[repos]]
2707+
path = "cloned_repo"
2708+
tags = []
2709+
2710+
[repos.remotes.origin]
2711+
name = "origin"
2712+
url = "source_origin"
2713+
2714+
[repos.remotes.upstream]
2715+
name = "upstream"
2716+
url = "source_upstream"
2717+
"#;
2718+
write_gitopolis_state_toml(&temp, initial_state_toml);
2719+
2720+
// Run clone - should skip the existing repo and NOT try to add remotes
2721+
gitopolis_executable()
2722+
.current_dir(&temp)
2723+
.args(vec!["clone"])
2724+
.assert()
2725+
.success()
2726+
.stdout(predicate::str::contains("Already exists, skipped"))
2727+
// Should NOT have any warnings about failed remote adds
2728+
.stderr(predicate::str::contains("Warning").not());
2729+
2730+
// Verify the original remote is unchanged (not overwritten)
2731+
let remotes_output = Command::new("git")
2732+
.current_dir(&cloned_path)
2733+
.args(vec!["remote", "--verbose"])
2734+
.output()
2735+
.expect("git remote --verbose failed");
2736+
let remotes = String::from_utf8(remotes_output.stdout).expect("utf8 conversion failed");
2737+
2738+
// Should only have the original origin remote, not the upstream from config
2739+
let expected_remotes = r#"origin existing_origin_url (fetch)
2740+
origin existing_origin_url (push)
2741+
"#;
2742+
assert_eq!(expected_remotes, remotes);
2743+
}

tests/gitopolis_tests.rs

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -514,8 +514,13 @@ impl Git for FakeGit {
514514
// No-op for fake implementation
515515
}
516516

517-
fn clone(&self, path: &str, url: &str) -> Result<(), GitopolisError> {
517+
fn clone(
518+
&self,
519+
path: &str,
520+
url: &str,
521+
_remote_name: Option<&str>,
522+
) -> Result<bool, GitopolisError> {
518523
(self.clone_callback)(path.to_owned(), url.to_owned());
519-
Ok(())
524+
Ok(true)
520525
}
521526
}

0 commit comments

Comments
 (0)