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(
41 repo: &Repository,
42 name: &str,
43 url: &str,
44 branch: Option<&str>,
45 patterns: &[&str],
46 path: Option<&Path>,
47 file_favor: Option<git2::FileFavor>,
48) -> Result<MergeOutcome, Box<dyn std::error::Error>> {
49 if repo.get_vendor_by_name(name)?.is_some() {
50 return Err(format!("vendor '{}' already exists", name).into());
51 }
52
53 let workdir = repo
54 .workdir()
55 .ok_or_else(|| git2::Error::from_str("repository has no working directory"))?;
56
57 let source = VendorSource {
58 name: name.to_string(),
59 url: url.to_string(),
60 branch: branch.map(String::from),
61 base: None,
62 patterns: patterns.iter().map(|s| s.to_string()).collect(),
63 };
64
65 {
67 let mut cfg = repo
68 .vendor_config()
69 .or_else(|_| git2::Config::open(&workdir.join(".gitvendors")))?;
70 source.to_config(&mut cfg)?;
71 }
72
73 repo.fetch_vendor(&source, None)?;
75
76 let path = path.unwrap_or_else(|| Path::new("."));
78 repo.track_vendor_pattern(&source, patterns, path)?;
79
80 let vendor_ref = repo.find_reference(&source.head_ref())?;
82 let vendor_commit = vendor_ref.peel_to_commit()?;
83 let updated = VendorSource {
84 name: source.name.clone(),
85 url: source.url.clone(),
86 branch: source.branch.clone(),
87 base: Some(vendor_commit.id().to_string()),
88 patterns: source.patterns.clone(),
89 };
90 {
91 let mut cfg = repo.vendor_config()?;
92 updated.to_config(&mut cfg)?;
93 }
94
95 let merged_index = repo.add_vendor(&source, patterns, path, file_favor)?;
98
99 let outcome = checkout_and_stage(repo, merged_index, updated)?;
102
103 let mut repo_index = repo.index()?;
105 repo_index.add_path(Path::new(".gitvendors"))?;
106 let attr_path = if path == Path::new(".") {
107 std::path::PathBuf::from(".gitattributes")
108 } else {
109 path.join(".gitattributes")
110 };
111 if workdir.join(&attr_path).exists() {
112 repo_index.add_path(&attr_path)?;
113 }
114 repo_index.write()?;
115
116 Ok(outcome)
117}
118
119pub fn track(
124 repo: &Repository,
125 name: &str,
126 patterns: &[&str],
127) -> Result<(), Box<dyn std::error::Error>> {
128 let mut vendor = repo
129 .get_vendor_by_name(name)?
130 .ok_or_else(|| format!("vendor '{}' not found", name))?;
131
132 for pat in patterns {
133 let pat = pat.to_string();
134 if !vendor.patterns.contains(&pat) {
135 vendor.patterns.push(pat);
136 }
137 }
138
139 let mut cfg = repo.vendor_config()?;
140 vendor.to_config(&mut cfg)?;
141 Ok(())
142}
143
144pub fn untrack(
149 repo: &Repository,
150 name: &str,
151 patterns: &[&str],
152) -> Result<(), Box<dyn std::error::Error>> {
153 let mut vendor = repo
154 .get_vendor_by_name(name)?
155 .ok_or_else(|| format!("vendor '{}' not found", name))?;
156
157 let to_remove: std::collections::HashSet<&str> = patterns.iter().copied().collect();
158 vendor.patterns.retain(|p| !to_remove.contains(p.as_str()));
159
160 let mut cfg = repo.vendor_config()?;
161 vendor.to_config(&mut cfg)?;
162 Ok(())
163}
164
165pub fn fetch_one(
169 repo: &Repository,
170 name: &str,
171) -> Result<Option<git2::Oid>, Box<dyn std::error::Error>> {
172 let vendor = repo
173 .get_vendor_by_name(name)?
174 .ok_or_else(|| format!("vendor '{}' not found", name))?;
175 let old_oid = repo
176 .find_reference(&vendor.head_ref())
177 .ok()
178 .and_then(|r| r.target());
179 let reference = repo.fetch_vendor(&vendor, None)?;
180 let oid = reference
181 .target()
182 .ok_or_else(|| git2::Error::from_str("fetched ref is symbolic; expected a direct ref"))?;
183 if old_oid == Some(oid) {
184 Ok(None)
185 } else {
186 Ok(Some(oid))
187 }
188}
189
190pub fn fetch_all(
194 repo: &Repository,
195) -> Result<Vec<(String, git2::Oid)>, Box<dyn std::error::Error>> {
196 let vendors = repo.list_vendors()?;
197 let mut results = Vec::with_capacity(vendors.len());
198 for v in &vendors {
199 let old_oid = repo
200 .find_reference(&v.head_ref())
201 .ok()
202 .and_then(|r| r.target());
203 let reference = repo.fetch_vendor(v, None)?;
204 let oid = reference.target().ok_or_else(|| {
205 git2::Error::from_str("fetched ref is symbolic; expected a direct ref")
206 })?;
207 if old_oid != Some(oid) {
208 results.push((v.name.clone(), oid));
209 }
210 }
211 Ok(results)
212}
213
214#[derive(Debug)]
216pub struct VendorStatus {
217 pub name: String,
218 pub upstream_oid: Option<git2::Oid>,
221}
222
223pub fn status(repo: &Repository) -> Result<Vec<VendorStatus>, Box<dyn std::error::Error>> {
226 let statuses = repo.check_vendors()?;
227 let mut out: Vec<VendorStatus> = statuses
228 .into_iter()
229 .map(|(vendor, maybe_oid)| VendorStatus {
230 name: vendor.name,
231 upstream_oid: maybe_oid,
232 })
233 .collect();
234 out.sort_by(|a, b| a.name.cmp(&b.name));
235 Ok(out)
236}
237
238pub fn rm(repo: &Repository, name: &str) -> Result<(), Box<dyn std::error::Error>> {
247 let vendor = repo
248 .get_vendor_by_name(name)?
249 .ok_or_else(|| format!("vendor '{}' not found", name))?;
250
251 let workdir = repo
252 .workdir()
253 .ok_or_else(|| git2::Error::from_str("repository has no working directory"))?;
254
255 let vendored_entries = collect_vendored_entries(repo, name)?;
258
259 let vendors_path = workdir.join(".gitvendors");
261 if vendors_path.exists() {
262 remove_vendor_from_gitvendors(&vendors_path, name)?;
263 }
264
265 if let Ok(mut reference) = repo.find_reference(&vendor.head_ref()) {
267 reference.delete()?;
268 }
269
270 remove_vendor_attrs(workdir, name)?;
272
273 let mut index = repo.index()?;
275 index.add_path(Path::new(".gitvendors"))?;
276 for entry in find_gitattributes(workdir) {
277 let rel = entry.strip_prefix(workdir).unwrap_or(&entry);
278 if rel.exists() || workdir.join(rel).exists() {
279 index.add_path(rel)?;
280 }
281 }
282
283 for entry in &vendored_entries {
286 let path = std::str::from_utf8(&entry.path)
287 .map_err(|e| git2::Error::from_str(&format!("invalid UTF-8 in path: {}", e)))?;
288
289 index.remove(Path::new(path), 0)?;
291
292 if entry.file_size == 0 {
293 let abs = workdir.join(path);
295 if abs.exists() {
296 std::fs::remove_file(&abs)?;
297 }
298 continue;
299 }
300
301 let make_entry = |stage: u16| git2::IndexEntry {
302 ctime: entry.ctime,
303 mtime: entry.mtime,
304 dev: entry.dev,
305 ino: entry.ino,
306 mode: entry.mode,
307 uid: entry.uid,
308 gid: entry.gid,
309 file_size: entry.file_size,
310 id: entry.id,
311 flags: (entry.flags & 0x0FFF) | (stage << 12),
312 flags_extended: entry.flags_extended,
313 path: entry.path.clone(),
314 };
315
316 index.add(&make_entry(1))?;
318 index.add(&make_entry(2))?;
320 }
322
323 index.write()?;
324
325 Ok(())
326}
327
328fn collect_vendored_entries(
330 repo: &Repository,
331 name: &str,
332) -> Result<Vec<git2::IndexEntry>, Box<dyn std::error::Error>> {
333 let index = repo.index()?;
334 let mut entries = Vec::new();
335 for entry in index.iter() {
336 let stage = (entry.flags >> 12) & 0x3;
337 if stage != 0 {
338 continue;
339 }
340 let path = std::str::from_utf8(&entry.path)
341 .map_err(|e| git2::Error::from_str(&format!("invalid UTF-8 in path: {}", e)))?;
342 match repo.get_attr(
343 Path::new(path),
344 "vendor",
345 git2::AttrCheckFlags::FILE_THEN_INDEX,
346 ) {
347 Ok(Some(value)) if value == name => entries.push(entry),
348 _ => {}
349 }
350 }
351 Ok(entries)
352}
353
354pub fn prune(repo: &Repository) -> Result<Vec<String>, Box<dyn std::error::Error>> {
359 let vendors = repo.list_vendors()?;
360 let known: HashSet<String> = vendors.into_iter().map(|v| v.name).collect();
361
362 let mut pruned = Vec::new();
363 for reference in repo.references_glob("refs/vendor/*")? {
364 let reference = reference?;
365 let refname = reference.name().unwrap_or("").to_string();
366 let vendor_name = refname.strip_prefix("refs/vendor/").unwrap_or("");
367 if !vendor_name.is_empty() && !known.contains(vendor_name) {
368 pruned.push(vendor_name.to_string());
369 }
370 }
371
372 for name in &pruned {
373 let refname = format!("refs/vendor/{}", name);
374 if let Ok(mut r) = repo.find_reference(&refname) {
375 r.delete()?;
376 }
377 }
378
379 Ok(pruned)
380}
381
382fn remove_vendor_from_gitvendors(
389 path: &Path,
390 name: &str,
391) -> Result<(), Box<dyn std::error::Error>> {
392 let content = std::fs::read_to_string(path)?;
393 let header = format!("[vendor \"{}\"]", name);
394 let mut out = String::new();
395 let mut skip = false;
396
397 for line in content.lines() {
398 let trimmed = line.trim();
399 if trimmed == header {
400 skip = true;
401 continue;
402 }
403 if skip && trimmed.starts_with('[') {
405 skip = false;
406 }
407 if !skip {
408 out.push_str(line);
409 out.push('\n');
410 }
411 }
412
413 std::fs::write(path, out)?;
414 Ok(())
415}
416
417fn find_gitattributes(workdir: &Path) -> Vec<std::path::PathBuf> {
420 let mut results = Vec::new();
421 fn walk(dir: &Path, results: &mut Vec<std::path::PathBuf>) {
422 let Ok(entries) = std::fs::read_dir(dir) else {
423 return;
424 };
425 for entry in entries.flatten() {
426 let path = entry.path();
427 if path.is_dir() {
428 if path.file_name().map_or(false, |n| n == ".git") {
430 continue;
431 }
432 walk(&path, results);
433 } else if path.file_name().map_or(false, |n| n == ".gitattributes") {
434 results.push(path);
435 }
436 }
437 }
438 walk(workdir, &mut results);
439 results
440}
441
442fn remove_vendor_attrs(workdir: &Path, name: &str) -> Result<(), Box<dyn std::error::Error>> {
445 let needle = format!("vendor={}", name);
446 for attr_path in find_gitattributes(workdir) {
447 let content = std::fs::read_to_string(&attr_path)?;
448 let filtered: Vec<&str> = content
449 .lines()
450 .filter(|line| !line.split_whitespace().any(|token| token == needle))
451 .collect();
452 if filtered.len() < content.lines().count() {
454 let mut out = filtered.join("\n");
455 if !out.is_empty() {
456 out.push('\n');
457 }
458 std::fs::write(&attr_path, out)?;
459 }
460 }
461 Ok(())
462}
463
464pub enum MergeOutcome {
466 UpToDate {
469 vendor: VendorSource,
471 },
472 Clean {
475 vendor: VendorSource,
477 },
478 Conflict {
483 index: git2::Index,
484 vendor: VendorSource,
486 },
487}
488
489pub fn merge_one(
496 repo: &Repository,
497 name: &str,
498 file_favor: Option<git2::FileFavor>,
499) -> Result<MergeOutcome, Box<dyn std::error::Error>> {
500 let vendor = repo
501 .get_vendor_by_name(name)?
502 .ok_or_else(|| format!("vendor '{}' not found", name))?;
503 merge_vendor(repo, &vendor, file_favor)
504}
505
506pub fn merge_all(
511 repo: &Repository,
512 file_favor: Option<git2::FileFavor>,
513) -> Result<Vec<(String, MergeOutcome)>, Box<dyn std::error::Error>> {
514 let vendors = repo.list_vendors()?;
515 let mut results = Vec::with_capacity(vendors.len());
516 for v in &vendors {
517 let outcome = merge_vendor(repo, v, file_favor)?;
518 results.push((v.name.clone(), outcome));
519 }
520 Ok(results)
521}
522
523fn checkout_and_stage(
540 repo: &Repository,
541 mut merged_index: git2::Index,
542 vendor: VendorSource,
543) -> Result<MergeOutcome, Box<dyn std::error::Error>> {
544 let has_conflicts = merged_index.has_conflicts();
545
546 let paths: Vec<String> = merged_index
548 .iter()
549 .filter_map(|entry| std::str::from_utf8(&entry.path).ok().map(String::from))
550 .collect();
551
552 let mut checkout = git2::build::CheckoutBuilder::new();
555 checkout.force();
556 checkout.allow_conflicts(true);
557 checkout.conflict_style_merge(true);
558 for p in &paths {
559 checkout.path(p);
560 }
561 repo.checkout_index(Some(&mut merged_index), Some(&mut checkout))?;
562
563 let mut repo_index = repo.index()?;
565 for entry in merged_index.iter() {
566 let stage = (entry.flags >> 12) & 0x3;
567 if stage != 0 {
568 continue;
569 }
570 let entry_path = std::str::from_utf8(&entry.path)
571 .map_err(|e| git2::Error::from_str(&format!("invalid UTF-8 in path: {}", e)))?;
572 repo_index.add_path(Path::new(entry_path))?;
573 }
574 repo_index.write()?;
575
576 if has_conflicts {
577 Ok(MergeOutcome::Conflict {
578 index: merged_index,
579 vendor,
580 })
581 } else {
582 Ok(MergeOutcome::Clean { vendor })
583 }
584}
585
586fn merge_vendor(
592 repo: &Repository,
593 vendor: &VendorSource,
594 file_favor: Option<git2::FileFavor>,
595) -> Result<MergeOutcome, Box<dyn std::error::Error>> {
596 let vendor_ref = repo.find_reference(&vendor.head_ref())?;
597 let vendor_commit = vendor_ref.peel_to_commit()?;
598
599 if let Some(base) = &vendor.base {
601 if git2::Oid::from_str(base)? == vendor_commit.id() {
602 return Ok(MergeOutcome::UpToDate {
603 vendor: vendor.clone(),
604 });
605 }
606 }
607
608 let updated = VendorSource {
610 name: vendor.name.clone(),
611 url: vendor.url.clone(),
612 branch: vendor.branch.clone(),
613 base: Some(vendor_commit.id().to_string()),
614 patterns: vendor.patterns.clone(),
615 };
616 {
617 let mut cfg = repo.vendor_config()?;
618 updated.to_config(&mut cfg)?;
619 }
620
621 let merged_index = repo.merge_vendor(vendor, None, file_favor)?;
622
623 repo.refresh_vendor_attrs(vendor, &merged_index, Path::new("."))?;
626
627 let outcome = checkout_and_stage(repo, merged_index, updated)?;
628
629 let mut repo_index = repo.index()?;
631 repo_index.add_path(Path::new(".gitvendors"))?;
632 if Path::new(".gitattributes").exists() {
633 repo_index.add_path(Path::new(".gitattributes"))?;
634 }
635 repo_index.write()?;
636
637 Ok(outcome)
638}