Skip to content

Commit 3edb288

Browse files
timabellclaude
andcommitted
feat: Add --dry-run flag to sync command (#214)
Add --dry-run option to sync --read-remotes and sync --write-remotes. When set, shows what would change without making any modifications. Sync output is now TSV with direction header, column headers, and per-remote status lines (added/removed/modified/unchanged). Repos with no changes are silently skipped; repos with any change show all their remotes. Read and write output is a symmetric mirror of each other. write-remotes now also updates URLs for existing remotes (set-url), not just adding missing ones. It will not remove any remotes. Direction headers clarify what is being updated: Updating .gitopolis.toml with values from git repo remotes... Updating git repo remotes with values from .gitopolis.toml... Rewrote sync e2e tests: 4 core tests (read/write x real/dry-run) each cover added, removed, modified, unchanged, and no-remotes edge cases with full TOML assertions. Added test helpers for git repo setup. Fixes #214 Prompts: - do this Add dry-run arg for sync commands · Issue #214 · timabell/gitopolis - (approved plan) - commit (x4, rejected cat/heredoc/tempfile approaches) - commit - bbc60c3 - dry run should output what it is changing to, and needs a clearer message in both directions for all repos. output all the before/after urls - newlines between repos - direction isn't clear, output needs to somehow indicate config->repo - skip repos with no change but show all remotes for repos with any change - new output format (provided example: reponame status remote url [url]) - include headers for tsv - i didn't get any output for read but did for write, what gives - Updating git repo remotes with values from .gitopolis.toml ... - ideally we'd have 4 tests for read/write dry/real covering added/removed/unchanged plus edge cases - these should be an exact mirror no? (showed asymmetric output) - don't output no changes that's not unixy - always assert full toml. write prompt list so far to a temp file in repo before compaction - fork URL not changed (write-remotes doesn't modify) - it should - okay to not remove remotes though (add to command doc that it won't remove any remotes) - yes, and remember prompts from tmp file and since then - amend last commit, expanding description, keep subject line intact Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 011c88e commit 3edb288

5 files changed

Lines changed: 479 additions & 125 deletions

File tree

src/git.rs

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ 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 set_remote_url(&self, path: &str, remote_name: &str, url: &str);
1213
/// Clone a repo. Returns Ok(true) if cloned, Ok(false) if already exists (skipped).
1314
fn clone(
1415
&self,
@@ -75,6 +76,19 @@ impl Git for GitImpl {
7576
}
7677
}
7778

79+
fn set_remote_url(&self, path: &str, remote_name: &str, url: &str) {
80+
let output = Command::new("git")
81+
.current_dir(path)
82+
.args(["remote", "set-url", remote_name, url])
83+
.output()
84+
.expect("Error running git remote set-url");
85+
if !output.status.success() {
86+
let stderr =
87+
String::from_utf8(output.stderr).expect("Error converting stderr to string");
88+
eprintln!("Warning: Failed to set URL for remote {remote_name}: {stderr}");
89+
}
90+
}
91+
7892
fn clone(
7993
&self,
8094
path: &str,

src/gitopolis.rs

Lines changed: 95 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
use crate::git::Git;
22
use crate::gitopolis::GitopolisError::*;
3-
use crate::repos::{Repo, RepoInfo, Repos};
3+
use crate::repos::{Remote, Repo, RepoInfo, Repos};
44
use crate::storage::Storage;
55
use crate::tag_filter::TagFilter;
66
use log::info;
@@ -145,21 +145,53 @@ impl Gitopolis {
145145
Ok(flat)
146146
}
147147

148-
pub fn sync_read_remotes(&mut self, filter: &TagFilter) -> Result<(), GitopolisError> {
148+
pub fn sync_read_remotes(
149+
&mut self,
150+
filter: &TagFilter,
151+
dry_run: bool,
152+
) -> Result<(), GitopolisError> {
149153
let mut repos = self.load()?;
150154
let repo_list = self.list(filter)?;
151155
let mut error_count = 0;
152156

153-
for repo in repo_list {
157+
info!("Updating .gitopolis.toml with values from git repo remotes...");
158+
info!("repo\tstatus\tremote\told_url\tnew_url");
159+
for repo in &repo_list {
154160
match self.git.read_all_remotes(repo.path.clone()) {
155-
Ok(remotes) => {
156-
// Find the repo in the mutable repos structure and update its remotes
157-
if let Some(repo_mut) = repos.find_repo(repo.path.clone()) {
158-
repo_mut.remotes.clear();
159-
for (name, url) in remotes {
160-
repo_mut.add_remote(name, url);
161+
Ok(git_remotes) => {
162+
if !Self::remotes_differ(&repo.remotes, &git_remotes) {
163+
continue;
164+
}
165+
166+
for (name, url) in &git_remotes {
167+
match repo.remotes.get(name) {
168+
Some(existing) if existing.url == *url => {
169+
info!("{}\tunchanged\t{}\t{}", repo.path, name, url);
170+
}
171+
Some(existing) => {
172+
info!(
173+
"{}\tmodified\t{}\t{}\t{}",
174+
repo.path, name, existing.url, url
175+
);
176+
}
177+
None => {
178+
info!("{}\tadded\t{}\t\t{}", repo.path, name, url);
179+
}
180+
}
181+
}
182+
for (name, remote) in &repo.remotes {
183+
if !git_remotes.contains_key(name) {
184+
info!("{}\tremoved\t{}\t{}", repo.path, name, remote.url);
185+
}
186+
}
187+
188+
if !dry_run {
189+
if let Some(repo_mut) = repos.find_repo(repo.path.clone()) {
190+
repo_mut.remotes.clear();
191+
for (name, url) in git_remotes {
192+
repo_mut.add_remote(name, url);
193+
}
161194
}
162-
info!("Updated {} with remotes from git", repo.path);
163195
}
164196
}
165197
Err(_) => {
@@ -169,7 +201,9 @@ impl Gitopolis {
169201
}
170202
}
171203

172-
self.save(repos)?;
204+
if !dry_run {
205+
self.save(repos)?;
206+
}
173207

174208
if error_count > 0 {
175209
eprintln!("{error_count} repos failed to sync");
@@ -179,12 +213,33 @@ impl Gitopolis {
179213
Ok(())
180214
}
181215

182-
pub fn sync_write_remotes(&self, filter: &TagFilter) -> Result<(), GitopolisError> {
216+
fn remotes_differ(
217+
config_remotes: &BTreeMap<String, Remote>,
218+
git_remotes: &BTreeMap<String, String>,
219+
) -> bool {
220+
if config_remotes.len() != git_remotes.len() {
221+
return true;
222+
}
223+
for (name, url) in git_remotes {
224+
match config_remotes.get(name) {
225+
Some(existing) if existing.url == *url => {}
226+
_ => return true,
227+
}
228+
}
229+
false
230+
}
231+
232+
pub fn sync_write_remotes(
233+
&self,
234+
filter: &TagFilter,
235+
dry_run: bool,
236+
) -> Result<(), GitopolisError> {
183237
let repo_list = self.list(filter)?;
184238
let mut error_count = 0;
185239

186-
for repo in repo_list {
187-
// Get current remotes from git
240+
info!("Updating git repo remotes with values from .gitopolis.toml...");
241+
info!("repo\tstatus\tremote\told_url\tnew_url");
242+
for repo in &repo_list {
188243
let current_remotes = match self.git.read_all_remotes(repo.path.clone()) {
189244
Ok(remotes) => remotes,
190245
Err(_) => {
@@ -194,11 +249,33 @@ impl Gitopolis {
194249
}
195250
};
196251

197-
// Add any missing remotes from config
252+
if !Self::remotes_differ(&repo.remotes, &current_remotes) {
253+
continue;
254+
}
255+
// Show config remotes (these are the source of truth for write)
198256
for (name, remote) in &repo.remotes {
199-
if !current_remotes.contains_key(name) {
200-
self.git.add_remote(&repo.path, name, &remote.url);
201-
info!("Added remote {} to {}", name, repo.path);
257+
match current_remotes.get(name) {
258+
Some(url) if *url == remote.url => {
259+
info!("{}\tunchanged\t{}\t{}", repo.path, name, remote.url);
260+
}
261+
Some(url) => {
262+
info!("{}\tmodified\t{}\t{}\t{}", repo.path, name, url, remote.url);
263+
if !dry_run {
264+
self.git.set_remote_url(&repo.path, name, &remote.url);
265+
}
266+
}
267+
None => {
268+
info!("{}\tadded\t{}\t\t{}", repo.path, name, remote.url);
269+
if !dry_run {
270+
self.git.add_remote(&repo.path, name, &remote.url);
271+
}
272+
}
273+
}
274+
}
275+
// Show git remotes not in config (mirror of read's "added")
276+
for (name, url) in &current_remotes {
277+
if !repo.remotes.contains_key(name) {
278+
info!("{}\tremoved\t{}\t{}", repo.path, name, url);
202279
}
203280
}
204281
}

src/main.rs

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -89,9 +89,12 @@ enum Commands {
8989
/// Update .gitopolis.toml from remotes in git repositories
9090
#[arg(long, conflicts_with = "write_remotes")]
9191
read_remotes: bool,
92-
/// Update git repositories with remotes from .gitopolis.toml
92+
/// Update git repositories with remotes from .gitopolis.toml (adds missing remotes and updates URLs, but will not remove any remotes)
9393
#[arg(long, conflicts_with = "read_remotes")]
9494
write_remotes: bool,
95+
/// Show what would be changed without making any modifications
96+
#[arg(long)]
97+
dry_run: bool,
9598
/// Filter by tags. Comma-separated tags use AND logic (e.g., "foo,bar" = foo AND bar).
9699
/// Multiple --tag flags use OR logic (e.g., "--tag foo,bar --tag baz" = (foo AND bar) OR baz).
97100
#[arg(short, long)]
@@ -188,16 +191,17 @@ fn main() {
188191
Some(Commands::Sync {
189192
read_remotes,
190193
write_remotes,
194+
dry_run,
191195
tag: tag_args,
192196
}) => {
193197
let filter = TagFilter::from_cli_args(tag_args);
194198
if *read_remotes {
195199
init_gitopolis()
196-
.sync_read_remotes(&filter)
200+
.sync_read_remotes(&filter, *dry_run)
197201
.expect("Sync read failed");
198202
} else if *write_remotes {
199203
init_gitopolis()
200-
.sync_write_remotes(&filter)
204+
.sync_write_remotes(&filter, *dry_run)
201205
.expect("Sync write failed");
202206
} else {
203207
eprintln!("Error: Must specify either --read-remotes or --write-remotes");

0 commit comments

Comments
 (0)