upleb.uk

Public git repos — served from a NIP-34 GRASP relay at git.upleb.uk

summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--CHANGELOG.md4
-rw-r--r--src/bin/ngit/sub_commands/sync.rs72
-rw-r--r--src/lib/list.rs4
3 files changed, 78 insertions, 2 deletions
diff --git a/CHANGELOG.md b/CHANGELOG.md
index a86ceea..d4d0a49 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7 7
8## [Unreleased] 8## [Unreleased]
9 9
10### Fixed
11
12- Regression introduced in 28ad5440: `ngit sync` crashed with "invalid refspec refs/remotes/origin/v1.4.4^{}:refs/tags/v1.4.4^{}" on repos with annotated tags; `RepoState::try_from` now retains `^{}` peeled-tag entries in state, but the sync refspec builder did not skip them; fixed by guarding all three iteration sites in sync.rs and `identify_remote_sync_issues` in list.rs; also corrected the always-false logic bug in `invalid_nostr_state_ref`
13
10## [2.2.2] - 2026-02-27 14## [2.2.2] - 2026-02-27
11 15
12### Added 16### Added
diff --git a/src/bin/ngit/sub_commands/sync.rs b/src/bin/ngit/sub_commands/sync.rs
index 146bcbc..c326817 100644
--- a/src/bin/ngit/sub_commands/sync.rs
+++ b/src/bin/ngit/sub_commands/sync.rs
@@ -228,6 +228,10 @@ pub async fn launch(args: &SubCommandArgs) -> Result<()> {
228 // delete ref from remote 228 // delete ref from remote
229 let mut not_deleted = vec![]; 229 let mut not_deleted = vec![];
230 for remote_ref_name in remote_state.keys() { 230 for remote_ref_name in remote_state.keys() {
231 // skip peeled-tag dereference markers — not real refs
232 if remote_ref_name.ends_with("^{}") {
233 continue;
234 }
231 // skip unspecified refs 235 // skip unspecified refs
232 if let Some(full_ref_name) = &full_ref_name { 236 if let Some(full_ref_name) = &full_ref_name {
233 if remote_ref_name != full_ref_name { 237 if remote_ref_name != full_ref_name {
@@ -253,6 +257,11 @@ pub async fn launch(args: &SubCommandArgs) -> Result<()> {
253 // add or update ref on remote 257 // add or update ref on remote
254 let mut not_updated = vec![]; 258 let mut not_updated = vec![];
255 for nostr_ref_name in nostr_state.state.keys() { 259 for nostr_ref_name in nostr_state.state.keys() {
260 // skip peeled-tag dereference markers (e.g. refs/tags/v1.0.0^{})
261 // — these are not real git refs and cannot appear in refspecs
262 if nostr_ref_name.ends_with("^{}") {
263 continue;
264 }
256 // skip unspecified refs 265 // skip unspecified refs
257 if let Some(full_ref_name) = &full_ref_name { 266 if let Some(full_ref_name) = &full_ref_name {
258 if nostr_ref_name != full_ref_name { 267 if nostr_ref_name != full_ref_name {
@@ -391,8 +400,9 @@ pub async fn launch(args: &SubCommandArgs) -> Result<()> {
391} 400}
392 401
393fn invalid_nostr_state_ref(ref_name: &str) -> bool { 402fn invalid_nostr_state_ref(ref_name: &str) -> bool {
394 ref_name.starts_with("refs/heads/pr/") 403 ref_name.ends_with("^{}")
395 && !(ref_name.starts_with("refs/heads/") || ref_name.starts_with("refs/tags/")) 404 || ref_name.starts_with("refs/heads/pr/")
405 || (!ref_name.starts_with("refs/heads/") && !ref_name.starts_with("refs/tags/"))
396} 406}
397 407
398fn identify_missing_refs(git_repo: &Repo, state: &HashMap<String, String>) -> Vec<String> { 408fn identify_missing_refs(git_repo: &Repo, state: &HashMap<String, String>) -> Vec<String> {
@@ -490,3 +500,61 @@ fn fetch_missing_refs(
490 missing_refs 500 missing_refs
491 } 501 }
492} 502}
503
504#[cfg(test)]
505mod tests {
506 use super::invalid_nostr_state_ref;
507
508 // Regression test: annotated-tag peeled refs (ending with ^{}) must be
509 // treated as invalid nostr state refs so they are never used to build
510 // git refspecs. Before the fix, these passed through and caused git2 to
511 // reject the push with "invalid refspec refs/remotes/origin/v1.4.4^{}:…".
512 #[test]
513 fn annotated_tag_peeled_ref_is_invalid() {
514 assert!(
515 invalid_nostr_state_ref("refs/tags/v1.4.4^{}"),
516 "peeled annotated-tag ref must be invalid"
517 );
518 assert!(
519 invalid_nostr_state_ref("refs/tags/v1.0.0^{}"),
520 "peeled annotated-tag ref must be invalid"
521 );
522 }
523
524 #[test]
525 fn pr_ref_is_invalid() {
526 assert!(
527 invalid_nostr_state_ref("refs/heads/pr/42"),
528 "PR branch refs must be invalid"
529 );
530 }
531
532 #[test]
533 fn arbitrary_non_heads_non_tags_ref_is_invalid() {
534 assert!(
535 invalid_nostr_state_ref("refs/notes/commits"),
536 "refs outside heads/tags must be invalid"
537 );
538 assert!(invalid_nostr_state_ref("HEAD"), "HEAD must be invalid");
539 }
540
541 #[test]
542 fn normal_branch_and_tag_refs_are_valid() {
543 assert!(
544 !invalid_nostr_state_ref("refs/heads/main"),
545 "normal branch must be valid"
546 );
547 assert!(
548 !invalid_nostr_state_ref("refs/heads/feature/foo"),
549 "feature branch must be valid"
550 );
551 assert!(
552 !invalid_nostr_state_ref("refs/tags/v1.4.4"),
553 "lightweight tag must be valid"
554 );
555 assert!(
556 !invalid_nostr_state_ref("refs/tags/v2.0.0-rc1"),
557 "release-candidate tag must be valid"
558 );
559 }
560}
diff --git a/src/lib/list.rs b/src/lib/list.rs
index d8b038e..19d17df 100644
--- a/src/lib/list.rs
+++ b/src/lib/list.rs
@@ -605,6 +605,10 @@ pub fn identify_remote_sync_issues(
605 let mut remote_issues: HashMap<String, RemoteIssues> = HashMap::new(); 605 let mut remote_issues: HashMap<String, RemoteIssues> = HashMap::new();
606 606
607 for (name, value) in &nostr_state.state { 607 for (name, value) in &nostr_state.state {
608 // skip peeled-tag dereference markers — not real refs
609 if name.ends_with("^{}") {
610 continue;
611 }
608 for (url, (remote_state, _is_grasp_server)) in remote_states { 612 for (url, (remote_state, _is_grasp_server)) in remote_states {
609 let remote_name = get_short_git_server_name(url); 613 let remote_name = get_short_git_server_name(url);
610 let issues = remote_issues.entry(remote_name.clone()).or_default(); 614 let issues = remote_issues.entry(remote_name.clone()).or_default();