use std::path::{Path, PathBuf};
use std::process::Command;
use crate::config::SyncorPaths;
use crate::error::{Result, SyncorError};
use crate::link::LinkInfo;
use crate::transport::{ConflictInfo, PullResult, PushResult, RemoteLinkInfo, SyncTransport};
fn retry_with_backoff<F, T>(max_attempts: u32, mut f: F) -> Result<T>
where
F: FnMut() -> Result<T>,
{
assert!(max_attempts >= 1, "max_attempts must be >= 1");
let delays = [5, 15, 45];
for attempt in 0..max_attempts {
match f() {
Ok(v) => return Ok(v),
Err(e) => {
let is_retryable = matches!(&e, SyncorError::Transport(msg) if
msg.contains("fetch failed") ||
msg.contains("push failed") ||
msg.contains("Could not resolve host") ||
msg.contains("Connection refused") ||
msg.contains("timed out")
);
if !is_retryable || attempt + 1 >= max_attempts {
return Err(e);
}
let delay = delays.get(attempt as usize).copied().unwrap_or(45);
tracing::warn!(
"transport operation failed (attempt {}/{}), retrying in {}s: {}",
attempt + 1,
max_attempts,
delay,
e
);
std::thread::sleep(std::time::Duration::from_secs(delay));
}
}
}
unreachable!("loop always returns")
}
pub struct GitTransport {
paths: SyncorPaths,
}
impl GitTransport {
pub fn new(paths: SyncorPaths) -> Self {
Self { paths }
}
fn repo_dir(&self, link: &LinkInfo) -> PathBuf {
self.paths.link_repo_dir(&link.id)
}
fn git(dir: &Path, args: &[&str]) -> Result<String> {
let output = Command::new("git")
.args(args)
.current_dir(dir)
.env("GIT_TERMINAL_PROMPT", "0")
.output()
.map_err(|e| SyncorError::Transport(format!("failed to run git: {}", e)))?;
if output.status.success() {
Ok(String::from_utf8_lossy(&output.stdout).to_string())
} else {
let stderr = String::from_utf8_lossy(&output.stderr).to_string();
Err(SyncorError::Transport(format!(
"git {} failed: {}",
args.join(" "),
stderr.trim()
)))
}
}
fn git_ok(dir: &Path, args: &[&str]) -> String {
Command::new("git")
.args(args)
.current_dir(dir)
.env("GIT_TERMINAL_PROMPT", "0")
.output()
.ok()
.filter(|o| o.status.success())
.map(|o| String::from_utf8_lossy(&o.stdout).to_string())
.unwrap_or_default()
}
fn primary_branch(repo_dir: &Path) -> String {
let out = Self::git_ok(repo_dir, &["symbolic-ref", "--short", "HEAD"]);
let branch = out.trim();
if !branch.is_empty() {
return branch.to_string();
}
let out = Self::git_ok(repo_dir, &["branch", "--list", "main"]);
if out.contains("main") {
return "main".to_string();
}
let out = Self::git_ok(repo_dir, &["branch", "--list", "master"]);
if out.contains("master") {
return "master".to_string();
}
"main".to_string()
}
}
impl SyncTransport for GitTransport {
fn init_remote(&self, link: &LinkInfo) -> Result<()> {
let repo_dir = self.repo_dir(link);
if repo_dir.join(".git").exists() {
return Ok(());
}
std::fs::create_dir_all(&repo_dir)?;
let clone_result = Command::new("git")
.args(["clone", &link.repo, "."])
.current_dir(&repo_dir)
.env("GIT_TERMINAL_PROMPT", "0")
.output();
let cloned = matches!(clone_result, Ok(ref o) if o.status.success());
if !cloned {
Self::git(&repo_dir, &["init"])?;
Self::git(&repo_dir, &["remote", "add", "origin", &link.repo])?;
}
let store_base = repo_dir.join("stores").join(&link.name);
std::fs::create_dir_all(store_base.join("packs"))?;
std::fs::create_dir_all(store_base.join("trees"))?;
let gitignore_path = repo_dir.join(".gitignore");
if !gitignore_path.exists() {
std::fs::write(&gitignore_path, "index.bin\n*.sqlite-wal\n*.sqlite-shm\n")?;
}
if !cloned {
let has_commits = Self::git_ok(&repo_dir, &["rev-parse", "HEAD"]);
if has_commits.is_empty() {
Self::git(&repo_dir, &["add", "-A"])?;
Self::git(
&repo_dir,
&["commit", "-m", "init syncor repo", "--allow-empty"],
)?;
let _ = Self::git(&repo_dir, &["branch", "-M", "main"]);
}
}
Ok(())
}
fn push(&self, link: &LinkInfo, _store_path: &Path) -> Result<PushResult> {
let repo_dir = self.repo_dir(link);
let branch = Self::primary_branch(&repo_dir);
Self::git(&repo_dir, &["add", "-A"])?;
let status = Self::git_ok(&repo_dir, &["status", "--porcelain"]);
if status.trim().is_empty() {
let rev = Self::git_ok(&repo_dir, &["rev-parse", "HEAD"]);
return Ok(PushResult::Success {
revision: rev.trim().to_string(),
});
}
Self::git(&repo_dir, &["commit", "-m", "syncor push"])?;
retry_with_backoff(3, || {
let push_output = Command::new("git")
.args(["push", "-u", "origin", &branch])
.current_dir(&repo_dir)
.env("GIT_TERMINAL_PROMPT", "0")
.output()
.map_err(|e| SyncorError::Transport(format!("push exec: {}", e)))?;
if push_output.status.success() {
let rev = Self::git_ok(&repo_dir, &["rev-parse", "HEAD"]);
Ok(PushResult::Success {
revision: rev.trim().to_string(),
})
} else {
let stderr = String::from_utf8_lossy(&push_output.stderr).to_string();
if stderr.contains("non-fast-forward") || stderr.contains("rejected") {
Ok(PushResult::Conflict {
details: ConflictInfo {
message: stderr.trim().to_string(),
},
})
} else {
Err(SyncorError::Transport(format!(
"push failed: {}",
stderr.trim()
)))
}
}
})
}
fn pull(&self, link: &LinkInfo, _store_path: &Path) -> Result<PullResult> {
let repo_dir = self.repo_dir(link);
let branch = Self::primary_branch(&repo_dir);
let head_before = Self::git_ok(&repo_dir, &["rev-parse", "HEAD"]);
retry_with_backoff(3, || {
let fetch_output = Command::new("git")
.args(["fetch", "origin", &branch])
.current_dir(&repo_dir)
.env("GIT_TERMINAL_PROMPT", "0")
.output()
.map_err(|e| SyncorError::Transport(format!("fetch exec: {}", e)))?;
if fetch_output.status.success() {
Ok(())
} else {
let stderr = String::from_utf8_lossy(&fetch_output.stderr).to_string();
Err(SyncorError::Transport(format!(
"fetch failed: {}",
stderr.trim()
)))
}
})?;
let remote_ref = format!("origin/{}", branch);
let remote_head = Self::git_ok(&repo_dir, &["rev-parse", &remote_ref]);
if head_before.trim() == remote_head.trim() && !head_before.is_empty() {
return Ok(PullResult::UpToDate);
}
let merge_output = Command::new("git")
.args(["merge", "--ff-only", &remote_ref])
.current_dir(&repo_dir)
.env("GIT_TERMINAL_PROMPT", "0")
.output()
.map_err(|e| SyncorError::Transport(format!("merge exec: {}", e)))?;
if merge_output.status.success() {
let rev = Self::git_ok(&repo_dir, &["rev-parse", "HEAD"]);
Ok(PullResult::Success {
revision: rev.trim().to_string(),
})
} else {
Ok(PullResult::Conflict {
details: ConflictInfo {
message: "cannot fast-forward; manual resolution needed".to_string(),
},
})
}
}
fn list_remote_links(&self, repo_url: &str) -> Result<Vec<RemoteLinkInfo>> {
let tmp = tempfile::tempdir()?;
let clone_output = Command::new("git")
.args(["clone", "--depth=1", repo_url, "."])
.current_dir(tmp.path())
.env("GIT_TERMINAL_PROMPT", "0")
.output();
match clone_output {
Ok(o) if o.status.success() => {}
_ => return Ok(vec![]),
}
let toml_path = tmp.path().join("syncor.toml");
if !toml_path.exists() {
return Ok(vec![]);
}
let contents = std::fs::read_to_string(&toml_path)?;
#[derive(serde::Deserialize)]
struct Manifest {
#[serde(default)]
links: Vec<ManifestLink>,
}
#[derive(serde::Deserialize)]
struct ManifestLink {
name: String,
#[serde(default)]
created_at: Option<String>,
}
let manifest: Manifest =
toml::from_str(&contents).map_err(|e| SyncorError::Config(e.to_string()))?;
Ok(manifest
.links
.into_iter()
.map(|l| RemoteLinkInfo {
name: l.name,
created_at: l.created_at.unwrap_or_default(),
})
.collect())
}
fn has_remote_changes(&self, link: &LinkInfo) -> Result<bool> {
let repo_dir = self.repo_dir(link);
let branch = Self::primary_branch(&repo_dir);
retry_with_backoff(3, || {
let fetch_output = Command::new("git")
.args(["fetch", "origin", &branch])
.current_dir(&repo_dir)
.env("GIT_TERMINAL_PROMPT", "0")
.output()
.map_err(|e| SyncorError::Transport(format!("fetch exec: {}", e)))?;
if fetch_output.status.success() {
Ok(())
} else {
let stderr = String::from_utf8_lossy(&fetch_output.stderr).to_string();
Err(SyncorError::Transport(format!(
"fetch failed: {}",
stderr.trim()
)))
}
})?;
let local = Self::git_ok(&repo_dir, &["rev-parse", "HEAD"]);
let remote = Self::git_ok(&repo_dir, &["rev-parse", &format!("origin/{}", branch)]);
Ok(local.trim() != remote.trim())
}
}