a73x

207e4d03

Use system git for fetch/push in sync, fixing SSH auth

a73x   2026-03-20 19:07

libgit2's SSH transport cannot use ssh-agent, causing auth failures.
Sync now shells out to `git fetch` and `git push` for network
operations while keeping git2 for local ref manipulation and DAG
reconciliation. Simplified reconcile_refs to not need remote_name
since sync refs are now fetched to a flat refs/collab/sync/ namespace.

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

diff --git a/src/error.rs b/src/error.rs
index 82a949c..0f65c88 100644
--- a/src/error.rs
+++ b/src/error.rs
@@ -7,4 +7,7 @@ pub enum Error {

    #[error(transparent)]
    Json(#[from] serde_json::Error),

    #[error("{0}")]
    Cmd(String),
}
diff --git a/src/sync.rs b/src/sync.rs
index 3c6e0f6..dda19ef 100644
--- a/src/sync.rs
+++ b/src/sync.rs
@@ -1,17 +1,19 @@
use git2::{Direction, Oid, Repository};
use std::process::Command;

use git2::Repository;

use crate::dag;
use crate::error::Error;
use crate::identity::get_author;

/// Add collab refspecs to all remotes.
pub fn init(repo: &Repository) -> Result<(), crate::error::Error> {
pub fn init(repo: &Repository) -> Result<(), Error> {
    let remotes = repo.remotes()?;
    if remotes.is_empty() {
        println!("No remotes configured.");
        return Ok(());
    }
    for remote_name in remotes.iter().flatten() {
        // Add fetch refspec for collab refs
        let fetch_spec = format!("+refs/collab/*:refs/collab/sync/{}/*", remote_name);
        repo.remote_add_fetch(remote_name, &fetch_spec)?;
        println!("Configured remote '{}'", remote_name);
@@ -21,66 +23,73 @@ pub fn init(repo: &Repository) -> Result<(), crate::error::Error> {
}

/// Sync with a specific remote: fetch, reconcile, push.
pub fn sync(repo: &Repository, remote_name: &str) -> Result<(), crate::error::Error> {
pub fn sync(repo: &Repository, remote_name: &str) -> Result<(), Error> {
    let author = get_author(repo)?;
    let workdir = repo.path().parent().unwrap_or(repo.path()).to_path_buf();

    // Step 1: Connect to remote and discover collab refs
    // Step 1: Fetch collab refs using system git (handles SSH agent, credentials, etc.)
    println!("Fetching from '{}'...", remote_name);
    let mut remote = repo.find_remote(remote_name)?;

    // Fetch using configured refspecs (empty slice = use config refspecs)
    let empty: &[&str] = &[];
    remote.fetch(empty, None, None)?;
    drop(remote);

    // After fetch, check if the refspec-mapped sync refs exist.
    // If git2's fetch didn't map them, manually discover and create them.
    // We do this by connecting and listing remote refs.
    let remote_collab_refs = list_remote_collab_refs(repo, remote_name)?;
    for (remote_ref_name, oid) in &remote_collab_refs {
        // Map refs/collab/X to refs/collab/sync/{remote}/X
        let suffix = remote_ref_name
            .strip_prefix("refs/collab/")
            .unwrap_or(remote_ref_name);
        let sync_ref = format!("refs/collab/sync/{}/{}", remote_name, suffix);

        // Ensure the object exists locally (it should after fetch)
        if repo.find_commit(*oid).is_ok() {
            repo.reference(&sync_ref, *oid, true, "sync: map remote ref")?;
        }
    let fetch_status = Command::new("git")
        .args([
            "fetch",
            remote_name,
            "+refs/collab/issues/*:refs/collab/sync/issues/*",
            "+refs/collab/patches/*:refs/collab/sync/patches/*",
        ])
        .current_dir(&workdir)
        .status()
        .map_err(|e| Error::Cmd(format!("failed to run git fetch: {}", e)))?;

    if !fetch_status.success() {
        return Err(Error::Cmd(format!(
            "git fetch exited with status {}",
            fetch_status
        )));
    }

    // Step 2: Reconcile issues
    reconcile_refs(repo, remote_name, "issues", &author)?;

    // Step 3: Reconcile patches
    reconcile_refs(repo, remote_name, "patches", &author)?;
    // Step 2: Reconcile
    // Re-open repo to see the fetched refs (git2 caches ref state)
    let repo = Repository::open(repo.path())?;
    reconcile_refs(&repo, "issues", &author)?;
    reconcile_refs(&repo, "patches", &author)?;

    // Step 4: Push — enumerate concrete refs (git2 push doesn't support globs)
    // Step 3: Push collab refs using system git
    println!("Pushing to '{}'...", remote_name);
    let mut push_specs: Vec<String> = Vec::new();
    let mut push_args = vec!["push", remote_name];
    let mut refspecs: Vec<String> = Vec::new();

    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() {
                push_specs.push(format!("+{}:{}", name, name));
                refspecs.push(format!("+{}:{}", name, name));
            }
        }
    }

    if !push_specs.is_empty() {
        let mut remote = repo.find_remote(remote_name)?;
        let specs: Vec<&str> = push_specs.iter().map(|s| s.as_str()).collect();
        remote.push(&specs, None)?;
    if refspecs.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
            )));
        }
    }

    // Step 5: Clean up sync refs
    for prefix in &[
        format!("refs/collab/sync/{}/issues/", remote_name),
        format!("refs/collab/sync/{}/patches/", remote_name),
    ] {
    // 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()))
@@ -95,36 +104,13 @@ pub fn sync(repo: &Repository, remote_name: &str) -> Result<(), crate::error::Er
    Ok(())
}

/// Connect to a remote and list all refs under refs/collab/ (issues + patches only).
fn list_remote_collab_refs(
    repo: &Repository,
    remote_name: &str,
) -> Result<Vec<(String, Oid)>, crate::error::Error> {
    let mut remote = repo.find_remote(remote_name)?;
    remote.connect(Direction::Fetch)?;

    let refs: Vec<(String, Oid)> = remote
        .list()?
        .iter()
        .filter(|head| {
            let name = head.name();
            name.starts_with("refs/collab/issues/") || name.starts_with("refs/collab/patches/")
        })
        .map(|head| (head.name().to_string(), head.oid()))
        .collect();

    remote.disconnect()?;
    Ok(refs)
}

/// Reconcile all refs of a given kind (issues or patches) from sync refs.
fn reconcile_refs(
    repo: &Repository,
    remote_name: &str,
    kind: &str,
    author: &crate::event::Author,
) -> Result<(), crate::error::Error> {
    let sync_prefix = format!("refs/collab/sync/{}/{}/", remote_name, kind);
) -> Result<(), Error> {
    let sync_prefix = format!("refs/collab/sync/{}/", kind);
    let sync_refs: Vec<(String, String)> = {
        let refs = repo.references_glob(&format!("{}*", sync_prefix))?;
        refs.filter_map(|r| {