Skip to main content

syncor_core/transport/
git.rs

1use std::path::{Path, PathBuf};
2use std::process::Command;
3
4use crate::config::SyncorPaths;
5use crate::error::{Result, SyncorError};
6use crate::link::LinkInfo;
7use crate::transport::{ConflictInfo, PullResult, PushResult, RemoteLinkInfo, SyncTransport};
8
9/// Retry a closure up to `max_attempts` times with exponential backoff.
10fn retry_with_backoff<F, T>(max_attempts: u32, mut f: F) -> Result<T>
11where
12    F: FnMut() -> Result<T>,
13{
14    assert!(max_attempts >= 1, "max_attempts must be >= 1");
15    let delays = [5, 15, 45];
16    for attempt in 0..max_attempts {
17        match f() {
18            Ok(v) => return Ok(v),
19            Err(e) => {
20                let is_retryable = matches!(&e, SyncorError::Transport(msg) if
21                    msg.contains("fetch failed") ||
22                    msg.contains("push failed") ||
23                    msg.contains("Could not resolve host") ||
24                    msg.contains("Connection refused") ||
25                    msg.contains("timed out")
26                );
27                if !is_retryable || attempt + 1 >= max_attempts {
28                    return Err(e);
29                }
30                let delay = delays.get(attempt as usize).copied().unwrap_or(45);
31                tracing::warn!(
32                    "transport operation failed (attempt {}/{}), retrying in {}s: {}",
33                    attempt + 1,
34                    max_attempts,
35                    delay,
36                    e
37                );
38                std::thread::sleep(std::time::Duration::from_secs(delay));
39            }
40        }
41    }
42    unreachable!("loop always returns")
43}
44
45/// Git-backed transport using the `git` CLI for operations that need
46/// authentication (clone, fetch, push) and libgit2 for local-only work
47/// (commit, staging).  This avoids the credential-helper issues that
48/// plague libgit2 on macOS / Windows.
49pub struct GitTransport {
50    paths: SyncorPaths,
51}
52
53impl GitTransport {
54    pub fn new(paths: SyncorPaths) -> Self {
55        Self { paths }
56    }
57
58    fn repo_dir(&self, link: &LinkInfo) -> PathBuf {
59        self.paths.link_repo_dir(&link.id)
60    }
61
62    /// Run a git CLI command in a given directory.
63    fn git(dir: &Path, args: &[&str]) -> Result<String> {
64        let output = Command::new("git")
65            .args(args)
66            .current_dir(dir)
67            .env("GIT_TERMINAL_PROMPT", "0")
68            .output()
69            .map_err(|e| SyncorError::Transport(format!("failed to run git: {}", e)))?;
70
71        if output.status.success() {
72            Ok(String::from_utf8_lossy(&output.stdout).to_string())
73        } else {
74            let stderr = String::from_utf8_lossy(&output.stderr).to_string();
75            Err(SyncorError::Transport(format!(
76                "git {} failed: {}",
77                args.join(" "),
78                stderr.trim()
79            )))
80        }
81    }
82
83    /// Run git command, returning Ok(stdout) or Ok("") on failure (non-fatal).
84    fn git_ok(dir: &Path, args: &[&str]) -> String {
85        Command::new("git")
86            .args(args)
87            .current_dir(dir)
88            .env("GIT_TERMINAL_PROMPT", "0")
89            .output()
90            .ok()
91            .filter(|o| o.status.success())
92            .map(|o| String::from_utf8_lossy(&o.stdout).to_string())
93            .unwrap_or_default()
94    }
95
96    fn primary_branch(repo_dir: &Path) -> String {
97        // Check current branch via symbolic-ref first
98        let out = Self::git_ok(repo_dir, &["symbolic-ref", "--short", "HEAD"]);
99        let branch = out.trim();
100        if !branch.is_empty() {
101            return branch.to_string();
102        }
103        // Fallback: check which branches exist
104        let out = Self::git_ok(repo_dir, &["branch", "--list", "main"]);
105        if out.contains("main") {
106            return "main".to_string();
107        }
108        let out = Self::git_ok(repo_dir, &["branch", "--list", "master"]);
109        if out.contains("master") {
110            return "master".to_string();
111        }
112        "main".to_string()
113    }
114}
115
116impl SyncTransport for GitTransport {
117    fn init_remote(&self, link: &LinkInfo) -> Result<()> {
118        let repo_dir = self.repo_dir(link);
119
120        if repo_dir.join(".git").exists() {
121            return Ok(());
122        }
123
124        std::fs::create_dir_all(&repo_dir)?;
125
126        // Try clone first; fall back to init for empty repos.
127        let clone_result = Command::new("git")
128            .args(["clone", &link.repo, "."])
129            .current_dir(&repo_dir)
130            .env("GIT_TERMINAL_PROMPT", "0")
131            .output();
132
133        let cloned = matches!(clone_result, Ok(ref o) if o.status.success());
134        if !cloned {
135            // Init + add remote for empty repos.
136            Self::git(&repo_dir, &["init"])?;
137            Self::git(&repo_dir, &["remote", "add", "origin", &link.repo])?;
138        }
139
140        // Create store dirs.
141        let store_base = repo_dir.join("stores").join(&link.name);
142        std::fs::create_dir_all(store_base.join("packs"))?;
143        std::fs::create_dir_all(store_base.join("trees"))?;
144
145        // .gitignore
146        let gitignore_path = repo_dir.join(".gitignore");
147        if !gitignore_path.exists() {
148            std::fs::write(&gitignore_path, "index.bin\n*.sqlite-wal\n*.sqlite-shm\n")?;
149        }
150
151        // Only create initial commit for freshly init'd repos (not cloned ones).
152        if !cloned {
153            let has_commits = Self::git_ok(&repo_dir, &["rev-parse", "HEAD"]);
154            if has_commits.is_empty() {
155                Self::git(&repo_dir, &["add", "-A"])?;
156                Self::git(
157                    &repo_dir,
158                    &["commit", "-m", "init syncor repo", "--allow-empty"],
159                )?;
160                let _ = Self::git(&repo_dir, &["branch", "-M", "main"]);
161            }
162        }
163
164        Ok(())
165    }
166
167    fn push(&self, link: &LinkInfo, _store_path: &Path) -> Result<PushResult> {
168        let repo_dir = self.repo_dir(link);
169        let branch = Self::primary_branch(&repo_dir);
170
171        // Stage all.
172        Self::git(&repo_dir, &["add", "-A"])?;
173
174        // Check if there's anything to commit.
175        let status = Self::git_ok(&repo_dir, &["status", "--porcelain"]);
176        if status.trim().is_empty() {
177            // Nothing changed — still "success" but with current HEAD as revision.
178            let rev = Self::git_ok(&repo_dir, &["rev-parse", "HEAD"]);
179            return Ok(PushResult::Success {
180                revision: rev.trim().to_string(),
181            });
182        }
183
184        Self::git(&repo_dir, &["commit", "-m", "syncor push"])?;
185
186        // Push via CLI (uses system credential helpers).
187        retry_with_backoff(3, || {
188            let push_output = Command::new("git")
189                .args(["push", "-u", "origin", &branch])
190                .current_dir(&repo_dir)
191                .env("GIT_TERMINAL_PROMPT", "0")
192                .output()
193                .map_err(|e| SyncorError::Transport(format!("push exec: {}", e)))?;
194
195            if push_output.status.success() {
196                let rev = Self::git_ok(&repo_dir, &["rev-parse", "HEAD"]);
197                Ok(PushResult::Success {
198                    revision: rev.trim().to_string(),
199                })
200            } else {
201                let stderr = String::from_utf8_lossy(&push_output.stderr).to_string();
202                if stderr.contains("non-fast-forward") || stderr.contains("rejected") {
203                    Ok(PushResult::Conflict {
204                        details: ConflictInfo {
205                            message: stderr.trim().to_string(),
206                        },
207                    })
208                } else {
209                    Err(SyncorError::Transport(format!(
210                        "push failed: {}",
211                        stderr.trim()
212                    )))
213                }
214            }
215        })
216    }
217
218    fn pull(&self, link: &LinkInfo, _store_path: &Path) -> Result<PullResult> {
219        let repo_dir = self.repo_dir(link);
220        let branch = Self::primary_branch(&repo_dir);
221
222        // Record current HEAD before fetch.
223        let head_before = Self::git_ok(&repo_dir, &["rev-parse", "HEAD"]);
224
225        // Fetch via CLI.
226        retry_with_backoff(3, || {
227            let fetch_output = Command::new("git")
228                .args(["fetch", "origin", &branch])
229                .current_dir(&repo_dir)
230                .env("GIT_TERMINAL_PROMPT", "0")
231                .output()
232                .map_err(|e| SyncorError::Transport(format!("fetch exec: {}", e)))?;
233
234            if fetch_output.status.success() {
235                Ok(())
236            } else {
237                let stderr = String::from_utf8_lossy(&fetch_output.stderr).to_string();
238                Err(SyncorError::Transport(format!(
239                    "fetch failed: {}",
240                    stderr.trim()
241                )))
242            }
243        })?;
244
245        // Compare local vs remote.
246        let remote_ref = format!("origin/{}", branch);
247        let remote_head = Self::git_ok(&repo_dir, &["rev-parse", &remote_ref]);
248
249        if head_before.trim() == remote_head.trim() && !head_before.is_empty() {
250            return Ok(PullResult::UpToDate);
251        }
252
253        // Try fast-forward merge.
254        let merge_output = Command::new("git")
255            .args(["merge", "--ff-only", &remote_ref])
256            .current_dir(&repo_dir)
257            .env("GIT_TERMINAL_PROMPT", "0")
258            .output()
259            .map_err(|e| SyncorError::Transport(format!("merge exec: {}", e)))?;
260
261        if merge_output.status.success() {
262            let rev = Self::git_ok(&repo_dir, &["rev-parse", "HEAD"]);
263            Ok(PullResult::Success {
264                revision: rev.trim().to_string(),
265            })
266        } else {
267            // If merge --ff-only fails, it's a conflict (diverged histories).
268            Ok(PullResult::Conflict {
269                details: ConflictInfo {
270                    message: "cannot fast-forward; manual resolution needed".to_string(),
271                },
272            })
273        }
274    }
275
276    fn list_remote_links(&self, repo_url: &str) -> Result<Vec<RemoteLinkInfo>> {
277        let tmp = tempfile::tempdir()?;
278
279        let clone_output = Command::new("git")
280            .args(["clone", "--depth=1", repo_url, "."])
281            .current_dir(tmp.path())
282            .env("GIT_TERMINAL_PROMPT", "0")
283            .output();
284
285        match clone_output {
286            Ok(o) if o.status.success() => {}
287            _ => return Ok(vec![]),
288        }
289
290        let toml_path = tmp.path().join("syncor.toml");
291        if !toml_path.exists() {
292            return Ok(vec![]);
293        }
294
295        let contents = std::fs::read_to_string(&toml_path)?;
296
297        #[derive(serde::Deserialize)]
298        struct Manifest {
299            #[serde(default)]
300            links: Vec<ManifestLink>,
301        }
302        #[derive(serde::Deserialize)]
303        struct ManifestLink {
304            name: String,
305            #[serde(default)]
306            created_at: Option<String>,
307        }
308
309        let manifest: Manifest =
310            toml::from_str(&contents).map_err(|e| SyncorError::Config(e.to_string()))?;
311
312        Ok(manifest
313            .links
314            .into_iter()
315            .map(|l| RemoteLinkInfo {
316                name: l.name,
317                created_at: l.created_at.unwrap_or_default(),
318            })
319            .collect())
320    }
321
322    fn has_remote_changes(&self, link: &LinkInfo) -> Result<bool> {
323        let repo_dir = self.repo_dir(link);
324        let branch = Self::primary_branch(&repo_dir);
325
326        // Fetch with retry.
327        retry_with_backoff(3, || {
328            let fetch_output = Command::new("git")
329                .args(["fetch", "origin", &branch])
330                .current_dir(&repo_dir)
331                .env("GIT_TERMINAL_PROMPT", "0")
332                .output()
333                .map_err(|e| SyncorError::Transport(format!("fetch exec: {}", e)))?;
334
335            if fetch_output.status.success() {
336                Ok(())
337            } else {
338                let stderr = String::from_utf8_lossy(&fetch_output.stderr).to_string();
339                Err(SyncorError::Transport(format!(
340                    "fetch failed: {}",
341                    stderr.trim()
342                )))
343            }
344        })?;
345
346        let local = Self::git_ok(&repo_dir, &["rev-parse", "HEAD"]);
347        let remote = Self::git_ok(&repo_dir, &["rev-parse", &format!("origin/{}", branch)]);
348
349        Ok(local.trim() != remote.trim())
350    }
351}