a73x

896e4754

Add per-ref push tracking and resumable sync on partial failure

a73x   2026-03-21 13:41

Replace batch git push with per-ref pushes that track success/failure
individually. On partial failure, persist pending refs with error messages
to .git/collab/sync-state.json. Next sync detects the state file and
resumes by retrying only the failed refs.

- Per-ref Pushed/FAILED output lines
- SyncState persistence with (ref_name, last_error) tuples
- Resume mode: skip fetch/reconcile, retry pending, clean stale sync refs
- PartialSync error variant with exit code 1
- 12 new tests including hook-based failure simulation

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

diff --git a/src/error.rs b/src/error.rs
index ce300ea..8755825 100644
--- a/src/error.rs
+++ b/src/error.rs
@@ -28,4 +28,7 @@ pub enum Error {

    #[error("another sync is in progress (pid {pid}, started {since})")]
    SyncLocked { pid: u32, since: String },

    #[error("sync partially failed: {succeeded} of {total} refs pushed")]
    PartialSync { succeeded: usize, total: usize },
}
diff --git a/src/main.rs b/src/main.rs
index 89cecfb..464e96a 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -15,7 +15,15 @@ fn main() {
    };

    if let Err(e) = git_collab::run(cli, &repo) {
        eprintln!("error: {}", e);
        std::process::exit(1);
        match &e {
            git_collab::error::Error::PartialSync { .. } => {
                // Summary already printed by sync; just exit with code 1
                std::process::exit(1);
            }
            _ => {
                eprintln!("error: {}", e);
                std::process::exit(1);
            }
        }
    }
}
diff --git a/src/sync.rs b/src/sync.rs
index f6d9b10..ec0d890 100644
--- a/src/sync.rs
+++ b/src/sync.rs
@@ -1,6 +1,9 @@
use std::fs;
use std::path::{Path, PathBuf};
use std::process::Command;

use git2::Repository;
use serde::{Deserialize, Serialize};

use crate::dag;
use crate::error::Error;
@@ -9,6 +12,225 @@ use crate::signing;
use crate::sync_lock::SyncLock;
use crate::trust;

// ---------------------------------------------------------------------------
// Per-ref push types (T002a)
// ---------------------------------------------------------------------------

/// Status of pushing a single ref.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum PushStatus {
    Pushed,
    Failed,
}

/// Result of pushing a single ref to the remote.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RefPushResult {
    pub ref_name: String,
    pub status: PushStatus,
    pub error: Option<String>,
}

/// Aggregated outcome of the entire push phase.
#[derive(Debug, Clone)]
pub struct SyncResult {
    pub results: Vec<RefPushResult>,
    pub remote: String,
}

impl SyncResult {
    pub fn succeeded(&self) -> Vec<&RefPushResult> {
        self.results
            .iter()
            .filter(|r| r.status == PushStatus::Pushed)
            .collect()
    }

    pub fn failed(&self) -> Vec<&RefPushResult> {
        self.results
            .iter()
            .filter(|r| r.status == PushStatus::Failed)
            .collect()
    }

    pub fn is_complete(&self) -> bool {
        self.results.iter().all(|r| r.status == PushStatus::Pushed)
    }
}

// ---------------------------------------------------------------------------
// Persistent sync state (T002b)
// ---------------------------------------------------------------------------

/// Persistent record of a partially-completed sync.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SyncState {
    pub remote: String,
    pub pending_refs: Vec<(String, String)>,
    pub timestamp: String,
}

impl SyncState {
    /// Path to the sync state file within a repo.
    fn path(repo: &Repository) -> PathBuf {
        let git_dir = repo.path();
        git_dir.join("collab").join("sync-state.json")
    }

    /// Load sync state from disk. Returns None if the file does not exist.
    /// If the file is corrupted, prints a warning, deletes it, and returns None.
    pub fn load(repo: &Repository) -> Option<SyncState> {
        let path = Self::path(repo);
        if !path.exists() {
            return None;
        }
        match fs::read_to_string(&path) {
            Ok(contents) => match serde_json::from_str(&contents) {
                Ok(state) => Some(state),
                Err(e) => {
                    eprintln!(
                        "warning: corrupted sync state file ({}); ignoring and proceeding with full sync",
                        e
                    );
                    let _ = fs::remove_file(&path);
                    None
                }
            },
            Err(_) => None,
        }
    }

    /// Save sync state to disk.
    pub fn save(&self, repo: &Repository) -> Result<(), Error> {
        let path = Self::path(repo);
        if let Some(parent) = path.parent() {
            fs::create_dir_all(parent)?;
        }
        let json = serde_json::to_string_pretty(self)?;
        fs::write(&path, json)?;
        Ok(())
    }

    /// Delete the sync state file.
    pub fn clear(repo: &Repository) -> Result<(), Error> {
        let path = Self::path(repo);
        if path.exists() {
            fs::remove_file(&path)?;
        }
        Ok(())
    }
}

// ---------------------------------------------------------------------------
// Push helpers (T003, T004)
// ---------------------------------------------------------------------------

/// Push a single ref to the remote. Returns a RefPushResult.
fn push_ref(workdir: &Path, remote_name: &str, ref_name: &str) -> RefPushResult {
    let refspec = format!("+{}:{}", ref_name, ref_name);
    match Command::new("git")
        .args(["push", remote_name, &refspec])
        .current_dir(workdir)
        .output()
    {
        Ok(output) => {
            if output.status.success() {
                RefPushResult {
                    ref_name: ref_name.to_string(),
                    status: PushStatus::Pushed,
                    error: None,
                }
            } else {
                let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
                RefPushResult {
                    ref_name: ref_name.to_string(),
                    status: PushStatus::Failed,
                    error: Some(stderr),
                }
            }
        }
        Err(e) => RefPushResult {
            ref_name: ref_name.to_string(),
            status: PushStatus::Failed,
            error: Some(format!("failed to run git push: {}", e)),
        },
    }
}

/// Collect all collab ref names that should be pushed.
fn collect_push_refs(repo: &Repository) -> Result<Vec<String>, Error> {
    let mut refs = Vec::new();
    for pattern in &["refs/collab/issues/*", "refs/collab/patches/*"] {
        let iter = repo.references_glob(pattern)?;
        for r in iter {
            let r = r?;
            if let Some(name) = r.name() {
                refs.push(name.to_string());
            }
        }
    }
    Ok(refs)
}

// ---------------------------------------------------------------------------
// Summary printing (T012)
// ---------------------------------------------------------------------------

/// Print a failure summary to stderr.
fn print_sync_summary(result: &SyncResult) {
    let succeeded = result.succeeded().len();
    let total = result.results.len();

    if succeeded == 0 {
        eprintln!("\nSync failed: {} of {} refs pushed.", succeeded, total);
    } else {
        eprintln!(
            "\nSync partially failed: {} of {} refs pushed.",
            succeeded, total
        );
    }

    let failed = result.failed();
    eprintln!("Failed refs:");
    for f in &failed {
        eprintln!(
            "  {}: {}",
            f.ref_name,
            f.error.as_deref().unwrap_or("unknown error")
        );
    }

    eprintln!(
        "\nRun `collab sync {}` again to retry {} failed ref(s).",
        result.remote,
        failed.len()
    );
}

/// Clean up refs/collab/sync/* refs.
fn cleanup_sync_refs(repo: &Repository) -> Result<(), Error> {
    for prefix in &["refs/collab/sync/issues/", "refs/collab/sync/patches/"] {
        let refs: Vec<String> = repo
            .references_glob(&format!("{}*", prefix))?
            .filter_map(|r| r.ok()?.name().map(|n| n.to_string()))
            .collect();
        for ref_name in refs {
            let mut r = repo.find_reference(&ref_name)?;
            r.delete()?;
        }
    }
    // Also clean up any refs under refs/collab/sync/ with remote-name prefix
    let refs: Vec<String> = repo
        .references_glob("refs/collab/sync/*")?
        .filter_map(|r| r.ok()?.name().map(|n| n.to_string()))
        .collect();
    for ref_name in refs {
        let mut r = repo.find_reference(&ref_name)?;
        r.delete()?;
    }
    Ok(())
}

/// Add collab refspecs to all remotes.
pub fn init(repo: &Repository) -> Result<(), Error> {
    let remotes = repo.remotes()?;
@@ -30,9 +252,18 @@ pub fn sync(repo: &Repository, remote_name: &str) -> Result<(), Error> {
    // Acquire advisory lock — held until _lock is dropped (RAII)
    let _lock = SyncLock::acquire(repo)?;

    let author = get_author(repo)?;
    let workdir = repo.path().parent().unwrap_or(repo.path()).to_path_buf();

    // Check for existing sync state (resume mode)
    if let Some(state) = SyncState::load(repo) {
        if state.remote == remote_name {
            return sync_resume(repo, remote_name, &workdir, &state);
        }
        // State is for a different remote — ignore it, run full sync
    }

    let author = get_author(repo)?;

    // Step 1: Fetch collab refs using system git (handles SSH agent, credentials, etc.)
    println!("Fetching from '{}'...", remote_name);
    let fetch_status = Command::new("git")
@@ -60,57 +291,146 @@ pub fn sync(repo: &Repository, remote_name: &str) -> Result<(), Error> {
    reconcile_refs(&repo, "issues", &author, &sk)?;
    reconcile_refs(&repo, "patches", &author, &sk)?;

    // Step 3: Push collab refs using system git
    // Step 3: Push collab refs individually
    println!("Pushing to '{}'...", remote_name);
    let mut push_args = vec!["push", remote_name];
    let mut refspecs: Vec<String> = Vec::new();
    let refs_to_push = collect_push_refs(&repo)?;

    for pattern in &["refs/collab/issues/*", "refs/collab/patches/*"] {
        let refs = repo.references_glob(pattern)?;
        for r in refs {
            let r = r?;
            if let Some(name) = r.name() {
                refspecs.push(format!("+{}:{}", name, name));
            }
        }
    }

    if refspecs.is_empty() {
    if refs_to_push.is_empty() {
        println!("Nothing to push.");
    } else {
        let refspec_strs: Vec<&str> = refspecs.iter().map(|s| s.as_str()).collect();
        push_args.extend(refspec_strs);

        let push_status = Command::new("git")
            .args(&push_args)
            .current_dir(&workdir)
            .status()
            .map_err(|e| Error::Cmd(format!("failed to run git push: {}", e)))?;

        if !push_status.success() {
            return Err(Error::Cmd(format!(
                "git push exited with status {}",
                push_status
            )));
        let sync_result = push_refs_individually(&workdir, remote_name, &refs_to_push);

        if !sync_result.is_complete() {
            // Save state for resume
            let now = chrono::Utc::now().to_rfc3339();
            let pending: Vec<(String, String)> = sync_result
                .failed()
                .iter()
                .map(|r| {
                    (
                        r.ref_name.clone(),
                        r.error.clone().unwrap_or_else(|| "unknown error".to_string()),
                    )
                })
                .collect();
            let state = SyncState {
                remote: remote_name.to_string(),
                pending_refs: pending,
                timestamp: now,
            };
            state.save(&repo)?;

            print_sync_summary(&sync_result);

            // Clean up sync refs before returning error
            cleanup_sync_refs(&repo)?;

            let succeeded = sync_result.succeeded().len();
            let total = sync_result.results.len();
            return Err(Error::PartialSync {
                succeeded,
                total,
            });
        }
    }

    // Step 4: Clean up sync refs
    for prefix in &["refs/collab/sync/issues/", "refs/collab/sync/patches/"] {
        let refs: Vec<String> = repo
            .references_glob(&format!("{}*", prefix))?
            .filter_map(|r| r.ok()?.name().map(|n| n.to_string()))
            .collect();
        for ref_name in refs {
            let mut r = repo.find_reference(&ref_name)?;
            r.delete()?;
        }
    }
    cleanup_sync_refs(&repo)?;

    println!("Sync complete.");
    Ok(())
}

/// Resume a partially-failed sync by retrying only the pending refs.
fn sync_resume(
    repo: &Repository,
    remote_name: &str,
    workdir: &Path,
    state: &SyncState,
) -> Result<(), Error> {
    println!(
        "Resuming sync to '{}' ({} refs pending from previous failure)...",
        remote_name,
        state.pending_refs.len()
    );

    // Print pending refs with their last error
    for (ref_name, last_error) in &state.pending_refs {
        println!("  Pending: {} (last error: {})", ref_name, last_error);
    }

    // Clean up stale sync refs (T018b)
    cleanup_sync_refs(repo)?;

    // Push the pending refs
    let ref_names: Vec<String> = state.pending_refs.iter().map(|(r, _)| r.clone()).collect();
    let sync_result = push_refs_individually(workdir, remote_name, &ref_names);

    if sync_result.is_complete() {
        // All pending refs pushed successfully
        SyncState::clear(repo)?;
        if sync_result.results.is_empty() {
            println!("All pending refs are already up to date. Clearing stale sync state.");
        } else {
            println!("\nSync complete. All previously-failed refs pushed.");
        }
        println!("Sync complete.");
        Ok(())
    } else {
        // Some refs still failing - update state
        let now = chrono::Utc::now().to_rfc3339();
        let pending: Vec<(String, String)> = sync_result
            .failed()
            .iter()
            .map(|r| {
                (
                    r.ref_name.clone(),
                    r.error.clone().unwrap_or_else(|| "unknown error".to_string()),
                )
            })
            .collect();
        let new_state = SyncState {
            remote: remote_name.to_string(),
            pending_refs: pending,
            timestamp: now,
        };
        new_state.save(repo)?;

        print_sync_summary(&sync_result);
        eprintln!("To force a full sync, delete .git/collab/sync-state.json");

        let succeeded = sync_result.succeeded().len();
        let total = sync_result.results.len();
        Err(Error::PartialSync {
            succeeded,
            total,
        })
    }
}

/// Push refs one at a time, printing per-ref status, and return aggregated results.
fn push_refs_individually(workdir: &Path, remote_name: &str, refs: &[String]) -> SyncResult {
    let mut results = Vec::new();
    for ref_name in refs {
        let result = push_ref(workdir, remote_name, ref_name);
        match &result.status {
            PushStatus::Pushed => println!("  Pushed {}", ref_name),
            PushStatus::Failed => {
                eprintln!(
                    "  FAILED {}: {}",
                    ref_name,
                    result.error.as_deref().unwrap_or("unknown error")
                );
            }
        }
        results.push(result);
    }
    SyncResult {
        results,
        remote: remote_name.to_string(),
    }
}

/// Reconcile all refs of a given kind (issues or patches) from sync refs.
fn reconcile_refs(
    repo: &Repository,
diff --git a/tests/sync_test.rs b/tests/sync_test.rs
index 48a7445..cbcd567 100644
--- a/tests/sync_test.rs
+++ b/tests/sync_test.rs
@@ -11,6 +11,7 @@ use tempfile::TempDir;

use git2::Repository;
use git_collab::dag;
use git_collab::error;
use git_collab::event::{Action, Event, ReviewVerdict};
use git_collab::signing;
use git_collab::state::{self, IssueState, IssueStatus, PatchState};
@@ -26,7 +27,7 @@ use common::{
// ---------------------------------------------------------------------------

struct TestCluster {
    _bare_dir: TempDir,
    bare_dir: TempDir,
    alice_dir: TempDir,
    bob_dir: TempDir,
    _key_setup: (), // signing key created in default config dir
@@ -80,7 +81,7 @@ impl TestCluster {
        }

        TestCluster {
            _bare_dir: bare_dir,
            bare_dir,
            alice_dir,
            bob_dir,
            _key_setup: (),
@@ -94,6 +95,11 @@ impl TestCluster {
    fn bob_repo(&self) -> Repository {
        Repository::open(self.bob_dir.path()).unwrap()
    }

    /// Return the path to the bare remote directory.
    fn bare_dir(&self) -> &std::path::Path {
        self.bare_dir.path()
    }
}

// ---------------------------------------------------------------------------
@@ -654,3 +660,412 @@ fn test_reconciliation_merge_commit_is_signed() {
        "Merge commit signature must be valid"
    );
}

// ---------------------------------------------------------------------------
// Sync Recovery Tests
// ---------------------------------------------------------------------------

/// Install a pre-receive hook in the bare repo that rejects refs matching a pattern.
fn install_reject_hook(bare_dir: &std::path::Path, reject_pattern: &str) {
    let hooks_dir = bare_dir.join("hooks");
    std::fs::create_dir_all(&hooks_dir).unwrap();
    let hook_path = hooks_dir.join("pre-receive");
    let script = format!(
        r#"#!/bin/sh
while read oldrev newrev refname; do
    case "$refname" in
        *{}*)
            echo "REJECT: $refname matches reject pattern" >&2
            exit 1
            ;;
    esac
done
exit 0
"#,
        reject_pattern
    );
    std::fs::write(&hook_path, script).unwrap();
    #[cfg(unix)]
    {
        use std::os::unix::fs::PermissionsExt;
        std::fs::set_permissions(&hook_path, std::fs::Permissions::from_mode(0o755)).unwrap();
    }
}

/// Remove the pre-receive hook from the bare repo.
fn remove_reject_hook(bare_dir: &std::path::Path) {
    let hook_path = bare_dir.join("hooks").join("pre-receive");
    let _ = std::fs::remove_file(&hook_path);
}

// T005 [US4]: Per-ref push isolates failures
#[test]
fn test_per_ref_push_isolates_failures() {
    let cluster = TestCluster::new();
    let alice_repo = cluster.alice_repo();

    // Create two issues
    let (_ref1, id1) = open_issue(&alice_repo, &alice(), "Issue one");
    let (_ref2, id2) = open_issue(&alice_repo, &alice(), "Issue two");

    // Install hook that rejects one specific issue
    install_reject_hook(cluster.bare_dir(), &id1[..8]);

    // Sync should fail with PartialSync
    let result = sync::sync(&alice_repo, "origin");
    assert!(result.is_err(), "sync should fail when a ref is rejected");
    let err = result.unwrap_err();
    match &err {
        git_collab::error::Error::PartialSync { succeeded, total } => {
            assert_eq!(*succeeded, 1, "one ref should succeed");
            assert_eq!(*total, 2, "two refs total");
        }
        other => panic!("expected PartialSync error, got: {:?}", other),
    }

    // Verify the non-rejected ref was pushed to the bare remote
    let bare_repo = Repository::open_bare(cluster.bare_dir()).unwrap();
    let pushed_ref = format!("refs/collab/issues/{}", id2);
    assert!(
        bare_repo.refname_to_id(&pushed_ref).is_ok(),
        "non-rejected ref should be on remote"
    );

    // The rejected ref should NOT be on the remote
    let rejected_ref = format!("refs/collab/issues/{}", id1);
    assert!(
        bare_repo.refname_to_id(&rejected_ref).is_err(),
        "rejected ref should NOT be on remote"
    );
}

// T006 [US4]: All refs push successfully — happy path unchanged
#[test]
fn test_all_refs_push_succeeds_unchanged_behavior() {
    let cluster = TestCluster::new();
    let alice_repo = cluster.alice_repo();

    let (_ref1, _id1) = open_issue(&alice_repo, &alice(), "Happy issue");

    // Sync should succeed
    let result = sync::sync(&alice_repo, "origin");
    assert!(result.is_ok(), "sync should succeed: {:?}", result.err());
}

// T010 [US1]: Partial failure reports failed refs
#[test]
fn test_partial_failure_reports_failed_refs() {
    let cluster = TestCluster::new();
    let alice_repo = cluster.alice_repo();

    let (_ref1, id1) = open_issue(&alice_repo, &alice(), "Will fail");
    let (_ref2, _id2) = open_issue(&alice_repo, &alice(), "Will succeed");

    install_reject_hook(cluster.bare_dir(), &id1[..8]);

    let result = sync::sync(&alice_repo, "origin");
    assert!(result.is_err());

    // Verify sync state was saved with the failed ref
    let state = sync::SyncState::load(&alice_repo);
    assert!(state.is_some(), "sync state should be saved");
    let state = state.unwrap();
    assert_eq!(state.remote, "origin");
    assert_eq!(state.pending_refs.len(), 1, "one ref should be pending");
    assert!(
        state.pending_refs[0].0.contains(&id1),
        "pending ref should be the rejected one"
    );
    assert!(
        !state.pending_refs[0].1.is_empty(),
        "error message should be recorded"
    );
}

// T011 [US1]: Total failure reports all failed
#[test]
fn test_total_failure_reports_all_failed() {
    let cluster = TestCluster::new();
    let alice_repo = cluster.alice_repo();

    let (_ref1, _id1) = open_issue(&alice_repo, &alice(), "Fail one");
    let (_ref2, _id2) = open_issue(&alice_repo, &alice(), "Fail two");

    // Reject ALL collab refs
    install_reject_hook(cluster.bare_dir(), "refs/collab/");

    let result = sync::sync(&alice_repo, "origin");
    assert!(result.is_err());
    match result.unwrap_err() {
        git_collab::error::Error::PartialSync { succeeded, total } => {
            assert_eq!(succeeded, 0, "no refs should succeed");
            assert_eq!(total, 2, "two refs total");
        }
        other => panic!("expected PartialSync, got: {:?}", other),
    }

    let state = sync::SyncState::load(&alice_repo).unwrap();
    assert_eq!(state.pending_refs.len(), 2, "all refs should be pending");
}

// T014 [US2]: Resume retries only failed refs
#[test]
fn test_resume_retries_only_failed_refs() {
    let cluster = TestCluster::new();
    let alice_repo = cluster.alice_repo();

    let (_ref1, id1) = open_issue(&alice_repo, &alice(), "Will fail first time");
    let (_ref2, _id2) = open_issue(&alice_repo, &alice(), "Will succeed first time");

    // First sync: reject id1
    install_reject_hook(cluster.bare_dir(), &id1[..8]);
    let _ = sync::sync(&alice_repo, "origin");

    // Verify state was saved
    let state = sync::SyncState::load(&alice_repo);
    assert!(state.is_some());
    assert_eq!(state.unwrap().pending_refs.len(), 1);

    // Remove the hook so all refs can push
    remove_reject_hook(cluster.bare_dir());

    // Resume sync should succeed
    let result = sync::sync(&alice_repo, "origin");
    assert!(result.is_ok(), "resume sync should succeed: {:?}", result.err());

    // State should be cleared
    assert!(
        sync::SyncState::load(&alice_repo).is_none(),
        "sync state should be cleared after successful resume"
    );
}

// T015 [US2]: Resume clears state on full success
#[test]
fn test_resume_clears_state_on_full_success() {
    let cluster = TestCluster::new();
    let alice_repo = cluster.alice_repo();

    let (_ref1, id1) = open_issue(&alice_repo, &alice(), "Issue for resume");

    // Manually create sync state
    let state = sync::SyncState {
        remote: "origin".to_string(),
        pending_refs: vec![(
            format!("refs/collab/issues/{}", id1),
            "previous error".to_string(),
        )],
        timestamp: chrono::Utc::now().to_rfc3339(),
    };
    state.save(&alice_repo).unwrap();

    // Sync in resume mode
    let result = sync::sync(&alice_repo, "origin");
    assert!(result.is_ok(), "resume should succeed: {:?}", result.err());

    // Verify state file is gone
    let state_path = alice_repo.path().join("collab").join("sync-state.json");
    assert!(
        !state_path.exists(),
        "sync-state.json should be deleted after successful resume"
    );
}

// T016 [US2]: Resume updates state on continued failure
#[test]
fn test_resume_updates_state_on_continued_failure() {
    let cluster = TestCluster::new();
    let alice_repo = cluster.alice_repo();

    let (_ref1, id1) = open_issue(&alice_repo, &alice(), "Will keep failing");
    let (_ref2, id2) = open_issue(&alice_repo, &alice(), "Will succeed on retry");

    // Manually create sync state with both refs pending
    let state = sync::SyncState {
        remote: "origin".to_string(),
        pending_refs: vec![
            (
                format!("refs/collab/issues/{}", id1),
                "previous error".to_string(),
            ),
            (
                format!("refs/collab/issues/{}", id2),
                "previous error".to_string(),
            ),
        ],
        timestamp: chrono::Utc::now().to_rfc3339(),
    };
    state.save(&alice_repo).unwrap();

    // Install hook that only rejects id1
    install_reject_hook(cluster.bare_dir(), &id1[..8]);

    // Resume should partially fail
    let result = sync::sync(&alice_repo, "origin");
    assert!(result.is_err());

    // State should be updated with only the still-failing ref
    let new_state = sync::SyncState::load(&alice_repo).unwrap();
    assert_eq!(
        new_state.pending_refs.len(),
        1,
        "only the still-failing ref should remain"
    );
    assert!(
        new_state.pending_refs[0].0.contains(&id1),
        "the still-failing ref should be id1"
    );
}

// T017 [US2]: No resume without state file
#[test]
fn test_no_resume_without_state_file() {
    let cluster = TestCluster::new();
    let alice_repo = cluster.alice_repo();

    let (_ref1, _id1) = open_issue(&alice_repo, &alice(), "Normal sync");

    // No sync state file — should run full flow
    let result = sync::sync(&alice_repo, "origin");
    assert!(result.is_ok(), "normal sync should succeed: {:?}", result.err());
}

// T018b [US2]: Resume cleans up stale sync refs
#[test]
fn test_resume_cleans_up_stale_sync_refs() {
    let cluster = TestCluster::new();
    let alice_repo = cluster.alice_repo();

    let (_ref1, id1) = open_issue(&alice_repo, &alice(), "Issue for stale ref test");

    // Push the issue first (normal sync)
    sync::sync(&alice_repo, "origin").unwrap();

    // Now manually create a stale sync ref
    let tip = alice_repo
        .refname_to_id(&format!("refs/collab/issues/{}", id1))
        .unwrap();
    alice_repo
        .reference(
            "refs/collab/sync/origin/issues/stale_ref",
            tip,
            true,
            "create stale sync ref",
        )
        .unwrap();

    // Create sync state to trigger resume mode
    let state = sync::SyncState {
        remote: "origin".to_string(),
        pending_refs: vec![(
            format!("refs/collab/issues/{}", id1),
            "previous error".to_string(),
        )],
        timestamp: chrono::Utc::now().to_rfc3339(),
    };
    state.save(&alice_repo).unwrap();

    // Resume sync
    let result = sync::sync(&alice_repo, "origin");
    assert!(result.is_ok(), "resume should succeed: {:?}", result.err());

    // Verify stale sync ref was cleaned up
    let stale_ref = alice_repo.refname_to_id("refs/collab/sync/origin/issues/stale_ref");
    assert!(
        stale_ref.is_err(),
        "stale sync ref should be cleaned up during resume"
    );
}

// T021 [US3]: Stale state detected and cleared when refs are already up to date
#[test]
fn test_stale_state_detected_and_cleared() {
    let cluster = TestCluster::new();
    let alice_repo = cluster.alice_repo();

    let (_ref1, id1) = open_issue(&alice_repo, &alice(), "Already pushed issue");

    // Push the issue normally first
    sync::sync(&alice_repo, "origin").unwrap();

    // Now create sync state as if the push had failed — but the refs are actually pushed
    let state = sync::SyncState {
        remote: "origin".to_string(),
        pending_refs: vec![(
            format!("refs/collab/issues/{}", id1),
            "connection timed out".to_string(),
        )],
        timestamp: chrono::Utc::now().to_rfc3339(),
    };
    state.save(&alice_repo).unwrap();

    // Resume sync — refs are already up to date
    let result = sync::sync(&alice_repo, "origin");
    assert!(
        result.is_ok(),
        "sync should succeed when refs already up to date: {:?}",
        result.err()
    );

    // State should be cleared
    assert!(
        sync::SyncState::load(&alice_repo).is_none(),
        "stale sync state should be cleared"
    );
}

// T022 [US3]: State for different remote is ignored
#[test]
fn test_state_for_different_remote_ignored() {
    let cluster = TestCluster::new();
    let alice_repo = cluster.alice_repo();

    let (_ref1, id1) = open_issue(&alice_repo, &alice(), "Issue for remote test");

    // Create sync state for a different remote
    let state = sync::SyncState {
        remote: "upstream".to_string(),
        pending_refs: vec![(
            format!("refs/collab/issues/{}", id1),
            "some error".to_string(),
        )],
        timestamp: chrono::Utc::now().to_rfc3339(),
    };
    state.save(&alice_repo).unwrap();

    // Sync against origin — should run full flow, ignoring the upstream state
    let result = sync::sync(&alice_repo, "origin");
    assert!(
        result.is_ok(),
        "sync to origin should succeed ignoring upstream state: {:?}",
        result.err()
    );
}

// T026: Corrupted state file handled gracefully
#[test]
fn test_corrupted_state_file_handled_gracefully() {
    let cluster = TestCluster::new();
    let alice_repo = cluster.alice_repo();

    let (_ref1, _id1) = open_issue(&alice_repo, &alice(), "Issue for corruption test");

    // Write invalid JSON to sync-state.json
    let collab_dir = alice_repo.path().join("collab");
    std::fs::create_dir_all(&collab_dir).unwrap();
    std::fs::write(collab_dir.join("sync-state.json"), "not valid json{{{").unwrap();

    // Sync should proceed normally (treating as no state)
    let result = sync::sync(&alice_repo, "origin");
    assert!(
        result.is_ok(),
        "sync should succeed despite corrupted state: {:?}",
        result.err()
    );

    // State file should be cleaned up
    assert!(
        !collab_dir.join("sync-state.json").exists(),
        "corrupted state file should be deleted"
    );
}