Skip to main content

git_vendor/
lib.rs

1#![doc = include_str!("../README.md")]
2
3pub mod cli;
4pub mod exe;
5
6use git_filter_tree::FilterTree;
7use git_set_attr::SetAttr;
8use std::{
9    collections::{HashMap, HashSet},
10    path::{Path, PathBuf},
11    str::FromStr,
12};
13
14use git2::Repository;
15
16/// Convert a path to a git-compatible string with forward slashes.
17///
18/// Git patterns (e.g. in `.gitattributes`) always use `/` as the separator,
19/// but `Path::join` and `PathBuf::from` produce `\` on Windows.
20fn to_git_path(p: &Path) -> String {
21    let s = p.to_string_lossy().replace('\\', "/");
22    s.strip_prefix("./").unwrap_or(&s).to_string()
23}
24
25/// Build a [`globset::GlobSet`] from a slice of pattern strings, normalizing
26/// trailing-`/` directory shorthands to `dir/**`.
27fn build_glob_matcher(patterns: &[impl AsRef<str>]) -> Result<globset::GlobSet, git2::Error> {
28    let mut builder = globset::GlobSetBuilder::new();
29    for pat in patterns {
30        let pat = pat.as_ref();
31        let normalized = if pat.ends_with('/') {
32            format!("{}**", pat)
33        } else {
34            pat.to_string()
35        };
36        let g = globset::Glob::new(&normalized)
37            .map_err(|e| git2::Error::from_str(&format!("Invalid pattern '{}': {}", pat, e)))?;
38        builder.add(g);
39    }
40    builder
41        .build()
42        .map_err(|e| git2::Error::from_str(&e.to_string()))
43}
44
45/// All metadata required to retrieve necessary objects from a vendor.
46#[derive(Clone, Hash, PartialEq, Eq)]
47pub struct VendorSource {
48    /// The unique identifier for this particular vendor.
49    pub name: String,
50    pub url: String,
51    /// The branch to track on the upstream remote.
52    /// If not specified, this defaults to `HEAD`.
53    pub branch: Option<String>,
54    /// The most recent merge base. If not specified,
55    /// it is assumed that no prior merge has taken
56    /// place and conflicts must be resolved manually.
57    pub base: Option<String>,
58    /// Glob pattern(s) selecting which upstream files to vendor.
59    pub patterns: Vec<String>,
60}
61
62impl VendorSource {
63    pub fn to_config(&self, cfg: &mut git2::Config) -> Result<(), git2::Error> {
64        cfg.set_str(&format!("vendor.{}.url", &self.name), &self.url)?;
65
66        if let Some(branch) = &self.branch {
67            cfg.set_str(&format!("vendor.{}.branch", &self.name), branch)?;
68        }
69
70        if let Some(base) = &self.base {
71            cfg.set_str(&format!("vendor.{}.base", &self.name), base)?;
72        }
73
74        // Remove existing pattern entries before writing the current set.
75        let pattern_key = format!("vendor.{}.pattern", &self.name);
76        let _ = cfg.remove_multivar(&pattern_key, ".*");
77        for pattern in &self.patterns {
78            cfg.set_multivar(&pattern_key, "^$", pattern)?;
79        }
80
81        Ok(())
82    }
83
84    pub fn from_config(cfg: &git2::Config, name: &str) -> Result<Option<Self>, git2::Error> {
85        let name = name.to_string();
86        let mut entries = cfg.entries(Some(&format!("vendor.{name}")))?;
87
88        if entries.next().is_none() {
89            return Ok(None);
90        }
91
92        let url = cfg.get_string(&format!("vendor.{name}.url"))?;
93        let branch = cfg.get_string(&format!("vendor.{name}.branch")).ok();
94        let base = cfg.get_string(&format!("vendor.{name}.base")).ok();
95
96        let mut patterns = Vec::new();
97        let pattern_entries = cfg.multivar(&format!("vendor.{name}.pattern"), None);
98        if let Ok(pattern_entries) = pattern_entries {
99            pattern_entries.for_each(|entry| {
100                if let Some(value) = entry.value() {
101                    patterns.push(value.to_string());
102                }
103            })?;
104        }
105
106        Ok(Some(Self {
107            name,
108            url,
109            branch,
110            base,
111            patterns,
112        }))
113    }
114
115    /// The ref holding the latest fetched upstream tip.
116    pub fn head_ref(&self) -> String {
117        format!("refs/vendor/{}", self.name)
118    }
119
120    /// The ref to track.
121    pub fn tracking_branch(&self) -> String {
122        match &self.branch {
123            Some(branch) => branch.clone(),
124            None => "HEAD".into(),
125        }
126    }
127}
128
129fn vendors_from_config(cfg: &git2::Config) -> Result<Vec<VendorSource>, git2::Error> {
130    let mut entries = cfg.entries(Some("vendor.*"))?;
131    let mut vendor_names = std::collections::HashSet::new();
132
133    while let Some(entry) = entries.next() {
134        let entry = entry?;
135        if let Some(name) = entry.name() {
136            // Entry names look like "vendor.<name>.<key>"; extract <name>
137            let parts: Vec<&str> = name.splitn(3, '.').collect();
138            if parts.len() == 3 && parts[0] == "vendor" {
139                vendor_names.insert(parts[1].to_string());
140            }
141        }
142    }
143
144    let mut vendors = Vec::new();
145    for name in vendor_names {
146        let vendor = VendorSource::from_config(cfg, &name)?;
147        if let Some(vendor) = vendor {
148            vendors.push(vendor);
149        } else {
150            return Err(git2::Error::from_str("vendor not found"));
151        }
152    }
153
154    Ok(vendors)
155}
156
157/// A trait which provides methods for vendoring content across repository boundaries.
158pub trait Vendor {
159    /// Retrieve vendor configuration by merging three levels (lowest → highest
160    /// priority), analogous to `git config`:
161    ///
162    /// 1. **Global** – `~/.gitvendors`
163    /// 2. **Local**  – `$GIT_DIR/gitvendors`
164    /// 3. **Index**  – `$WORKDIR/.gitvendors` (tracked)
165    ///
166    /// Writes go to the highest-priority file present in the stack (index).
167    fn vendor_config(&self) -> Result<git2::Config, git2::Error>;
168
169    /// Retrieve all vendored files in a given tree.
170    fn vendored_subtree(&self) -> Result<git2::Tree<'_>, git2::Error>;
171
172    /// Return all vendor sources tracked at the commit provided (defaulting to `HEAD`).
173    fn list_vendors(&self) -> Result<Vec<VendorSource>, git2::Error>;
174
175    /// Return all vendor sources mapped to the upstream tip OID if it differs from the base tree.
176    /// `Some(oid)` means there are unmerged upstream changes at that commit; `None` means up to date.
177    fn check_vendors(&self) -> Result<HashMap<VendorSource, Option<git2::Oid>>, git2::Error>;
178
179    /// Track vendor pattern(s) by writing per-file gitattributes lines with the `vendor` attribute.
180    fn track_vendor_pattern(
181        &self,
182        vendor: &VendorSource,
183        globs: &[&str],
184        path: &Path,
185    ) -> Result<(), git2::Error>;
186
187    /// Refresh `.gitattributes` after a merge so that per-file entries match
188    /// the merged result.  New upstream files get entries; deleted files lose
189    /// them.
190    fn refresh_vendor_attrs(
191        &self,
192        vendor: &VendorSource,
193        merged_index: &git2::Index,
194        path: &Path,
195    ) -> Result<(), git2::Error>;
196
197    /// Fetch the upstream for the given vendor and advance `refs/vendor/$name`.
198    /// Returns the updated reference.
199    fn fetch_vendor<'a>(
200        &'a self,
201        source: &VendorSource,
202        maybe_opts: Option<&mut git2::FetchOptions>,
203    ) -> Result<git2::Reference<'a>, git2::Error>;
204
205    /// Perform the initial add of a vendor source.
206    ///
207    /// Unlike `merge_vendor`, which relies on files already present in HEAD to
208    /// determine the upstream ↔ local mapping, `add_vendor` uses the given
209    /// `glob` and `path` to filter the upstream tree directly.  This makes it
210    /// suitable for the first-time add where no vendor files exist in HEAD yet.
211    ///
212    /// The resulting `git2::Index` contains the merged entries ready to be
213    /// written to the working tree and staged.
214    fn add_vendor(
215        &self,
216        vendor: &VendorSource,
217        globs: &[&str],
218        path: &Path,
219        file_favor: Option<git2::FileFavor>,
220    ) -> Result<git2::Index, git2::Error>;
221
222    /// If a `base` exists in the vendor source provided (by `name`),
223    /// initiate a three-way merge with the base reference, the
224    /// commit provided (defaulting to the repository's `HEAD`),
225    /// and the tip of `refs/vendor/{name}`. If no `base` exists,
226    /// then a two-way merge is performed and a new `base` is written
227    /// to the the returned `VendorSource`.
228    fn merge_vendor(
229        &self,
230        vendor: &VendorSource,
231        maybe_opts: Option<&mut git2::FetchOptions>,
232        file_favor: Option<git2::FileFavor>,
233    ) -> Result<git2::Index, git2::Error>;
234
235    /// Given a vendor's name and a target commit (defaulting to `HEAD`),
236    /// return the vendor's `base` reference it it exists. If no such `base`
237    /// exists for the provided vendor source, `None` is returned.
238    fn find_vendor_base(
239        &self,
240        vendor: &VendorSource,
241    ) -> Result<Option<git2::Commit<'_>>, git2::Error>;
242
243    /// Return a `VendorSource` which matches the provided name, if one exists
244    /// in the provided `commit` (defaulting to `HEAD`).
245    fn get_vendor_by_name(&self, name: &str) -> Result<Option<VendorSource>, git2::Error>;
246}
247
248fn bail_if_bare(repo: &Repository) -> Result<(), git2::Error> {
249    // TODO: add support for bare repositories
250    // Support for bare repositories is currently blocked by the lack of
251    // in-memory `gitconfig` readers. How hard can that be to make?
252    if repo.is_bare() {
253        return Err(git2::Error::from_str(
254            "a working tree is required; bare repositories are not supported",
255        ));
256    }
257
258    Ok(())
259}
260
261impl Vendor for Repository {
262    fn vendor_config(&self) -> Result<git2::Config, git2::Error> {
263        bail_if_bare(self)?;
264        let workdir = self
265            .workdir()
266            .ok_or_else(|| git2::Error::from_str("repository has no working directory"))?;
267
268        let mut cfg = git2::Config::new()?;
269
270        // Global ~/.gitvendors (lowest priority).
271        // Derive the home directory from libgit2's own global config path
272        // (~/.gitconfig) so we don't depend on env vars directly.
273        if let Some(global_path) = git2::Config::find_global()
274            .ok()
275            .and_then(|p| p.parent().map(|h| h.join(".gitvendors")))
276            .filter(|p| p.exists())
277        {
278            cfg.add_file(&global_path, git2::ConfigLevel::Global, false)?;
279        }
280
281        // Local $GIT_DIR/gitvendors (repo-private, not tracked).
282        let local_path = self.path().join("gitvendors");
283        if local_path.exists() {
284            cfg.add_file(&local_path, git2::ConfigLevel::Local, false)?;
285        }
286
287        // Index $WORKDIR/.gitvendors (tracked, highest priority).
288        let index_path = workdir.join(".gitvendors");
289        cfg.add_file(&index_path, git2::ConfigLevel::App, false)?;
290
291        Ok(cfg)
292    }
293
294    fn vendored_subtree(&self) -> Result<git2::Tree<'_>, git2::Error> {
295        let head = self.head()?.peel_to_tree()?;
296
297        let mut vendored_entries: Vec<git2::TreeEntry> = Vec::new();
298
299        head.walk(git2::TreeWalkMode::PreOrder, |_, entry| {
300            if let Some(attrs) = entry.name().and_then(|name| {
301                self.get_attr(
302                    &PathBuf::from_str(name).ok()?,
303                    "vendored",
304                    git2::AttrCheckFlags::FILE_THEN_INDEX,
305                )
306                .ok()
307            }) {
308                if attrs == Some("true") || attrs == Some("set") {
309                    vendored_entries.push(entry.to_owned());
310                }
311            }
312            git2::TreeWalkResult::Ok
313        })?;
314
315        todo!()
316    }
317
318    fn list_vendors(&self) -> Result<Vec<VendorSource>, git2::Error> {
319        let cfg = self.vendor_config()?;
320        vendors_from_config(&cfg)
321    }
322
323    fn fetch_vendor<'a>(
324        &'a self,
325        vendor: &VendorSource,
326        maybe_opts: Option<&mut git2::FetchOptions>,
327    ) -> Result<git2::Reference<'a>, git2::Error> {
328        let mut remote = self.remote_anonymous(&vendor.url)?;
329        let refspec = format!("{}:{}", vendor.tracking_branch(), vendor.head_ref());
330        remote.fetch(&[&refspec], maybe_opts, None)?;
331
332        let head = self.find_reference(&vendor.head_ref())?;
333
334        Ok(head)
335    }
336
337    fn check_vendors(&self) -> Result<HashMap<VendorSource, Option<git2::Oid>>, git2::Error> {
338        let vendors = self.list_vendors()?;
339        let mut updates = HashMap::new();
340
341        for vendor in vendors {
342            match vendor.base.as_ref() {
343                Some(base) => {
344                    let base = git2::Oid::from_str(base)?;
345                    let head = self.find_reference(&vendor.head_ref())?.target().ok_or(
346                        git2::Error::from_str("head ref was not found; this is an internal error"),
347                    )?;
348
349                    if base == head {
350                        updates.insert(vendor, None);
351                    } else {
352                        updates.insert(vendor, Some(head));
353                    }
354                }
355                None => {
356                    let head = self.find_reference(&vendor.head_ref())?.target().ok_or(
357                        git2::Error::from_str("head ref was not found; this is an internal error"),
358                    )?;
359                    updates.insert(vendor, Some(head));
360                }
361            }
362        }
363
364        Ok(updates)
365    }
366
367    fn track_vendor_pattern(
368        &self,
369        vendor: &VendorSource,
370        globs: &[&str],
371        path: &Path,
372    ) -> Result<(), git2::Error> {
373        let workdir = self
374            .workdir()
375            .ok_or_else(|| git2::Error::from_str("repository has no working directory"))?;
376        let gitattributes = workdir.join(path).join(".gitattributes");
377        let tree = self.find_reference(&vendor.head_ref())?.peel_to_tree()?;
378
379        for glob in globs {
380            let glob_patterns: Vec<String> = vec![glob.to_string()];
381            let matcher = build_glob_matcher(&glob_patterns)?;
382
383            let mut matched_files: Vec<String> = Vec::new();
384
385            tree.walk(git2::TreeWalkMode::PreOrder, |dir, entry| {
386                if entry.kind() != Some(git2::ObjectType::Blob) {
387                    return git2::TreeWalkResult::Ok;
388                }
389                let remote_path = format!("{}{}", dir, entry.name().unwrap());
390                if matcher.is_match(&remote_path) {
391                    matched_files.push(remote_path);
392                }
393                git2::TreeWalkResult::Ok
394            })?;
395
396            if matched_files.is_empty() {
397                continue;
398            }
399
400            let vendor_attr = format!("vendor={}", vendor.name);
401
402            for file in &matched_files {
403                let local_pattern = to_git_path(&path.join(file));
404                self.set_attr(&local_pattern, &[&vendor_attr], &gitattributes)?;
405            }
406        }
407
408        Ok(())
409    }
410
411    fn add_vendor(
412        &self,
413        vendor: &VendorSource,
414        globs: &[&str],
415        _path: &Path,
416        file_favor: Option<git2::FileFavor>,
417    ) -> Result<git2::Index, git2::Error> {
418        let matcher = build_glob_matcher(globs)?;
419
420        // Build the set of upstream paths that match the glob pattern.
421        let theirs = self.find_reference(&vendor.head_ref())?.peel_to_tree()?;
422        let theirs_filtered =
423            self.filter_by_predicate(&theirs, |_repo, entry_path| matcher.is_match(entry_path))?;
424
425        // Collect upstream paths so we can filter HEAD to only overlapping
426        // entries.  This lets merge_trees detect add/add conflicts when a
427        // local file already exists at the same path as an incoming vendor
428        // file.
429        let mut upstream_paths: HashSet<String> = HashSet::new();
430        theirs_filtered.walk(git2::TreeWalkMode::PreOrder, |dir, entry| {
431            if entry.kind() == Some(git2::ObjectType::Blob) {
432                upstream_paths.insert(format!("{}{}", dir, entry.name().unwrap()));
433            }
434            git2::TreeWalkResult::Ok
435        })?;
436
437        let ours = self.head()?.peel_to_tree()?;
438        let ours_filtered =
439            self.filter_by_predicate(&ours, |_repo, p| upstream_paths.contains(&*to_git_path(p)))?;
440
441        // Two-way merge: empty ancestor so that both sides look like pure
442        // additions.  If the same path exists in both ours and theirs with
443        // different content, git2 will report an add/add conflict.
444        let empty_tree = {
445            let empty_oid = self.treebuilder(None)?.write()?;
446            self.find_tree(empty_oid)?
447        };
448
449        let mut opts = git2::MergeOptions::new();
450        opts.find_renames(true);
451        opts.rename_threshold(50);
452        if let Some(favor) = file_favor {
453            opts.file_favor(favor);
454        }
455
456        self.merge_trees(&empty_tree, &ours_filtered, &theirs_filtered, Some(&opts))
457    }
458
459    fn merge_vendor(
460        &self,
461        vendor: &VendorSource,
462        _maybe_opts: Option<&mut git2::FetchOptions>,
463        file_favor: Option<git2::FileFavor>,
464    ) -> Result<git2::Index, git2::Error> {
465        // UPSTREAM (theirs): use stored patterns to filter the upstream tree.
466        // This catches files added upstream since the last merge.
467        let matcher = build_glob_matcher(&vendor.patterns)?;
468        let theirs = self.find_reference(&vendor.head_ref())?.peel_to_tree()?;
469        let theirs_filtered =
470            self.filter_by_predicate(&theirs, |_repo, path| matcher.is_match(path))?;
471
472        // LOCAL (ours): use gitattributes to find files currently tracked for
473        // this vendor.  Falls back to vendor patterns when the gitattribute
474        // is unset (e.g. legacy .gitattributes with `./` prefixed patterns).
475        let expected_vendor = vendor.name.clone();
476        let ours = self.head()?.peel_to_tree()?;
477        let ours_filtered = self.filter_by_predicate(&ours, |repo, path| {
478            match repo.get_attr(path, "vendor", git2::AttrCheckFlags::FILE_THEN_INDEX) {
479                Ok(Some(value)) if value == expected_vendor => true,
480                _ => matcher.is_match(path),
481            }
482        })?;
483
484        let mut opts = git2::MergeOptions::new();
485        opts.find_renames(true);
486        opts.rename_threshold(50);
487        if let Some(favor) = file_favor {
488            opts.file_favor(favor);
489        }
490
491        let base_commit = self.find_vendor_base(&vendor)?;
492        let base_full_tree;
493        let base = match &base_commit {
494            Some(c) => {
495                base_full_tree = c.as_object().peel_to_tree()?;
496                self.filter_by_predicate(&base_full_tree, |_repo, path| matcher.is_match(path))?
497            }
498            None => self.find_tree(ours_filtered.id())?,
499        };
500
501        self.merge_trees(&base, &ours_filtered, &theirs_filtered, Some(&opts))
502    }
503
504    fn refresh_vendor_attrs(
505        &self,
506        vendor: &VendorSource,
507        merged_index: &git2::Index,
508        path: &Path,
509    ) -> Result<(), git2::Error> {
510        let workdir = self
511            .workdir()
512            .ok_or_else(|| git2::Error::from_str("repository has no working directory"))?;
513        let gitattributes = workdir.join(path).join(".gitattributes");
514        let vendor_attr = format!("vendor={}", vendor.name);
515        let matcher = build_glob_matcher(&vendor.patterns)?;
516
517        // Collect all paths in the merged index that match the vendor's patterns.
518        let mut merged_paths: HashSet<PathBuf> = HashSet::new();
519        for entry in merged_index.iter() {
520            let stage = (entry.flags >> 12) & 0x3;
521            if stage != 0 {
522                continue;
523            }
524            if let Ok(entry_path) = std::str::from_utf8(&entry.path) {
525                let p = PathBuf::from(entry_path);
526                if matcher.is_match(&p) {
527                    merged_paths.insert(p);
528                }
529            }
530        }
531
532        // Read existing gitattributes, remove stale entries for this vendor,
533        // keep everything else.
534        let needle = format!("vendor={}", vendor.name);
535        let mut lines: Vec<String> = if gitattributes.exists() {
536            let content = std::fs::read_to_string(&gitattributes)
537                .map_err(|e| git2::Error::from_str(&format!("read .gitattributes: {e}")))?;
538            content
539                .lines()
540                .filter(|line| {
541                    // Keep lines that don't belong to this vendor.
542                    !line.split_whitespace().any(|tok| tok == needle)
543                })
544                .map(String::from)
545                .collect()
546        } else {
547            Vec::new()
548        };
549
550        // Add per-file entries for all merged paths.
551        let mut sorted: Vec<_> = merged_paths.into_iter().collect();
552        sorted.sort();
553        for file in sorted {
554            let local_pattern = path.join(&file);
555            let line = format!("{} {}", to_git_path(&local_pattern), vendor_attr);
556            lines.push(line);
557        }
558
559        // Write back.
560        if let Some(parent) = gitattributes.parent() {
561            std::fs::create_dir_all(parent).map_err(|e| {
562                git2::Error::from_str(&format!("create dir for .gitattributes: {e}"))
563            })?;
564        }
565        let mut content = lines.join("\n");
566        if !content.is_empty() && !content.ends_with('\n') {
567            content.push('\n');
568        }
569        std::fs::write(&gitattributes, &content)
570            .map_err(|e| git2::Error::from_str(&format!("write .gitattributes: {e}")))?;
571        Ok(())
572    }
573
574    fn find_vendor_base(
575        &self,
576        vendor: &VendorSource,
577    ) -> Result<Option<git2::Commit<'_>>, git2::Error> {
578        match vendor.base.as_ref() {
579            Some(base) => {
580                let oid = git2::Oid::from_str(base)?;
581                let commit = self.find_commit(oid)?;
582                return Ok(Some(commit));
583            }
584            _ => Ok(None),
585        }
586    }
587
588    fn get_vendor_by_name(&self, name: &str) -> Result<Option<VendorSource>, git2::Error> {
589        let gitvendors = self.vendor_config()?;
590        VendorSource::from_config(&gitvendors, name)
591    }
592}
593
594#[cfg(test)]
595mod tests;