Skip to main content

git_vendor/
exe.rs

1use std::collections::HashSet;
2use std::path::Path;
3
4use git2::Repository;
5
6use crate::Vendor;
7use crate::VendorSource;
8
9/// Open a repository from the given path, or from the environment / current
10/// directory when `None` is passed.
11pub 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
18/// List every vendor source configured in the repository.
19///
20/// Returns a `Vec` of `VendorSource` entries.  An empty `Vec` means no
21/// vendors are configured.
22pub fn list(repo: &Repository) -> Result<Vec<VendorSource>, git2::Error> {
23    repo.list_vendors()
24}
25
26/// Register a new vendor source, fetch its upstream, merge the vendor tree
27/// into the working directory, and stage everything in the index.
28///
29/// Behaves like `git submodule add`: the vendor files, `.gitvendors`, and
30/// `.gitattributes` are written to the working tree and staged in the index,
31/// but no commit is created.  The caller (or user) is expected to commit.
32///
33/// Returns the updated `VendorSource` wrapped in a [`MergeOutcome`].
34///
35/// * `name`    – unique identifier stored in `.gitvendors`
36/// * `url`     – remote URL to vendor from
37/// * `branch`  – upstream branch to track (`None` → HEAD)
38/// * `patterns` – glob(s) selecting which upstream files to vendor (e.g. `["**"]`)
39/// * `path`     – local directory for vendored files; defaults to `"."`
40pub 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    // Persist to .gitvendors config (create the file if it doesn't exist yet).
66    {
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    // Fetch upstream.
74    repo.fetch_vendor(&source, None)?;
75
76    // Track the requested pattern(s).
77    let path = path.unwrap_or_else(|| Path::new("."));
78    repo.track_vendor_pattern(&source, patterns, path)?;
79
80    // Update base in .gitvendors to the current upstream tip.
81    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    // Perform the initial one-time merge using the glob pattern(s) directly,
96    // since no vendor files exist in HEAD yet for `merge_vendor` to discover.
97    let merged_index = repo.add_vendor(&source, patterns, path, file_favor)?;
98
99    // Write merged result (including conflict markers) to the working tree
100    // and stage clean entries.
101    let outcome = checkout_and_stage(repo, merged_index, updated)?;
102
103    // Stage metadata files that checkout_and_stage does not cover.
104    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
119/// Add pattern(s) to an existing vendor's configuration in `.gitvendors`.
120///
121/// Only edits `.gitvendors` — does not fetch, merge, or touch `.gitattributes`.
122/// Run `git vendor merge` to apply the new patterns.
123pub 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
144/// Remove pattern(s) from an existing vendor's configuration in `.gitvendors`.
145///
146/// Only edits `.gitvendors` — does not touch `.gitattributes` or the working tree.
147/// Run `git vendor merge` to reconcile.
148pub 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
165/// Fetch the latest upstream commits for a single vendor.
166///
167/// Returns `Some(oid)` if the ref advanced, or `None` if already up-to-date.
168pub 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
190/// Fetch the latest upstream commits for every configured vendor.
191///
192/// Returns a list of `(vendor_name, oid)` pairs.
193pub 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/// Per-vendor update status returned by [`status`].
215#[derive(Debug)]
216pub struct VendorStatus {
217    pub name: String,
218    /// `Some(oid)` when upstream has unmerged changes at that commit;
219    /// `None` when the vendor is up to date.
220    pub upstream_oid: Option<git2::Oid>,
221}
222
223/// Check every configured vendor and report whether it has unmerged upstream
224/// changes.
225pub 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
238/// Remove a vendor source: delete its `.gitvendors` entry, remove its
239/// `refs/vendor/<name>` ref, remove matching lines from `.gitattributes`
240/// files, and mark vendored files as "deleted by them" conflicts in the
241/// index.
242///
243/// The vendored files are left in the working tree.  The user resolves
244/// each conflict by either accepting the deletion (`git rm <file>`) or
245/// keeping the file (`git add <file>`).
246pub 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    // Collect vendored file index entries *before* we remove gitattributes,
256    // because we rely on the `vendor=<name>` attribute to identify them.
257    let vendored_entries = collect_vendored_entries(repo, name)?;
258
259    // 1. Remove the vendor section from .gitvendors.
260    let vendors_path = workdir.join(".gitvendors");
261    if vendors_path.exists() {
262        remove_vendor_from_gitvendors(&vendors_path, name)?;
263    }
264
265    // 2. Delete refs/vendor/<name>.
266    if let Ok(mut reference) = repo.find_reference(&vendor.head_ref()) {
267        reference.delete()?;
268    }
269
270    // 3. Remove gitattributes lines that reference this vendor.
271    remove_vendor_attrs(workdir, name)?;
272
273    // 4. Stage .gitvendors (and any affected .gitattributes).
274    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    // 5. For each vendored file, either remove it outright (if empty) or
284    //    mark it as a "deleted by them" conflict.
285    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        // Remove the clean stage-0 entry first.
290        index.remove(Path::new(path), 0)?;
291
292        if entry.file_size == 0 {
293            // Empty file — just delete from working tree, no conflict needed.
294            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        // Stage 1 — ancestor (base).
317        index.add(&make_entry(1))?;
318        // Stage 2 — ours (identical content).
319        index.add(&make_entry(2))?;
320        // No stage 3 → "deleted by them".
321    }
322
323    index.write()?;
324
325    Ok(())
326}
327
328/// Collect stage-0 index entries for files attributed to the given vendor.
329fn 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
354/// Prune `refs/vendor/*` refs that have no corresponding entry in
355/// `.gitvendors`.
356///
357/// Returns the names of pruned refs.
358pub 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
382// ---------------------------------------------------------------------------
383// Helpers for `rm`
384// ---------------------------------------------------------------------------
385
386/// Remove the `[vendor "<name>"]` section (and its keys) from a
387/// `.gitvendors` file, rewriting it in place.
388fn 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        // A new section header ends the skip region.
404        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
417/// Walk the working tree for `.gitattributes` files, returning their absolute
418/// paths.
419fn 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                // Skip .git directory.
429                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
442/// Remove lines containing `vendor=<name>` from all `.gitattributes` files
443/// under the working tree.
444fn 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        // Only rewrite if something changed.
453        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
464/// Result of a single vendor merge.
465pub enum MergeOutcome {
466    /// The vendor's `base` already matches the latest `refs/vendor/$name`.
467    /// Nothing was changed.
468    UpToDate {
469        /// The vendor source (unchanged).
470        vendor: VendorSource,
471    },
472    /// The merge completed cleanly.  All changes are staged in the index and
473    /// written to the working tree, but no commit has been created.
474    Clean {
475        /// The vendor source with updated `base`.
476        vendor: VendorSource,
477    },
478    /// The merge has conflicts.  Conflict markers have been written to the
479    /// working tree.  The returned `git2::Index` contains the conflict
480    /// entries.  The caller is responsible for presenting them to the user.
481    /// `base` has still been updated in `.gitvendors`.
482    Conflict {
483        index: git2::Index,
484        /// The vendor source with updated `base`.
485        vendor: VendorSource,
486    },
487}
488
489/// Merge upstream changes for a single vendor.
490///
491/// Writes the merged result to the working tree and stages it in the index.
492/// Always updates the vendor's `base` in `.gitvendors`.  No commit is created.
493///
494/// Returns the updated `VendorSource` wrapped in a [`MergeOutcome`].
495pub 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
506/// Merge upstream changes for every configured vendor.
507///
508/// Returns one `(vendor_name, MergeOutcome)` per vendor, in the order they
509/// were processed.  Processing stops at the first error.
510pub 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
523// ---------------------------------------------------------------------------
524// Internal helpers
525// ---------------------------------------------------------------------------
526
527/// Write the contents of a merged index to the working tree using libgit2's
528/// checkout machinery, then stage any cleanly-resolved entries in the
529/// repository's own index.
530///
531/// The checkout is scoped to only the paths present in `merged_index` so that
532/// unrelated working-tree files are left untouched.
533///
534/// Conflict markers are written for conflicted files using the merge style
535/// (`<<<<<<< ours` / `=======` / `>>>>>>> theirs`).
536///
537/// Returns `MergeOutcome::Conflict` when the index has conflicts, or
538/// `MergeOutcome::Clean` otherwise.
539fn 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    // Collect every path in the merged index so we can scope the checkout.
547    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    // Write merged entries (and conflict markers) to the working directory,
553    // touching only the paths that are part of this merge.
554    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    // Stage cleanly-resolved (stage 0) entries in the repository index.
564    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
586/// Merge a single vendor's upstream into the working tree and stage the
587/// result.  Always updates `base` in `.gitvendors` to the current upstream
588/// tip.  No commit is created.
589///
590/// Returns the updated `VendorSource` wrapped in a [`MergeOutcome`].
591fn 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    // Nothing to do when the base already matches the upstream tip.
600    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    // Always update base in .gitvendors to the current upstream tip.
609    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    // Refresh .gitattributes: add entries for new upstream files, remove
624    // entries for files deleted upstream.
625    repo.refresh_vendor_attrs(vendor, &merged_index, Path::new("."))?;
626
627    let outcome = checkout_and_stage(repo, merged_index, updated)?;
628
629    // Stage metadata files that checkout_and_stage does not cover.
630    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}