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` – raw pattern strings, optionally with colon mapping syntax
39///   (e.g. `["src/**:ext/"]`).  See [`PatternMapping`].
40/// * `path`    – default destination prefix applied to patterns that have no
41///   explicit colon mapping.  Written into `.gitvendors` as the
42///   colon syntax so future merges use the same placement.
43pub 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    // Compute the CWD relative to the workdir so that running the command
61    // from a subdirectory (e.g. `cd ext/ && git vendor add ...`) behaves the
62    // same as passing `--path ext/` from the repo root.
63    //
64    // `Repository::open_from_env` / `open` canonicalises the workdir, so we
65    // do the same for CWD before stripping the prefix.
66    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    // Resolve the effective destination path:
75    //
76    // - If `--path` was given, join it onto the CWD offset so that both
77    //   `--path ext/` from the root and `cd ext/ && --path .` work correctly.
78    // - If `--path` was omitted, fall back to the CWD offset so that
79    //   `cd ext/ && git vendor add ...` places files under `ext/`.
80    //
81    // A resulting path of `""` / `"."` means "no remapping" and is treated
82    // as `None` by `apply_default_path`.
83    let resolved_path: Option<std::path::PathBuf> = match (path, &cwd_rel) {
84        // Explicit --path: join with the CWD offset so relative paths like
85        // "." are anchored to where the user is standing.
86        (Some(p), Some(rel)) => {
87            let joined = rel.join(p);
88            // Normalize away any "." components without touching the FS.
89            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        // No --path: use CWD offset (may be None when at the repo root).
99        (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    // If `--path` was supplied, bake it as the default destination into any
109    // patterns that don't already carry an explicit colon mapping.
110    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    // Persist to .gitvendors config (create the file if it doesn't exist yet).
121    {
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    // Fetch upstream.
129    repo.fetch_vendor(&source, None)?;
130
131    // Track the stored patterns in .gitattributes.  The raw_patterns already
132    // carry any colon destination from --path (baked in above), so we pass
133    // Path::new(".") to signal "no additional prefix".
134    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    // Update base in .gitvendors to the current upstream tip.
138    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    // Perform the initial one-time merge using the stored patterns directly,
153    // since no vendor files exist in HEAD yet for `merge_vendor` to discover.
154    // raw_patterns already carry any colon destination, so pass Path::new(".").
155    let merged_index = repo.add_vendor(&source, &raw_pattern_refs, Path::new("."), file_favor)?;
156
157    // Write merged result (including conflict markers) to the working tree
158    // and stage clean entries.
159    let outcome = checkout_and_stage(repo, merged_index, updated)?;
160
161    // Stage metadata files that checkout_and_stage does not cover.
162    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
172/// Apply `path` as the default destination prefix to any raw pattern strings
173/// that do not already carry an explicit colon mapping.
174///
175/// Patterns that already contain a `:` are left unchanged.
176pub 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    // Normalize to a forward-slash string ending with '/'.
186    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            // Path "." means "no remapping" – same as omitting --path.
191            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                // Already has an explicit destination – leave it alone.
201                raw.to_string()
202            } else {
203                format!("{}:{}", raw, dest)
204            }
205        })
206        .collect()
207}
208
209/// Add pattern(s) to an existing vendor's configuration in `.gitvendors`.
210///
211/// Only edits `.gitvendors` — does not fetch, merge, or touch `.gitattributes`.
212/// Run `git vendor merge` to apply the new patterns.
213pub 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
234/// Remove pattern(s) from an existing vendor's configuration in `.gitvendors`.
235///
236/// Only edits `.gitvendors` — does not touch `.gitattributes` or the working tree.
237/// Run `git vendor merge` to reconcile.
238pub 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
255/// Fetch the latest upstream commits for a single vendor.
256///
257/// Returns `Some(oid)` if the ref advanced, or `None` if already up-to-date.
258pub 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
280/// Fetch the latest upstream commits for every configured vendor.
281///
282/// Returns a list of `(vendor_name, oid)` pairs.
283pub 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/// Per-vendor update status returned by [`status`].
305#[derive(Debug)]
306pub struct VendorStatus {
307    pub name: String,
308    /// `Some(oid)` when upstream has unmerged changes at that commit;
309    /// `None` when the vendor is up to date.
310    pub upstream_oid: Option<git2::Oid>,
311}
312
313/// Check every configured vendor and report whether it has unmerged upstream
314/// changes.
315pub 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
328/// Remove a vendor source: delete its `.gitvendors` entry, remove its
329/// `refs/vendor/<name>` ref, remove matching lines from `.gitattributes`
330/// files, and mark vendored files as "deleted by them" conflicts in the
331/// index.
332///
333/// The vendored files are left in the working tree.  The user resolves
334/// each conflict by either accepting the deletion (`git rm <file>`) or
335/// keeping the file (`git add <file>`).
336pub 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    // Collect vendored file index entries *before* we remove gitattributes,
346    // because we rely on the `vendor=<name>` attribute to identify them.
347    let vendored_entries = collect_vendored_entries(repo, name)?;
348
349    // 1. Remove the vendor section from .gitvendors.
350    let vendors_path = workdir.join(".gitvendors");
351    if vendors_path.exists() {
352        remove_vendor_from_gitvendors(&vendors_path, name)?;
353    }
354
355    // 2. Delete refs/vendor/<name>.
356    if let Ok(mut reference) = repo.find_reference(&vendor.head_ref()) {
357        reference.delete()?;
358    }
359
360    // 3. Remove gitattributes lines that reference this vendor.
361    remove_vendor_attrs(workdir, name)?;
362
363    // 4. Stage .gitvendors (and any affected .gitattributes).
364    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    // 5. For each vendored file, either remove it outright (if empty) or
374    //    mark it as a "deleted by them" conflict.
375    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        // Remove the clean stage-0 entry first.
380        index.remove(Path::new(path), 0)?;
381
382        if entry.file_size == 0 {
383            // Empty file — just delete from working tree, no conflict needed.
384            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        // Stage 1 — ancestor (base).
407        index.add(&make_entry(1))?;
408        // Stage 2 — ours (identical content).
409        index.add(&make_entry(2))?;
410        // No stage 3 → "deleted by them".
411    }
412
413    index.write()?;
414
415    Ok(())
416}
417
418/// Collect stage-0 index entries for files attributed to the given vendor.
419fn 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
444/// Prune `refs/vendor/*` refs that have no corresponding entry in
445/// `.gitvendors`.
446///
447/// Returns the names of pruned refs.
448pub 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
472// ---------------------------------------------------------------------------
473// Helpers for `rm`
474// ---------------------------------------------------------------------------
475
476/// Remove the `[vendor "<name>"]` section (and its keys) from a
477/// `.gitvendors` file, rewriting it in place.
478fn 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        // A new section header ends the skip region.
494        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
507/// Walk the working tree for `.gitattributes` files, returning their absolute
508/// paths.
509fn 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                // Skip .git directory.
519                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
532/// Remove lines containing `vendor=<name>` from all `.gitattributes` files
533/// under the working tree.
534fn 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        // Only rewrite if something changed.
543        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
554/// Result of a single vendor merge.
555pub enum MergeOutcome {
556    /// The vendor's `base` already matches the latest `refs/vendor/$name`.
557    /// Nothing was changed.
558    UpToDate {
559        /// The vendor source (unchanged).
560        vendor: VendorSource,
561    },
562    /// The merge completed cleanly.  All changes are staged in the index and
563    /// written to the working tree, but no commit has been created.
564    Clean {
565        /// The vendor source with updated `base`.
566        vendor: VendorSource,
567    },
568    /// The merge has conflicts.  Conflict markers have been written to the
569    /// working tree.  The returned `git2::Index` contains the conflict
570    /// entries.  The caller is responsible for presenting them to the user.
571    /// `base` has still been updated in `.gitvendors`.
572    Conflict {
573        index: git2::Index,
574        /// The vendor source with updated `base`.
575        vendor: VendorSource,
576    },
577}
578
579/// Merge upstream changes for a single vendor.
580///
581/// Writes the merged result to the working tree and stages it in the index.
582/// Always updates the vendor's `base` in `.gitvendors`.  No commit is created.
583///
584/// Returns the updated `VendorSource` wrapped in a [`MergeOutcome`].
585pub 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
596/// Merge upstream changes for every configured vendor.
597///
598/// Returns one `(vendor_name, MergeOutcome)` per vendor, in the order they
599/// were processed.  Processing stops at the first error.
600pub 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
613// ---------------------------------------------------------------------------
614// Internal helpers
615// ---------------------------------------------------------------------------
616
617/// Write the contents of a merged index to the working tree using libgit2's
618/// checkout machinery, then stage any cleanly-resolved entries in the
619/// repository's own index.
620///
621/// The checkout is scoped to only the paths present in `merged_index` so that
622/// unrelated working-tree files are left untouched.
623///
624/// Conflict markers are written for conflicted files using the merge style
625/// (`<<<<<<< ours` / `=======` / `>>>>>>> theirs`).
626///
627/// Returns `MergeOutcome::Conflict` when the index has conflicts, or
628/// `MergeOutcome::Clean` otherwise.
629fn 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    // Collect every path in the merged index so we can scope the checkout.
637    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    // Write merged entries (and conflict markers) to the working directory,
643    // touching only the paths that are part of this merge.
644    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    // Stage cleanly-resolved (stage 0) entries in the repository index.
654    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
676/// Merge a single vendor's upstream into the working tree and stage the
677/// result.  Always updates `base` in `.gitvendors` to the current upstream
678/// tip.  No commit is created.
679///
680/// Returns the updated `VendorSource` wrapped in a [`MergeOutcome`].
681fn 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    // Nothing to do when the base already matches the upstream tip.
690    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    // Always update base in .gitvendors to the current upstream tip.
699    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    // Refresh .gitattributes: add entries for new upstream files, remove
714    // entries for files deleted upstream.
715    repo.refresh_vendor_attrs(vendor, &merged_index, Path::new("."))?;
716
717    let outcome = checkout_and_stage(repo, merged_index, updated)?;
718
719    // Stage metadata files that checkout_and_stage does not cover.
720    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}