Skip to content

Commit a7a4407

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 + ae9ed5c commit a7a4407

4 files changed

Lines changed: 226 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: 185 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2552,3 +2552,188 @@ 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+
let temp_path_str = temp.path().to_str().unwrap();
2607+
let remotes = remotes.replace(&format!("{}/", temp_path_str), "");
2608+
2609+
let expected_remotes = r#"aaa source_aaa (fetch)
2610+
aaa source_aaa (push)
2611+
origin source_origin (fetch)
2612+
origin source_origin (push)
2613+
zzz source_zzz (fetch)
2614+
zzz source_zzz (push)
2615+
"#;
2616+
assert_eq!(expected_remotes, remotes);
2617+
}
2618+
2619+
#[test]
2620+
fn clone_multiple_remotes_uses_first_when_no_origin() {
2621+
// Test that when cloning a repo with remotes aaa, bbb (no origin):
2622+
// - It clones using the first remote (aaa) alphabetically
2623+
// - The remote is named "aaa" (not "origin")
2624+
// - It adds bbb as an additional remote
2625+
let temp = temp_folder();
2626+
2627+
// Create source repos for each remote
2628+
create_local_repo(&temp, "source_aaa");
2629+
create_local_repo(&temp, "source_bbb");
2630+
2631+
// Create state with multiple remotes, none named origin
2632+
let initial_state_toml = r#"[[repos]]
2633+
path = "cloned_repo"
2634+
tags = []
2635+
2636+
[repos.remotes.aaa]
2637+
name = "aaa"
2638+
url = "source_aaa"
2639+
2640+
[repos.remotes.bbb]
2641+
name = "bbb"
2642+
url = "source_bbb"
2643+
"#;
2644+
write_gitopolis_state_toml(&temp, initial_state_toml);
2645+
2646+
// Run clone
2647+
gitopolis_executable()
2648+
.current_dir(&temp)
2649+
.args(vec!["clone"])
2650+
.assert()
2651+
.success()
2652+
.stdout(predicate::str::contains("Cloning source_aaa"));
2653+
2654+
// Verify remotes in cloned repo
2655+
let cloned_path = temp.path().join("cloned_repo");
2656+
2657+
let remotes_output = Command::new("git")
2658+
.current_dir(&cloned_path)
2659+
.args(vec!["remote", "--verbose"])
2660+
.output()
2661+
.expect("git remote --verbose failed");
2662+
let remotes = String::from_utf8(remotes_output.stdout).expect("utf8 conversion failed");
2663+
2664+
// git clone converts relative local paths to absolute, so strip the temp path prefix
2665+
let temp_path_str = temp.path().to_str().unwrap();
2666+
let remotes = remotes.replace(&format!("{}/", temp_path_str), "");
2667+
2668+
// Should have aaa and bbb remotes, NOT origin
2669+
let expected_remotes = r#"aaa source_aaa (fetch)
2670+
aaa source_aaa (push)
2671+
bbb source_bbb (fetch)
2672+
bbb source_bbb (push)
2673+
"#;
2674+
assert_eq!(expected_remotes, remotes);
2675+
}
2676+
2677+
#[test]
2678+
fn clone_skips_adding_remotes_when_repo_exists() {
2679+
// Test that when a repo already exists, clone skips it entirely
2680+
// and doesn't try to add remotes (which would fail with "remote already exists")
2681+
let temp = temp_folder();
2682+
2683+
// Create source repos
2684+
create_local_repo(&temp, "source_origin");
2685+
create_local_repo(&temp, "source_upstream");
2686+
2687+
// Pre-create the target repo with origin remote already configured
2688+
let cloned_path = temp.path().join("cloned_repo");
2689+
fs::create_dir_all(&cloned_path).expect("create repo dir failed");
2690+
Command::new("git")
2691+
.current_dir(&cloned_path)
2692+
.args(vec!["init", "--initial-branch", "main"])
2693+
.output()
2694+
.expect("git init failed");
2695+
Command::new("git")
2696+
.current_dir(&cloned_path)
2697+
.args(vec!["remote", "add", "origin", "existing_origin_url"])
2698+
.output()
2699+
.expect("git remote add failed");
2700+
2701+
// Create state with multiple remotes
2702+
let initial_state_toml = r#"[[repos]]
2703+
path = "cloned_repo"
2704+
tags = []
2705+
2706+
[repos.remotes.origin]
2707+
name = "origin"
2708+
url = "source_origin"
2709+
2710+
[repos.remotes.upstream]
2711+
name = "upstream"
2712+
url = "source_upstream"
2713+
"#;
2714+
write_gitopolis_state_toml(&temp, initial_state_toml);
2715+
2716+
// Run clone - should skip the existing repo and NOT try to add remotes
2717+
gitopolis_executable()
2718+
.current_dir(&temp)
2719+
.args(vec!["clone"])
2720+
.assert()
2721+
.success()
2722+
.stdout(predicate::str::contains("Already exists, skipped"))
2723+
// Should NOT have any warnings about failed remote adds
2724+
.stderr(predicate::str::contains("Warning").not());
2725+
2726+
// Verify the original remote is unchanged (not overwritten)
2727+
let remotes_output = Command::new("git")
2728+
.current_dir(&cloned_path)
2729+
.args(vec!["remote", "--verbose"])
2730+
.output()
2731+
.expect("git remote --verbose failed");
2732+
let remotes = String::from_utf8(remotes_output.stdout).expect("utf8 conversion failed");
2733+
2734+
// Should only have the original origin remote, not the upstream from config
2735+
let expected_remotes = r#"origin existing_origin_url (fetch)
2736+
origin existing_origin_url (push)
2737+
"#;
2738+
assert_eq!(expected_remotes, remotes);
2739+
}

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)