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
9fn 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
45pub 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 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 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 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 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 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 Self::git(&repo_dir, &["init"])?;
137 Self::git(&repo_dir, &["remote", "add", "origin", &link.repo])?;
138 }
139
140 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 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 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 Self::git(&repo_dir, &["add", "-A"])?;
173
174 let status = Self::git_ok(&repo_dir, &["status", "--porcelain"]);
176 if status.trim().is_empty() {
177 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 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 let head_before = Self::git_ok(&repo_dir, &["rev-parse", "HEAD"]);
224
225 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 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 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 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 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}