1use crate::config::Dependency;
19use crate::sync::resolve_version_constraint;
20use std::path::{Path, PathBuf};
21use std::process::Command;
22
23pub fn git_bin() -> String {
31 for candidate in [
32 "git".to_string(),
33 "/usr/bin/git".to_string(),
34 "/usr/local/bin/git".to_string(),
35 "/bin/git".to_string(),
36 ] {
37 if Command::new(&candidate)
38 .arg("--version")
39 .output()
40 .map(|o| o.status.success())
41 .unwrap_or(false)
42 {
43 return candidate;
44 }
45 }
46 "git".to_string()
48}
49
50#[derive(Debug)]
52pub enum FetchError {
53 GitUnavailable,
55 Git {
57 args: String,
58 status: Option<i32>,
59 stderr: String,
60 },
61 NoSource(String),
63 MissingUrl(String),
65 Io {
67 context: String,
68 source: std::io::Error,
69 },
70}
71
72impl std::fmt::Display for FetchError {
73 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
74 match self {
75 FetchError::GitUnavailable => {
76 write!(
77 f,
78 "the `git` executable is required but was not found on PATH"
79 )
80 }
81 FetchError::Git {
82 args,
83 status,
84 stderr,
85 } => write!(
86 f,
87 "git {} failed (exit {:?}): {}",
88 args,
89 status,
90 stderr.trim()
91 ),
92 FetchError::NoSource(name) => {
93 write!(f, "dependency '{}' declares neither `path` nor `git`", name)
94 }
95 FetchError::MissingUrl(name) => {
96 write!(f, "git dependency '{}' is missing a `git` URL", name)
97 }
98 FetchError::Io { context, source } => {
99 write!(f, "I/O error {}: {}", context, source)
100 }
101 }
102 }
103}
104
105impl std::error::Error for FetchError {}
106
107#[derive(Debug, Clone, PartialEq, Eq)]
109pub struct FetchOutcome {
110 pub name: String,
112 pub local_path: PathBuf,
114 pub resolved_rev: String,
116 pub already_present: bool,
118}
119
120pub trait DependencyFetcher {
122 fn fetch(&self, name: &str, dep: &Dependency, force: bool) -> Result<FetchOutcome, FetchError>;
125}
126
127pub fn git_cache_key(dep: &Dependency) -> String {
132 let url = dep.git.clone().unwrap_or_default();
133 let reference = dep
134 .tag
135 .clone()
136 .or_else(|| dep.branch.clone())
137 .or_else(|| dep.rev.clone())
138 .unwrap_or_else(|| "head".to_string());
139 let mut h: u64 = 0xcbf29ce484222325;
141 for b in url.as_bytes().iter().chain(reference.as_bytes()) {
142 h ^= *b as u64;
143 h = h.wrapping_mul(0x100000001b3);
144 }
145 format!("{:016x}", h)
146}
147
148pub struct GitFetcher {
155 pub cache_root: PathBuf,
157}
158
159impl GitFetcher {
160 pub fn new(cache_root: impl Into<PathBuf>) -> Self {
162 Self {
163 cache_root: cache_root.into(),
164 }
165 }
166
167 fn git_available() -> Result<(), FetchError> {
168 Command::new(git_bin())
169 .arg("--version")
170 .output()
171 .map_err(|_| FetchError::GitUnavailable)?;
172 Ok(())
173 }
174
175 fn head_rev(checkout: &Path) -> String {
177 let out = Command::new(git_bin())
178 .current_dir(checkout)
179 .args(["rev-parse", "HEAD"])
180 .output();
181 match out {
182 Ok(o) if o.status.success() => String::from_utf8_lossy(&o.stdout).trim().to_string(),
183 _ => String::new(),
184 }
185 }
186
187 pub fn resolved_rev_for(dep: &Dependency, checkout: &Path) -> String {
189 if let Some(rev) = &dep.rev {
190 return rev.clone();
191 }
192 let refspec = dep
193 .tag
194 .as_ref()
195 .map(|t| format!("refs/tags/{}", t))
196 .or_else(|| {
197 dep.branch
198 .as_ref()
199 .map(|b| format!("refs/remotes/origin/{}", b))
200 })
201 .unwrap_or_default();
202 if refspec.is_empty() {
203 return Self::head_rev(checkout);
204 }
205 let out = Command::new(git_bin())
206 .current_dir(checkout)
207 .args(["rev-parse", &refspec])
208 .output();
209 match out {
210 Ok(o) if o.status.success() => String::from_utf8_lossy(&o.stdout).trim().to_string(),
211 _ => String::new(),
212 }
213 }
214}
215
216impl DependencyFetcher for GitFetcher {
217 fn fetch(&self, name: &str, dep: &Dependency, force: bool) -> Result<FetchOutcome, FetchError> {
218 let url = dep
219 .git
220 .clone()
221 .ok_or_else(|| FetchError::MissingUrl(name.to_string()))?;
222 if url.trim().is_empty() {
223 return Err(FetchError::MissingUrl(name.to_string()));
224 }
225
226 Self::git_available()?;
227
228 let effective_dep = if let Some(ver) = &dep.version {
235 if dep.tag.is_none() && dep.branch.is_none() && dep.rev.is_none() {
236 match resolve_version_constraint(&url, ver) {
237 Ok(Some((tag, _))) => Dependency {
238 git: Some(url.clone()),
239 tag: Some(tag),
240 ..Default::default()
241 },
242 Ok(None) => {
243 return Err(FetchError::Git {
244 args: format!("resolve version '{}'", ver),
245 status: Some(1),
246 stderr: format!("no remote tag satisfies constraint '{}'", ver),
247 });
248 }
249 Err(e) => {
250 return Err(FetchError::Git {
251 args: format!("resolve version '{}'", ver),
252 status: None,
253 stderr: e.to_string(),
254 });
255 }
256 }
257 } else {
258 dep.clone()
260 }
261 } else {
262 dep.clone()
263 };
264
265 let key = git_cache_key(&effective_dep);
266 let checkout = self.cache_root.join("git").join(&key);
267 let parent = checkout
273 .parent()
274 .ok_or_else(|| FetchError::Io {
275 context: format!("invalid cache path {}", checkout.display()),
276 source: std::io::Error::new(std::io::ErrorKind::InvalidInput, "no parent"),
277 })?
278 .to_path_buf();
279 std::fs::create_dir_all(&parent).map_err(|source| FetchError::Io {
280 context: format!("creating cache dir {}", parent.display()),
281 source,
282 })?;
283
284 let existing = checkout.join(".git").exists();
285 if existing && !force {
286 let want = Self::resolved_rev_for(&effective_dep, &checkout);
288 if !want.is_empty() && want == Self::head_rev(&checkout) {
289 return Ok(FetchOutcome {
290 name: name.to_string(),
291 local_path: checkout,
292 resolved_rev: want,
293 already_present: true,
294 });
295 }
296 }
297
298 let (workdir, clone_args) = if existing {
304 let workdir = checkout.clone();
305 let args = if force {
306 vec![
307 "fetch".to_string(),
308 "--force".to_string(),
309 url.clone(),
310 "+refs/heads/*:refs/remotes/origin/*".to_string(),
311 "+refs/tags/*:refs/tags/*".to_string(),
312 ]
313 } else {
314 vec!["fetch".to_string()]
315 };
316 (workdir, args)
317 } else {
318 (
319 parent,
320 vec![
321 "clone".to_string(),
322 "--no-checkout".to_string(),
323 url.clone(),
324 checkout.to_string_lossy().to_string(),
325 ],
326 )
327 };
328 run_git(&workdir, &clone_args)?;
329
330 let checkout_ref: String = if let Some(rev) = &effective_dep.rev {
332 rev.clone()
333 } else if let Some(tag) = &effective_dep.tag {
334 tag.clone()
335 } else if let Some(branch) = &effective_dep.branch {
336 format!("origin/{}", branch)
337 } else {
338 "origin/HEAD".to_string()
339 };
340 run_git(
341 &checkout,
342 &["checkout".to_string(), "--force".to_string(), checkout_ref],
343 )?;
344
345 let resolved = Self::resolved_rev_for(&effective_dep, &checkout);
346 Ok(FetchOutcome {
347 name: name.to_string(),
348 local_path: checkout,
349 resolved_rev: resolved,
350 already_present: false,
351 })
352 }
353}
354
355pub struct PathResolver;
358
359impl DependencyFetcher for PathResolver {
360 fn fetch(
361 &self,
362 name: &str,
363 dep: &Dependency,
364 _force: bool,
365 ) -> Result<FetchOutcome, FetchError> {
366 let path = dep
367 .path
368 .clone()
369 .ok_or_else(|| FetchError::NoSource(name.to_string()))?;
370 if path.trim().is_empty() {
371 return Err(FetchError::NoSource(name.to_string()));
372 }
373 Ok(FetchOutcome {
374 name: name.to_string(),
375 local_path: PathBuf::from(path),
376 resolved_rev: String::new(),
377 already_present: true,
378 })
379 }
380}
381
382fn run_git(dir: &Path, args: &[String]) -> Result<(), FetchError> {
384 let out = Command::new(git_bin())
385 .current_dir(dir)
386 .args(args)
387 .output()
388 .map_err(|_| FetchError::GitUnavailable)?;
389 if out.status.success() {
390 Ok(())
391 } else {
392 Err(FetchError::Git {
393 args: args.join(" "),
394 status: out.status.code(),
395 stderr: String::from_utf8_lossy(&out.stderr).to_string(),
396 })
397 }
398}
399
400#[cfg(test)]
401mod tests {
402 use super::*;
403 use crate::config::{Dependency, DevKitConfig, PackageConfig};
404 use std::collections::HashMap;
405 use std::io::Write;
406
407 fn git_cmd(dir: &Path) -> Command {
408 let mut c = Command::new("git");
409 c.current_dir(dir)
410 .env("GIT_CONFIG_COUNT", "1")
415 .env("GIT_CONFIG_KEY_0", "safe.directory")
416 .env("GIT_CONFIG_VALUE_0", "*");
417 c
418 }
419
420 fn fresh_repo_dir() -> PathBuf {
427 use std::sync::atomic::{AtomicU64, Ordering};
428 static COUNTER: AtomicU64 = AtomicU64::new(0);
429 let n = COUNTER.fetch_add(1, Ordering::Relaxed);
430 let nanos = std::time::SystemTime::now()
431 .duration_since(std::time::UNIX_EPOCH)
432 .unwrap()
433 .as_nanos();
434 let pid = std::process::id();
435 let dir = std::env::temp_dir().join(format!("sdkt-fetch-src-{}-{}-{}", pid, nanos, n));
436 let _ = std::fs::remove_dir_all(&dir);
439 std::fs::create_dir_all(&dir).unwrap();
440 dir
441 }
442
443 fn make_git_repo() -> PathBuf {
444 let dir = fresh_repo_dir();
445 let run = |args: &[&str]| {
446 let o = git_cmd(&dir).args(args).output().expect("git available");
447 if !o.status.success() {
448 panic!(
449 "git {:?} failed (exit {:?}):\n--- stdout ---\n{}\n--- stderr ---\n{}",
450 args,
451 o.status.code(),
452 String::from_utf8_lossy(&o.stdout),
453 String::from_utf8_lossy(&o.stderr)
454 );
455 }
456 };
457 run(&["init", "-q"]);
458 run(&["config", "user.email", "test@sdkt.local"]);
459 run(&["config", "user.name", "sdkt test"]);
460 {
465 let f = dir.join("lib.rs");
466 let mut fh = std::fs::File::create(&f).unwrap();
467 fh.write_all(b"pub fn answer() -> u32 { 42 }").unwrap();
468 }
469 run(&["add", "lib.rs"]);
470 run(&["commit", "-q", "-m", "initial"]);
471 run(&["tag", "v1.0.0"]);
472 {
474 let f = dir.join("lib.rs");
475 let mut fh = std::fs::File::create(&f).unwrap();
476 fh.write_all(b"pub fn answer() -> u32 { 43 }").unwrap();
477 }
478 run(&["add", "lib.rs"]);
479 run(&["commit", "-q", "-m", "second"]);
480 dir
481 }
482
483 fn config_with_git(
484 url: &str,
485 tag: Option<&str>,
486 branch: Option<&str>,
487 rev: Option<&str>,
488 ) -> DevKitConfig {
489 DevKitConfig {
490 package: Some(PackageConfig {
491 name: Some("app".to_string()),
492 version: Some("0.1.0".to_string()),
493 description: None,
494 }),
495 dependencies: {
496 let mut m = HashMap::new();
497 m.insert(
498 "dep".to_string(),
499 Dependency {
500 git: Some(url.to_string()),
501 tag: tag.map(|s| s.to_string()),
502 branch: branch.map(|s| s.to_string()),
503 rev: rev.map(|s| s.to_string()),
504 ..Default::default()
505 },
506 );
507 m
508 },
509 ..Default::default()
510 }
511 }
512
513 #[test]
514 fn fetch_git_by_tag() {
515 let src = make_git_repo();
516 let url = src.to_string_lossy().to_string();
517 let cfg = config_with_git(&url, Some("v1.0.0"), None, None);
518 let dep = cfg.dependencies.get("dep").unwrap();
519
520 let cache = std::env::temp_dir().join(format!(
521 "sdkt-fetch-cache-{}",
522 std::time::SystemTime::now()
523 .duration_since(std::time::UNIX_EPOCH)
524 .unwrap()
525 .as_nanos()
526 ));
527 let _ = std::fs::remove_dir_all(&cache);
528
529 let fetcher = GitFetcher::new(&cache);
530 let out = fetcher.fetch("dep", dep, false).expect("fetch ok");
531 assert!(out.local_path.exists());
532 assert!(out.local_path.join(".git").exists());
533 assert!(!out.resolved_rev.is_empty());
534 let lib = std::fs::read_to_string(out.local_path.join("lib.rs")).unwrap();
536 assert!(lib.contains("42"), "expected tagged content, got: {}", lib);
537
538 let out2 = fetcher.fetch("dep", dep, false).expect("fetch ok");
540 assert!(out2.already_present);
541
542 let _ = std::fs::remove_dir_all(&cache);
543 let _ = std::fs::remove_dir_all(&src);
544 }
545
546 #[test]
547 fn fetch_git_by_rev() {
548 let src = make_git_repo();
549 let url = src.to_string_lossy().to_string();
550 let cfg = config_with_git(&url, None, None, None);
551 let _dep = cfg.dependencies.get("dep").unwrap();
552 let rev = {
554 let o = git_cmd(&src).args(["rev-parse", "HEAD"]).output().unwrap();
555 String::from_utf8_lossy(&o.stdout).trim().to_string()
556 };
557 let cfg2 = config_with_git(&url, None, None, Some(&rev));
558 let dep2 = cfg2.dependencies.get("dep").unwrap();
559
560 let cache = std::env::temp_dir().join(format!(
561 "sdkt-fetch-cache-rev-{}",
562 std::time::SystemTime::now()
563 .duration_since(std::time::UNIX_EPOCH)
564 .unwrap()
565 .as_nanos()
566 ));
567 let _ = std::fs::remove_dir_all(&cache);
568 let fetcher = GitFetcher::new(&cache);
569 let out = fetcher.fetch("dep", dep2, false).expect("fetch ok");
570 assert_eq!(out.resolved_rev, rev);
571 let lib = std::fs::read_to_string(out.local_path.join("lib.rs")).unwrap();
572 assert!(lib.contains("43"), "expected head content, got: {}", lib);
573
574 let _ = std::fs::remove_dir_all(&cache);
575 let _ = std::fs::remove_dir_all(&src);
576 let _ = &cfg; }
578
579 #[test]
580 fn path_resolver_returns_path() {
581 let tmp = std::env::temp_dir().join("sdkt-fetch-path");
582 let _ = std::fs::remove_dir_all(&tmp);
583 std::fs::create_dir_all(&tmp).unwrap();
584 let dep = Dependency {
585 path: Some(tmp.to_string_lossy().to_string()),
586 ..Default::default()
587 };
588 let out = PathResolver.fetch("local", &dep, false).unwrap();
589 assert_eq!(out.local_path, tmp);
590 assert!(out.resolved_rev.is_empty());
591 let _ = std::fs::remove_dir_all(&tmp);
592 }
593}