Skip to content

Commit c6af029

Browse files
timabellclaude
andcommitted
feat: Add ability to rename a tag (#213)
Add 'rename tag' subcommand to rename a tag across all repos: gitopolis rename tag old_name new_name If some repos already have the new tag, use --merge to combine: gitopolis rename tag old_name new_name --merge Without --merge, conflicting repos cause an error. Output lists affected repos for easy verification. Prompts: - now this one [Add ability to rename a tag · Issue #213 · timabell/gitopolis](#213) Fixes #213 bump: minor Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 92bbd17 commit c6af029

5 files changed

Lines changed: 304 additions & 0 deletions

File tree

src/gitopolis.rs

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,17 @@ impl Gitopolis {
7272
repos.remove_tag(tag_name, normalize_folders(repo_folders))?;
7373
self.save(repos)
7474
}
75+
pub fn rename_tag(
76+
&mut self,
77+
old_name: &str,
78+
new_name: &str,
79+
merge: bool,
80+
) -> Result<Vec<String>, GitopolisError> {
81+
let mut repos = self.load()?;
82+
let affected = repos.rename_tag(old_name, new_name, merge)?;
83+
self.save(repos)?;
84+
Ok(affected)
85+
}
7586
pub fn remove_tag_from_all(&mut self, tag_name: &str) -> Result<Vec<String>, GitopolisError> {
7687
let mut repos = self.load()?;
7788
let affected = repos.remove_tag_from_all(tag_name);

src/main.rs

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,11 @@ enum Commands {
110110
#[clap(subcommand)]
111111
entity: MoveEntity,
112112
},
113+
/// Rename a tag or other entity
114+
Rename {
115+
#[clap(subcommand)]
116+
entity: RenameEntity,
117+
},
113118
}
114119

115120
#[derive(Subcommand)]
@@ -123,6 +128,20 @@ enum MoveEntity {
123128
},
124129
}
125130

131+
#[derive(Subcommand)]
132+
enum RenameEntity {
133+
/// Rename a tag across all repos. Use --merge if some repos already have the new tag.
134+
Tag {
135+
/// Current tag name
136+
old_name: String,
137+
/// New tag name
138+
new_name: String,
139+
/// Allow renaming even if some repos already have the new tag
140+
#[clap(long)]
141+
merge: bool,
142+
},
143+
}
144+
126145
fn main() {
127146
env_logger::builder()
128147
.format(|buf, record| writeln!(buf, "{}", record.args())) // turn off log decorations https://docs.rs/env_logger/0.9.0/env_logger/#using-a-custom-format
@@ -252,6 +271,28 @@ fn main() {
252271
}
253272
}
254273
},
274+
Some(Commands::Rename { entity }) => match entity {
275+
RenameEntity::Tag {
276+
old_name,
277+
new_name,
278+
merge,
279+
} => match init_gitopolis().rename_tag(old_name, new_name, *merge) {
280+
Ok(affected) => {
281+
if affected.is_empty() {
282+
println!("Tag '{}' not found on any repos", old_name);
283+
} else {
284+
println!("Renamed tag '{}' to '{}' on:", old_name, new_name);
285+
for repo in &affected {
286+
println!("{}", repo);
287+
}
288+
}
289+
}
290+
Err(error) => {
291+
eprintln!("Error: {}", error.message());
292+
std::process::exit(1);
293+
}
294+
},
295+
},
255296
None => {
256297
panic!("no command") // this doesn't happen because help shows instead
257298
}

src/repos.rs

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,42 @@ impl Repos {
126126
) -> Result<(), GitopolisError> {
127127
self.tag(tag_name, repo_folders, true)
128128
}
129+
pub fn rename_tag(
130+
&mut self,
131+
old_name: &str,
132+
new_name: &str,
133+
merge: bool,
134+
) -> Result<Vec<String>, GitopolisError> {
135+
// Check for conflicts if not merging
136+
if !merge {
137+
for repo in &self.repos {
138+
if repo.tags.iter().any(|t| t == old_name)
139+
&& repo.tags.iter().any(|t| t == new_name)
140+
{
141+
return Err(GitopolisError::StateError {
142+
message: format!(
143+
"Repo '{}' already has tag '{}'. Use --merge to combine.",
144+
repo.path, new_name
145+
),
146+
});
147+
}
148+
}
149+
}
150+
151+
let mut affected = Vec::new();
152+
for repo in &mut self.repos {
153+
if let Some(ix) = repo.tags.iter().position(|t| t == old_name) {
154+
repo.tags.remove(ix);
155+
if !repo.tags.iter().any(|t| t == new_name) {
156+
repo.tags.push(new_name.to_string());
157+
repo.tags.sort_by_key(|a| a.to_lowercase());
158+
}
159+
affected.push(repo.path.clone());
160+
}
161+
}
162+
Ok(affected)
163+
}
164+
129165
pub fn remove_tag_from_all(&mut self, tag_name: &str) -> Vec<String> {
130166
let mut affected = Vec::new();
131167
for repo in &mut self.repos {

tests/end_to_end_tests.rs

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -721,6 +721,82 @@ fn tag_remove_from_all_repos_not_found() {
721721
.stdout("Tags nonexistent not found on any repos\n");
722722
}
723723

724+
#[test]
725+
fn rename_tag() {
726+
let temp = temp_folder();
727+
add_a_repo_with_tags(&temp, "repo1", "git://example.org/url1", vec!["old_name"]);
728+
add_a_repo_with_tags(
729+
&temp,
730+
"repo2",
731+
"git://example.org/url2",
732+
vec!["old_name", "other"],
733+
);
734+
735+
gitopolis_executable()
736+
.current_dir(&temp)
737+
.args(vec!["rename", "tag", "old_name", "new_name"])
738+
.assert()
739+
.success()
740+
.stdout("Renamed tag 'old_name' to 'new_name' on:\nrepo1\nrepo2\n");
741+
742+
let actual_toml = read_gitopolis_state_toml(&temp);
743+
assert!(actual_toml.contains("\"new_name\""));
744+
assert!(!actual_toml.contains("\"old_name\""));
745+
}
746+
747+
#[test]
748+
fn rename_tag_merge() {
749+
let temp = temp_folder();
750+
add_a_repo_with_tags(&temp, "repo1", "git://example.org/url1", vec!["old_name"]);
751+
add_a_repo_with_tags(
752+
&temp,
753+
"repo2",
754+
"git://example.org/url2",
755+
vec!["new_name", "old_name"],
756+
);
757+
758+
gitopolis_executable()
759+
.current_dir(&temp)
760+
.args(vec!["rename", "tag", "old_name", "new_name", "--merge"])
761+
.assert()
762+
.success()
763+
.stdout("Renamed tag 'old_name' to 'new_name' on:\nrepo1\nrepo2\n");
764+
765+
let actual_toml = read_gitopolis_state_toml(&temp);
766+
assert!(!actual_toml.contains("\"old_name\""));
767+
}
768+
769+
#[test]
770+
fn rename_tag_conflict_without_merge() {
771+
let temp = temp_folder();
772+
add_a_repo_with_tags(
773+
&temp,
774+
"repo1",
775+
"git://example.org/url1",
776+
vec!["old_name", "new_name"],
777+
);
778+
779+
gitopolis_executable()
780+
.current_dir(&temp)
781+
.args(vec!["rename", "tag", "old_name", "new_name"])
782+
.assert()
783+
.failure()
784+
.stderr(predicates::str::contains("Use --merge to combine"));
785+
}
786+
787+
#[test]
788+
fn rename_tag_not_found() {
789+
let temp = temp_folder();
790+
add_a_repo_with_tags(&temp, "repo1", "git://example.org/url1", vec!["other"]);
791+
792+
gitopolis_executable()
793+
.current_dir(&temp)
794+
.args(vec!["rename", "tag", "nonexistent", "new_name"])
795+
.assert()
796+
.success()
797+
.stdout("Tag 'nonexistent' not found on any repos\n");
798+
}
799+
724800
#[test]
725801
fn tags() {
726802
let temp = temp_folder();

tests/gitopolis_tests.rs

Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -489,6 +489,146 @@ url = \"git://example.org/repo3\"
489489
assert_eq!(vec!["repo1", "repo2"], affected);
490490
}
491491

492+
#[test]
493+
fn rename_tag() {
494+
let starting_state = "[[repos]]
495+
path = \"repo1\"
496+
tags = [\"old_tag\"]
497+
498+
[repos.remotes.origin]
499+
name = \"origin\"
500+
url = \"git://example.org/repo1\"
501+
502+
[[repos]]
503+
path = \"repo2\"
504+
tags = [\"old_tag\", \"other\"]
505+
506+
[repos.remotes.origin]
507+
name = \"origin\"
508+
url = \"git://example.org/repo2\"
509+
510+
[[repos]]
511+
path = \"repo3\"
512+
tags = [\"other\"]
513+
514+
[repos.remotes.origin]
515+
name = \"origin\"
516+
url = \"git://example.org/repo3\"\
517+
";
518+
519+
let expected_toml = "[[repos]]
520+
path = \"repo1\"
521+
tags = [\"new_tag\"]
522+
523+
[repos.remotes.origin]
524+
name = \"origin\"
525+
url = \"git://example.org/repo1\"
526+
527+
[[repos]]
528+
path = \"repo2\"
529+
tags = [\"new_tag\", \"other\"]
530+
531+
[repos.remotes.origin]
532+
name = \"origin\"
533+
url = \"git://example.org/repo2\"
534+
535+
[[repos]]
536+
path = \"repo3\"
537+
tags = [\"other\"]
538+
539+
[repos.remotes.origin]
540+
name = \"origin\"
541+
url = \"git://example.org/repo3\"
542+
";
543+
544+
let storage = FakeStorage::new()
545+
.with_contents(starting_state.to_string())
546+
.with_file_saved_callback(|state| assert_eq!(expected_toml.to_owned(), state))
547+
.boxed();
548+
549+
let git = FakeGit::new().boxed();
550+
let mut gitopolis = Gitopolis::new(storage, git);
551+
552+
let affected = gitopolis
553+
.rename_tag("old_tag", "new_tag", false)
554+
.expect("Failed to rename tag");
555+
556+
assert_eq!(vec!["repo1", "repo2"], affected);
557+
}
558+
559+
#[test]
560+
fn rename_tag_merge() {
561+
let starting_state = "[[repos]]
562+
path = \"repo1\"
563+
tags = [\"old_tag\"]
564+
565+
[repos.remotes.origin]
566+
name = \"origin\"
567+
url = \"git://example.org/repo1\"
568+
569+
[[repos]]
570+
path = \"repo2\"
571+
tags = [\"new_tag\", \"old_tag\"]
572+
573+
[repos.remotes.origin]
574+
name = \"origin\"
575+
url = \"git://example.org/repo2\"\
576+
";
577+
578+
let expected_toml = "[[repos]]
579+
path = \"repo1\"
580+
tags = [\"new_tag\"]
581+
582+
[repos.remotes.origin]
583+
name = \"origin\"
584+
url = \"git://example.org/repo1\"
585+
586+
[[repos]]
587+
path = \"repo2\"
588+
tags = [\"new_tag\"]
589+
590+
[repos.remotes.origin]
591+
name = \"origin\"
592+
url = \"git://example.org/repo2\"
593+
";
594+
595+
let storage = FakeStorage::new()
596+
.with_contents(starting_state.to_string())
597+
.with_file_saved_callback(|state| assert_eq!(expected_toml.to_owned(), state))
598+
.boxed();
599+
600+
let git = FakeGit::new().boxed();
601+
let mut gitopolis = Gitopolis::new(storage, git);
602+
603+
let affected = gitopolis
604+
.rename_tag("old_tag", "new_tag", true)
605+
.expect("Failed to rename tag with merge");
606+
607+
assert_eq!(vec!["repo1", "repo2"], affected);
608+
}
609+
610+
#[test]
611+
fn rename_tag_conflict_without_merge() {
612+
let starting_state = "[[repos]]
613+
path = \"repo1\"
614+
tags = [\"new_tag\", \"old_tag\"]
615+
616+
[repos.remotes.origin]
617+
name = \"origin\"
618+
url = \"git://example.org/repo1\"\
619+
";
620+
621+
let storage = FakeStorage::new()
622+
.with_contents(starting_state.to_string())
623+
.boxed();
624+
625+
let git = FakeGit::new().boxed();
626+
let mut gitopolis = Gitopolis::new(storage, git);
627+
628+
let result = gitopolis.rename_tag("old_tag", "new_tag", false);
629+
assert!(result.is_err());
630+
}
631+
492632
struct FakeStorage {
493633
exists: bool,
494634
contents: String,

0 commit comments

Comments
 (0)