1use std::collections::HashSet;
2use std::path::Path;
3
4use git2::Repository;
5
6use crate::Vendor;
7use crate::VendorSource;
8
9pub fn open_repo(path: Option<&Path>) -> Result<Repository, git2::Error> {
12 match path {
13 Some(p) => Repository::open(p),
14 None => Repository::open_from_env(),
15 }
16}
17
18pub fn list(repo: &Repository) -> Result<Vec<VendorSource>, git2::Error> {
23 repo.list_vendors()
24}
25
26pub fn add(
44 repo: &Repository,
45 name: &str,
46 url: &str,
47 branch: Option<&str>,
48 patterns: &[&str],
49 path: Option<&Path>,
50 file_favor: Option<git2::FileFavor>,
51) -> Result<MergeOutcome, Box<dyn std::error::Error>> {
52 if repo.get_vendor_by_name(name)?.is_some() {
53 return Err(format!("vendor '{}' already exists", name).into());
54 }
55
56 let workdir = repo
57 .workdir()
58 .ok_or_else(|| git2::Error::from_str("repository has no working directory"))?;
59
60 let cwd_rel: Option<std::path::PathBuf> = std::env::current_dir()
67 .ok()
68 .and_then(|cwd| cwd.canonicalize().ok())
69 .and_then(|cwd| {
70 let wd = workdir.canonicalize().ok()?;
71 cwd.strip_prefix(&wd).ok().map(|p| p.to_path_buf())
72 });
73
74 let resolved_path: Option<std::path::PathBuf> = match (path, &cwd_rel) {
84 (Some(p), Some(rel)) => {
87 let joined = rel.join(p);
88 let s = joined.to_string_lossy().replace('\\', "/");
90 let s = s.trim_end_matches('/');
91 if s.is_empty() || s == "." {
92 None
93 } else {
94 Some(std::path::PathBuf::from(s))
95 }
96 }
97 (Some(p), None) => Some(p.to_path_buf()),
98 (None, rel) => rel.as_ref().and_then(|r| {
100 if r == std::path::Path::new("") || r == std::path::Path::new(".") {
101 None
102 } else {
103 Some(r.clone())
104 }
105 }),
106 };
107
108 let raw_patterns: Vec<String> = apply_default_path(patterns, resolved_path.as_deref());
111
112 let source = VendorSource {
113 name: name.to_string(),
114 url: url.to_string(),
115 branch: branch.map(String::from),
116 base: None,
117 patterns: raw_patterns.clone(),
118 };
119
120 {
122 let mut cfg = repo
123 .vendor_config()
124 .or_else(|_| git2::Config::open(&workdir.join(".gitvendors")))?;
125 source.to_config(&mut cfg)?;
126 }
127
128 repo.fetch_vendor(&source, None)?;
130
131 let raw_pattern_refs: Vec<&str> = raw_patterns.iter().map(String::as_str).collect();
135 repo.track_vendor_pattern(&source, &raw_pattern_refs, Path::new("."))?;
136
137 let vendor_ref = repo.find_reference(&source.head_ref())?;
139 let vendor_commit = vendor_ref.peel_to_commit()?;
140 let updated = VendorSource {
141 name: source.name.clone(),
142 url: source.url.clone(),
143 branch: source.branch.clone(),
144 base: Some(vendor_commit.id().to_string()),
145 patterns: raw_patterns.clone(),
146 };
147 {
148 let mut cfg = repo.vendor_config()?;
149 updated.to_config(&mut cfg)?;
150 }
151
152 let merged_index = repo.add_vendor(&source, &raw_pattern_refs, Path::new("."), file_favor)?;
156
157 let outcome = checkout_and_stage(repo, merged_index, updated)?;
160
161 let mut repo_index = repo.index()?;
163 repo_index.add_path(Path::new(".gitvendors"))?;
164 if workdir.join(".gitattributes").exists() {
165 repo_index.add_path(Path::new(".gitattributes"))?;
166 }
167 repo_index.write()?;
168
169 Ok(outcome)
170}
171
172pub fn apply_default_path_pub(patterns: &[&str], path: Option<&Path>) -> Vec<String> {
177 apply_default_path(patterns, path)
178}
179
180fn apply_default_path(patterns: &[&str], path: Option<&Path>) -> Vec<String> {
181 let Some(dest_path) = path else {
182 return patterns.iter().map(|s| s.to_string()).collect();
183 };
184
185 let dest = {
187 let s = dest_path.to_string_lossy().replace('\\', "/");
188 let s = s.trim_end_matches('/');
189 if s.is_empty() || s == "." {
190 return patterns.iter().map(|s| s.to_string()).collect();
192 }
193 format!("{}/", s)
194 };
195
196 patterns
197 .iter()
198 .map(|raw| {
199 if raw.contains(':') {
200 raw.to_string()
202 } else {
203 format!("{}:{}", raw, dest)
204 }
205 })
206 .collect()
207}
208
209pub fn track(
214 repo: &Repository,
215 name: &str,
216 patterns: &[&str],
217) -> Result<(), Box<dyn std::error::Error>> {
218 let mut vendor = repo
219 .get_vendor_by_name(name)?
220 .ok_or_else(|| format!("vendor '{}' not found", name))?;
221
222 for pat in patterns {
223 let pat = pat.to_string();
224 if !vendor.patterns.contains(&pat) {
225 vendor.patterns.push(pat);
226 }
227 }
228
229 let mut cfg = repo.vendor_config()?;
230 vendor.to_config(&mut cfg)?;
231 Ok(())
232}
233
234pub fn untrack(
239 repo: &Repository,
240 name: &str,
241 patterns: &[&str],
242) -> Result<(), Box<dyn std::error::Error>> {
243 let mut vendor = repo
244 .get_vendor_by_name(name)?
245 .ok_or_else(|| format!("vendor '{}' not found", name))?;
246
247 let to_remove: std::collections::HashSet<&str> = patterns.iter().copied().collect();
248 vendor.patterns.retain(|p| !to_remove.contains(p.as_str()));
249
250 let mut cfg = repo.vendor_config()?;
251 vendor.to_config(&mut cfg)?;
252 Ok(())
253}
254
255pub fn fetch_one(
259 repo: &Repository,
260 name: &str,
261) -> Result<Option<git2::Oid>, Box<dyn std::error::Error>> {
262 let vendor = repo
263 .get_vendor_by_name(name)?
264 .ok_or_else(|| format!("vendor '{}' not found", name))?;
265 let old_oid = repo
266 .find_reference(&vendor.head_ref())
267 .ok()
268 .and_then(|r| r.target());
269 let reference = repo.fetch_vendor(&vendor, None)?;
270 let oid = reference
271 .target()
272 .ok_or_else(|| git2::Error::from_str("fetched ref is symbolic; expected a direct ref"))?;
273 if old_oid == Some(oid) {
274 Ok(None)
275 } else {
276 Ok(Some(oid))
277 }
278}
279
280pub fn fetch_all(
284 repo: &Repository,
285) -> Result<Vec<(String, git2::Oid)>, Box<dyn std::error::Error>> {
286 let vendors = repo.list_vendors()?;
287 let mut results = Vec::with_capacity(vendors.len());
288 for v in &vendors {
289 let old_oid = repo
290 .find_reference(&v.head_ref())
291 .ok()
292 .and_then(|r| r.target());
293 let reference = repo.fetch_vendor(v, None)?;
294 let oid = reference.target().ok_or_else(|| {
295 git2::Error::from_str("fetched ref is symbolic; expected a direct ref")
296 })?;
297 if old_oid != Some(oid) {
298 results.push((v.name.clone(), oid));
299 }
300 }
301 Ok(results)
302}
303
304#[derive(Debug)]
306pub struct VendorStatus {
307 pub name: String,
308 pub upstream_oid: Option<git2::Oid>,
311}
312
313pub fn status(repo: &Repository) -> Result<Vec<VendorStatus>, Box<dyn std::error::Error>> {
316 let statuses = repo.check_vendors()?;
317 let mut out: Vec<VendorStatus> = statuses
318 .into_iter()
319 .map(|(vendor, maybe_oid)| VendorStatus {
320 name: vendor.name,
321 upstream_oid: maybe_oid,
322 })
323 .collect();
324 out.sort_by(|a, b| a.name.cmp(&b.name));
325 Ok(out)
326}
327
328pub fn rm(repo: &Repository, name: &str) -> Result<(), Box<dyn std::error::Error>> {
337 let vendor = repo
338 .get_vendor_by_name(name)?
339 .ok_or_else(|| format!("vendor '{}' not found", name))?;
340
341 let workdir = repo
342 .workdir()
343 .ok_or_else(|| git2::Error::from_str("repository has no working directory"))?;
344
345 let vendored_entries = collect_vendored_entries(repo, name)?;
348
349 let vendors_path = workdir.join(".gitvendors");
351 if vendors_path.exists() {
352 remove_vendor_from_gitvendors(&vendors_path, name)?;
353 }
354
355 if let Ok(mut reference) = repo.find_reference(&vendor.head_ref()) {
357 reference.delete()?;
358 }
359
360 remove_vendor_attrs(workdir, name)?;
362
363 let mut index = repo.index()?;
365 index.add_path(Path::new(".gitvendors"))?;
366 for entry in find_gitattributes(workdir) {
367 let rel = entry.strip_prefix(workdir).unwrap_or(&entry);
368 if rel.exists() || workdir.join(rel).exists() {
369 index.add_path(rel)?;
370 }
371 }
372
373 for entry in &vendored_entries {
376 let path = std::str::from_utf8(&entry.path)
377 .map_err(|e| git2::Error::from_str(&format!("invalid UTF-8 in path: {}", e)))?;
378
379 index.remove(Path::new(path), 0)?;
381
382 if entry.file_size == 0 {
383 let abs = workdir.join(path);
385 if abs.exists() {
386 std::fs::remove_file(&abs)?;
387 }
388 continue;
389 }
390
391 let make_entry = |stage: u16| git2::IndexEntry {
392 ctime: entry.ctime,
393 mtime: entry.mtime,
394 dev: entry.dev,
395 ino: entry.ino,
396 mode: entry.mode,
397 uid: entry.uid,
398 gid: entry.gid,
399 file_size: entry.file_size,
400 id: entry.id,
401 flags: (entry.flags & 0x0FFF) | (stage << 12),
402 flags_extended: entry.flags_extended,
403 path: entry.path.clone(),
404 };
405
406 index.add(&make_entry(1))?;
408 index.add(&make_entry(2))?;
410 }
412
413 index.write()?;
414
415 Ok(())
416}
417
418fn collect_vendored_entries(
420 repo: &Repository,
421 name: &str,
422) -> Result<Vec<git2::IndexEntry>, Box<dyn std::error::Error>> {
423 let index = repo.index()?;
424 let mut entries = Vec::new();
425 for entry in index.iter() {
426 let stage = (entry.flags >> 12) & 0x3;
427 if stage != 0 {
428 continue;
429 }
430 let path = std::str::from_utf8(&entry.path)
431 .map_err(|e| git2::Error::from_str(&format!("invalid UTF-8 in path: {}", e)))?;
432 match repo.get_attr(
433 Path::new(path),
434 "vendor",
435 git2::AttrCheckFlags::FILE_THEN_INDEX,
436 ) {
437 Ok(Some(value)) if value == name => entries.push(entry),
438 _ => {}
439 }
440 }
441 Ok(entries)
442}
443
444pub fn prune(repo: &Repository) -> Result<Vec<String>, Box<dyn std::error::Error>> {
449 let vendors = repo.list_vendors()?;
450 let known: HashSet<String> = vendors.into_iter().map(|v| v.name).collect();
451
452 let mut pruned = Vec::new();
453 for reference in repo.references_glob("refs/vendor/*")? {
454 let reference = reference?;
455 let refname = reference.name().unwrap_or("").to_string();
456 let vendor_name = refname.strip_prefix("refs/vendor/").unwrap_or("");
457 if !vendor_name.is_empty() && !known.contains(vendor_name) {
458 pruned.push(vendor_name.to_string());
459 }
460 }
461
462 for name in &pruned {
463 let refname = format!("refs/vendor/{}", name);
464 if let Ok(mut r) = repo.find_reference(&refname) {
465 r.delete()?;
466 }
467 }
468
469 Ok(pruned)
470}
471
472fn remove_vendor_from_gitvendors(
479 path: &Path,
480 name: &str,
481) -> Result<(), Box<dyn std::error::Error>> {
482 let content = std::fs::read_to_string(path)?;
483 let header = format!("[vendor \"{}\"]", name);
484 let mut out = String::new();
485 let mut skip = false;
486
487 for line in content.lines() {
488 let trimmed = line.trim();
489 if trimmed == header {
490 skip = true;
491 continue;
492 }
493 if skip && trimmed.starts_with('[') {
495 skip = false;
496 }
497 if !skip {
498 out.push_str(line);
499 out.push('\n');
500 }
501 }
502
503 std::fs::write(path, out)?;
504 Ok(())
505}
506
507fn find_gitattributes(workdir: &Path) -> Vec<std::path::PathBuf> {
510 let mut results = Vec::new();
511 fn walk(dir: &Path, results: &mut Vec<std::path::PathBuf>) {
512 let Ok(entries) = std::fs::read_dir(dir) else {
513 return;
514 };
515 for entry in entries.flatten() {
516 let path = entry.path();
517 if path.is_dir() {
518 if path.file_name().is_some_and(|n| n == ".git") {
520 continue;
521 }
522 walk(&path, results);
523 } else if path.file_name().is_some_and(|n| n == ".gitattributes") {
524 results.push(path);
525 }
526 }
527 }
528 walk(workdir, &mut results);
529 results
530}
531
532fn remove_vendor_attrs(workdir: &Path, name: &str) -> Result<(), Box<dyn std::error::Error>> {
535 let needle = format!("vendor={}", name);
536 for attr_path in find_gitattributes(workdir) {
537 let content = std::fs::read_to_string(&attr_path)?;
538 let filtered: Vec<&str> = content
539 .lines()
540 .filter(|line| !line.split_whitespace().any(|token| token == needle))
541 .collect();
542 if filtered.len() < content.lines().count() {
544 let mut out = filtered.join("\n");
545 if !out.is_empty() {
546 out.push('\n');
547 }
548 std::fs::write(&attr_path, out)?;
549 }
550 }
551 Ok(())
552}
553
554pub enum MergeOutcome {
556 UpToDate {
559 vendor: VendorSource,
561 },
562 Clean {
565 vendor: VendorSource,
567 },
568 Conflict {
573 index: git2::Index,
574 vendor: VendorSource,
576 },
577}
578
579pub fn merge_one(
586 repo: &Repository,
587 name: &str,
588 file_favor: Option<git2::FileFavor>,
589) -> Result<MergeOutcome, Box<dyn std::error::Error>> {
590 let vendor = repo
591 .get_vendor_by_name(name)?
592 .ok_or_else(|| format!("vendor '{}' not found", name))?;
593 merge_vendor(repo, &vendor, file_favor)
594}
595
596pub fn merge_all(
601 repo: &Repository,
602 file_favor: Option<git2::FileFavor>,
603) -> Result<Vec<(String, MergeOutcome)>, Box<dyn std::error::Error>> {
604 let vendors = repo.list_vendors()?;
605 let mut results = Vec::with_capacity(vendors.len());
606 for v in &vendors {
607 let outcome = merge_vendor(repo, v, file_favor)?;
608 results.push((v.name.clone(), outcome));
609 }
610 Ok(results)
611}
612
613fn checkout_and_stage(
630 repo: &Repository,
631 mut merged_index: git2::Index,
632 vendor: VendorSource,
633) -> Result<MergeOutcome, Box<dyn std::error::Error>> {
634 let has_conflicts = merged_index.has_conflicts();
635
636 let paths: Vec<String> = merged_index
638 .iter()
639 .filter_map(|entry| std::str::from_utf8(&entry.path).ok().map(String::from))
640 .collect();
641
642 let mut checkout = git2::build::CheckoutBuilder::new();
645 checkout.force();
646 checkout.allow_conflicts(true);
647 checkout.conflict_style_merge(true);
648 for p in &paths {
649 checkout.path(p);
650 }
651 repo.checkout_index(Some(&mut merged_index), Some(&mut checkout))?;
652
653 let mut repo_index = repo.index()?;
655 for entry in merged_index.iter() {
656 let stage = (entry.flags >> 12) & 0x3;
657 if stage != 0 {
658 continue;
659 }
660 let entry_path = std::str::from_utf8(&entry.path)
661 .map_err(|e| git2::Error::from_str(&format!("invalid UTF-8 in path: {}", e)))?;
662 repo_index.add_path(Path::new(entry_path))?;
663 }
664 repo_index.write()?;
665
666 if has_conflicts {
667 Ok(MergeOutcome::Conflict {
668 index: merged_index,
669 vendor,
670 })
671 } else {
672 Ok(MergeOutcome::Clean { vendor })
673 }
674}
675
676fn merge_vendor(
682 repo: &Repository,
683 vendor: &VendorSource,
684 file_favor: Option<git2::FileFavor>,
685) -> Result<MergeOutcome, Box<dyn std::error::Error>> {
686 let vendor_ref = repo.find_reference(&vendor.head_ref())?;
687 let vendor_commit = vendor_ref.peel_to_commit()?;
688
689 if let Some(base) = &vendor.base
691 && git2::Oid::from_str(base)? == vendor_commit.id()
692 {
693 return Ok(MergeOutcome::UpToDate {
694 vendor: vendor.clone(),
695 });
696 }
697
698 let updated = VendorSource {
700 name: vendor.name.clone(),
701 url: vendor.url.clone(),
702 branch: vendor.branch.clone(),
703 base: Some(vendor_commit.id().to_string()),
704 patterns: vendor.patterns.clone(),
705 };
706 {
707 let mut cfg = repo.vendor_config()?;
708 updated.to_config(&mut cfg)?;
709 }
710
711 let merged_index = repo.merge_vendor(vendor, None, file_favor)?;
712
713 repo.refresh_vendor_attrs(vendor, &merged_index, Path::new("."))?;
716
717 let outcome = checkout_and_stage(repo, merged_index, updated)?;
718
719 let mut repo_index = repo.index()?;
721 repo_index.add_path(Path::new(".gitvendors"))?;
722 if Path::new(".gitattributes").exists() {
723 repo_index.add_path(Path::new(".gitattributes"))?;
724 }
725 repo_index.write()?;
726
727 Ok(outcome)
728}