From f2b6e46ea3ecd21e3d0d10dc98d8460aecf21068 Mon Sep 17 00:00:00 2001 From: "Joseph D. Carpinelli" Date: Tue, 9 Jun 2026 08:37:52 -0400 Subject: [PATCH 01/31] feat: implement CLI, extend VendorWorktree, and refactor gitattributes helpers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements the five top-level subcommands (add, update, status, remove, list) driven by a clap derive CLI in the new cli.rs. Two trait changes support the implementation: checkout_vendor now returns the full overlay tree OID (so callers can mint a commit from the same tree without re-running the overlay), and prepare_merge is promoted from an internal exe helper to a first-class VendorWorktree method. The gitattributes quoting helpers (quote_attr_pattern, unquote_attr_pattern, find_pattern_end) are replaced with two focused helpers: check_attr_pattern returns Error::InvalidPath for paths that would require C-style quoting (space, #, control chars — never seen on real git source paths), and split_attr_line delegates to gix_quote::ansi_c::undo for the decode side. gix-quote is already a transitive dependency; this makes it direct. The attributes feature is enabled on gix for future use. Unit tests move to src/exe_tests.rs and use rstest for parameterized cases. feat: add CLI subcommands add, update, status, remove, list feat: checkout_vendor returns full overlay tree OID feat: add prepare_merge to VendorWorktree trait refactor: replace quoting helpers with check_attr_pattern + split_attr_line feat: add Error::InvalidPath chore: add gix-quote direct dep; enable gix attributes feature Assisted-by: Claude:claude-sonnet-4-6 --- Cargo.lock | 1 + Cargo.toml | 3 +- crates/git-vendor/Cargo.toml | 1 + crates/git-vendor/src/cli.rs | 87 +++++ crates/git-vendor/src/error.rs | 2 + crates/git-vendor/src/exe.rs | 128 ++++++- crates/git-vendor/src/exe_tests.rs | 40 +++ crates/git-vendor/src/main.rs | 514 ++++++++++++++++++++++++++++- crates/git-vendor/src/vendor.rs | 27 +- 9 files changed, 784 insertions(+), 19 deletions(-) create mode 100644 crates/git-vendor/src/cli.rs create mode 100644 crates/git-vendor/src/exe_tests.rs diff --git a/Cargo.lock b/Cargo.lock index 8a78d1c..d8aa929 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -513,6 +513,7 @@ dependencies = [ "git-set-attr", "gix", "gix-glob", + "gix-quote", "proptest", "rstest", "tempfile", diff --git a/Cargo.toml b/Cargo.toml index 8c2bfd2..167002b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,8 +14,9 @@ missing_docs = "warn" [workspace.dependencies] clap = { version = "4.5.60", features = ["derive"] } clap_mangen = "0.2.31" -gix = { version = "0.83", features = ["merge", "tree-editor", "blocking-network-client", "worktree-mutation"] } +gix = { version = "0.83", features = ["attributes", "merge", "tree-editor", "blocking-network-client", "worktree-mutation"] } gix-glob = "0.26.0" +gix-quote = "0.7" tempfile = "3" thiserror = "2" diff --git a/crates/git-vendor/Cargo.toml b/crates/git-vendor/Cargo.toml index 959df98..bbd7c02 100644 --- a/crates/git-vendor/Cargo.toml +++ b/crates/git-vendor/Cargo.toml @@ -28,6 +28,7 @@ git-set-attr = { version = "0.1.2", path = "../git-set-attr" } tempfile.workspace = true thiserror.workspace = true gix-glob.workspace = true +gix-quote.workspace = true [dev-dependencies] proptest = "1.5.0" diff --git a/crates/git-vendor/src/cli.rs b/crates/git-vendor/src/cli.rs new file mode 100644 index 0000000..65dc91c --- /dev/null +++ b/crates/git-vendor/src/cli.rs @@ -0,0 +1,87 @@ +//! Command-line interface shape for `git-vendor`. + +use clap::{Parser, Subcommand}; + +/// Manage vendored external repository content. +#[derive(Parser)] +#[command(name = "git-vendor", bin_name = "git vendor")] +pub struct Cli { + #[command(subcommand)] + pub command: Command, +} + +#[derive(Subcommand)] +pub enum Command { + /// Add a new vendor dependency and integrate it into the current branch. + /// + /// Fetches the upstream ref, three-way merges it into the working tree, and + /// mints a merge commit. Equivalent to `git subtree add` or a tracked + /// `git submodule add` that copies files instead of linking a repo. + Add { + /// Unique name for this vendor (used in `.gitvendors` and `.gitattributes`). + name: String, + + /// Remote URL of the upstream repository. + url: String, + + /// Branch, tag, or SHA to track on the upstream (defaults to `HEAD`). + #[arg(long, value_name = "REF")] + ref_name: Option, + + /// File pattern used to filter the upstream vendor content with optional + /// remapping into the working copy, e.g. `src/**:vendor/lib/`. May be + /// repeated to select multiple paths. + #[arg(long = "pattern", short = 'p', value_name = "GLOB[:DEST]")] + patterns: Vec, + + /// Record upstream history as a parentless squash commit instead of a + /// full merge. + #[arg(long)] + squash: bool, + + /// Commit message (defaults to `vendor: add `). + #[arg(long, short = 'm', value_name = "MSG")] + message: Option, + }, + + /// Fetch and integrate upstream updates for one or all vendors. + /// + /// Equivalent to `git subtree pull` or `git submodule update --remote`. + Update { + /// Vendor name to update; updates all configured vendors if omitted. + name: Option, + + /// Commit message (defaults to `vendor: update `). + #[arg(long, short = 'm', value_name = "MSG")] + message: Option, + + /// Allow integrating an upstream that was force-pushed (rewound + /// history). Without this flag, a force-push is reported as an error. + #[arg(long)] + force: bool, + }, + + /// Show synchronization status for one or all vendors. + Status { + /// Vendor name to check; checks all configured vendors if omitted. + name: Option, + + /// Fetch from upstream before reporting status. + #[arg(long, short = 'f')] + fetch: bool, + }, + + /// Remove a vendor dependency and its files from the working tree. + Remove { + /// Vendor name to remove. + name: String, + + /// Remove the config entry and `.gitattributes` tracking but leave the + /// vendored files in the working tree. + #[arg(long)] + keep_files: bool, + }, + + /// List all configured vendor dependencies. + List, +} diff --git a/crates/git-vendor/src/error.rs b/crates/git-vendor/src/error.rs index 1fba694..6c8a3a6 100644 --- a/crates/git-vendor/src/error.rs +++ b/crates/git-vendor/src/error.rs @@ -15,6 +15,8 @@ pub enum Error { Fetch(String), #[error("merge conflict: {0}")] Conflict(String), + #[error("path requires quoting in .gitattributes: {0}")] + InvalidPath(String), #[error(transparent)] Gix(Box), } diff --git a/crates/git-vendor/src/exe.rs b/crates/git-vendor/src/exe.rs index beb9648..2c1003a 100644 --- a/crates/git-vendor/src/exe.rs +++ b/crates/git-vendor/src/exe.rs @@ -8,11 +8,15 @@ use gix::bstr::{BStr, ByteSlice as _}; -use crate::{Error, VendorEntry, VendorMerge, VendorRepository, VendorWorktree}; +use crate::{Error, VendorEntry, VendorMerge, VendorMode, VendorRepository, VendorWorktree}; impl VendorWorktree for gix::Repository { - fn checkout_vendor(&self, entry: &VendorEntry, tree: gix::ObjectId) -> Result<(), Error> { - // SAFETY + fn checkout_vendor( + &self, + entry: &VendorEntry, + tree: gix::ObjectId, + ) -> Result { + // IMPORTANT // This is the trust boundary where upstream content (carried verbatim // through `upstream_tree`, including symlink and gitlink modes, // mirroring git-subtree/submodule) reaches the working copy. Like @@ -94,7 +98,7 @@ impl VendorWorktree for gix::Repository { .write(gix::index::write::Options::default()) .map_err(|e| Error::Gix(Box::new(e)))?; - Ok(()) + Ok(full_tree) } fn checkout_vendor_conflicted( @@ -159,13 +163,16 @@ impl VendorWorktree for gix::Repository { let attr_value = format!("vendor={}", entry.name.as_str()); let attr_bytes = attr_value.as_bytes(); - let already_tracked: std::collections::HashSet<&[u8]> = existing + for path in paths { + check_attr_pattern(path.as_bytes())?; + } + + let already_tracked: std::collections::HashSet> = existing .lines() .filter_map(|line| { - let i = line.iter().position(|&b| b == b' ')?; - let attr = line[i + 1..].trim(); + let (pattern, attr) = split_attr_line(line)?; if attr == attr_bytes { - Some(&line[..i]) + Some(pattern.into_owned()) } else { None } @@ -189,6 +196,7 @@ impl VendorWorktree for gix::Repository { std::fs::write(&gitattributes, &out)?; } + stage_gitattributes(self, &out)?; Ok(()) } @@ -208,11 +216,9 @@ impl VendorWorktree for gix::Repository { let mut filtered: Vec = Vec::with_capacity(existing.len()); for line in existing.lines() { - let keep = if let Some(i) = line.iter().position(|&b| b == b' ') { - let attr = line[i + 1..].trim(); - !(attr == attr_bytes && remove.contains(&line[..i])) - } else { - true + let keep = match split_attr_line(line) { + Some((pattern, attr)) => !(attr == attr_bytes && remove.contains(pattern.as_ref())), + None => true, }; if keep { filtered.extend_from_slice(line); @@ -224,6 +230,102 @@ impl VendorWorktree for gix::Repository { std::fs::write(&gitattributes, &filtered)?; } + stage_gitattributes(self, &filtered)?; Ok(()) } + + fn prepare_merge( + &self, + entry: &VendorEntry, + merge: &VendorMerge, + message: &str, + ) -> Result<(), Error> { + let git_dir = self.git_dir(); + if entry.mode == VendorMode::Squash { + std::fs::write(git_dir.join("SQUASH_MSG"), message.as_bytes())?; + } else { + std::fs::write( + git_dir.join("MERGE_HEAD"), + format!("{}\n", merge.upstream_commit), + )?; + std::fs::write(git_dir.join("MERGE_MSG"), message.as_bytes())?; + } + Ok(()) + } +} + +// ── helpers ────────────────────────────────────────────────────────────────── + +/// Return `Err` if `path` contains characters that require C-style quoting in +/// `.gitattributes` (space, tab, `#`, `"`, `\`, or control characters). +/// Git source paths from tree objects never contain these in practice. +fn check_attr_pattern(path: &[u8]) -> Result<(), Error> { + if path + .iter() + .any(|&b| matches!(b, b' ' | b'\t' | b'#' | b'"' | b'\\' | 0..=31 | 127)) + { + return Err(Error::InvalidPath( + String::from_utf8_lossy(path).into_owned(), + )); + } + Ok(()) +} + +/// Parse one `.gitattributes` line into `(unquoted_pattern, trimmed_attrs)`. +/// +/// Returns `None` for blank lines, comment lines, or lines with no attribute +/// separator. Handles both plain and C-style-quoted patterns using +/// [`gix_quote::ansi_c::undo`]. +fn split_attr_line(line: &[u8]) -> Option<(std::borrow::Cow<'_, [u8]>, &[u8])> { + if line.starts_with(b"\"") { + let (pattern, consumed) = gix_quote::ansi_c::undo(line.as_bstr()).ok()?; + let rest = line.get(consumed..)?; + if rest.first().is_some_and(|&b| b == b' ' || b == b'\t') { + let owned: Vec = pattern.as_ref().to_vec(); + Some((std::borrow::Cow::Owned(owned), rest[1..].trim())) + } else { + None + } + } else { + let pos = line.iter().position(|&b| b == b' ' || b == b'\t')?; + Some(( + std::borrow::Cow::Borrowed(&line[..pos]), + line[pos + 1..].trim(), + )) + } +} + +#[cfg(test)] +#[path = "exe_tests.rs"] +mod tests; + +/// Write `content` as a blob into the object database and upsert the +/// `.gitattributes` index entry to point at it. +/// +/// This exists because [`VendorWorktree::track_vendor`] and +/// [`VendorWorktree::untrack_vendor`] write `.gitattributes` as a working-copy +/// side effect rather than folding it into the vendor tree before +/// `index_from_tree` runs. Ideally those methods would return a blob OID so +/// the caller could include `.gitattributes` in `full_tree` like any other +/// file, making this function unnecessary. +fn stage_gitattributes(repo: &gix::Repository, content: &[u8]) -> Result<(), Error> { + let blob_oid = repo + .write_object(gix::objs::BlobRef { data: content })? + .detach(); + + let mut index = repo.open_index().map_err(|e| Error::Gix(Box::new(e)))?; + index.remove_entries(|_, path, _| path == b".gitattributes".as_bstr()); + index.dangerously_push_entry( + gix::index::entry::Stat::default(), + blob_oid, + gix::index::entry::Flags::empty(), + gix::index::entry::Mode::FILE, + b".gitattributes".as_bstr(), + ); + index.sort_entries(); + index + .write(gix::index::write::Options::default()) + .map_err(|e| Error::Gix(Box::new(e)))?; + + Ok(()) } diff --git a/crates/git-vendor/src/exe_tests.rs b/crates/git-vendor/src/exe_tests.rs new file mode 100644 index 0000000..1ec7057 --- /dev/null +++ b/crates/git-vendor/src/exe_tests.rs @@ -0,0 +1,40 @@ +use rstest::rstest; + +use super::{check_attr_pattern, split_attr_line}; + +#[rstest] +#[case(b"vendor/a.txt")] +#[case(b"src/lib.rs")] +#[case(b"deep/nested/path.txt")] +fn check_plain_path_ok(#[case] path: &[u8]) { + assert!(check_attr_pattern(path).is_ok()); +} + +#[rstest] +#[case(b"vendor/a b.txt")] +#[case(b"#readme")] +#[case(b"say \"hi\"")] +#[case(b"a\tb")] +#[case(b"a\x01b")] +#[case(b"a\x7fb")] +fn check_path_with_special_chars_errors(#[case] path: &[u8]) { + assert!(check_attr_pattern(path).is_err()); +} + +#[rstest] +#[case(b"vendor/a.txt vendor=mylib", b"vendor/a.txt", b"vendor=mylib")] +#[case(b"vendor/a.txt\tvendor=mylib", b"vendor/a.txt", b"vendor=mylib")] +#[case(b"\"vendor/a b.txt\" vendor=mylib", b"vendor/a b.txt", b"vendor=mylib")] +#[case(b"\"say \\\"hi\\\"\" vendor=mylib", b"say \"hi\"", b"vendor=mylib")] +fn split_line(#[case] line: &[u8], #[case] pattern: &[u8], #[case] attr: &[u8]) { + let (p, a) = split_attr_line(line).unwrap(); + assert_eq!(p.as_ref(), pattern); + assert_eq!(a, attr); +} + +#[rstest] +#[case(b"vendor/a.txt")] +#[case(b"")] +fn split_no_attr_returns_none(#[case] line: &[u8]) { + assert!(split_attr_line(line).is_none()); +} diff --git a/crates/git-vendor/src/main.rs b/crates/git-vendor/src/main.rs index 209b9b6..0164254 100644 --- a/crates/git-vendor/src/main.rs +++ b/crates/git-vendor/src/main.rs @@ -1,2 +1,512 @@ -// TODO -fn main() {} +#![allow(missing_docs)] + +mod cli; + +use std::path::{Path, PathBuf}; + +use clap::Parser as _; +use git_vendor::{ + PatternMapping, VendorConfig, VendorEntry, VendorMode, VendorName, VendorRepository, + VendorStatus, VendorWorktree, +}; + +type Result> = std::result::Result; + +fn main() { + let cli = cli::Cli::parse(); + if let Err(e) = run(cli) { + eprintln!("error: {e}"); + std::process::exit(1); + } +} + +fn run(cli: cli::Cli) -> Result<()> { + match cli.command { + cli::Command::Add { + name, + url, + ref_name, + patterns, + squash, + message, + } => cmd_add(name, url, ref_name, patterns, squash, message), + cli::Command::Update { + name, + message, + force, + } => cmd_update(name, message, force), + cli::Command::Status { name, fetch } => cmd_status(name, fetch), + cli::Command::Remove { name, keep_files } => cmd_remove(name, keep_files), + cli::Command::List => cmd_list(), + } +} + +// ── helpers ────────────────────────────────────────────────────────────────── + +fn discover() -> Result { + Ok(gix::discover(".")?) +} + +fn config_path(repo: &gix::Repository) -> Result { + let workdir = repo.workdir().ok_or("not a working-copy repository")?; + Ok(workdir.join(".gitvendors")) +} + +fn load_config(path: &Path) -> Result { + if path.exists() { + Ok(VendorConfig::open(path)?) + } else { + Ok(VendorConfig::parse("")?) + } +} + +fn save_config(config: &VendorConfig, path: &Path) -> Result<()> { + Ok(std::fs::write(path, config.to_string())?) +} + +fn require_entry(config: &VendorConfig, name: &str) -> Result { + config + .get(name)? + .ok_or_else(|| format!("no vendor named {name:?}").into()) +} + +fn tree_paths(repo: &gix::Repository, tree_id: gix::ObjectId) -> Result> { + let index = repo.index_from_tree(&tree_id)?; + Ok(index + .entries() + .iter() + .map(|e| e.path(&index).into()) + .collect()) +} + +fn advance_head(repo: &gix::Repository, new_commit: gix::ObjectId, msg: &str) -> Result<()> { + use gix::refs::Target; + use gix::refs::transaction::{Change, LogChange, PreviousValue, RefEdit, RefLog}; + + let name: gix::refs::FullName = "HEAD".try_into()?; + repo.edit_references([RefEdit { + change: Change::Update { + log: LogChange { + mode: RefLog::AndReference, + force_create_reflog: false, + message: msg.as_bytes().into(), + }, + expected: PreviousValue::Any, + new: Target::Object(new_commit), + }, + name, + deref: true, + }])?; + Ok(()) +} + +fn committer_sig(repo: &gix::Repository) -> Result { + let sig_ref = repo + .committer() + .ok_or("no committer identity; set user.name and user.email")? + .map_err(|e| format!("committer: {e}"))?; + sig_ref + .to_owned() + .map_err(|e| format!("committer time: {e}").into()) +} + +fn author_sig(repo: &gix::Repository) -> Result { + let sig_ref = repo + .author() + .ok_or("no author identity; set user.name and user.email")? + .map_err(|e| format!("author: {e}"))?; + sig_ref + .to_owned() + .map_err(|e| format!("author time: {e}").into()) +} + +fn reconcile_tracked_paths( + repo: &gix::Repository, + entry: &VendorEntry, + old_paths: &[gix::bstr::BString], + new_paths: &[gix::bstr::BString], +) -> Result<()> { + use gix::bstr::BStr; + let track: Vec<&BStr> = new_paths.iter().map(|b| b.as_ref()).collect(); + repo.track_vendor(entry, &track)?; + + let new_set: std::collections::HashSet<&[u8]> = + new_paths.iter().map(|b| b.as_slice()).collect(); + let removed: Vec<&BStr> = old_paths + .iter() + .filter(|b| !new_set.contains(b.as_slice())) + .map(|b| b.as_ref()) + .collect(); + if !removed.is_empty() { + repo.untrack_vendor(entry, &removed)?; + } + Ok(()) +} + +/// Read the staged `.gitattributes` blob OID from the index. +fn staged_attrs_blob(repo: &gix::Repository) -> Result { + use gix::bstr::ByteSlice as _; + let index = repo.open_index().map_err(|e| format!("{e}"))?; + index + .entries() + .iter() + .find(|e| e.path(&index) == b".gitattributes".as_bstr()) + .map(|e| e.id) + .ok_or_else(|| "no .gitattributes in index after tracking".into()) +} + +/// Upsert the staged `.gitattributes` blob into `full_tree`, returning the +/// corrected full tree that includes updated vendor membership. +fn final_tree( + repo: &gix::Repository, + full_tree: gix::ObjectId, + attrs_blob: gix::ObjectId, +) -> Result { + use gix::bstr::ByteSlice as _; + let mut editor = repo + .find_object(full_tree) + .map_err(|e| format!("{e}"))? + .into_tree() + .edit() + .map_err(|e| format!("{e}"))?; + editor + .upsert( + b".gitattributes".as_bstr(), + gix::objs::tree::EntryKind::Blob, + attrs_blob, + ) + .map_err(|e| format!("{e}"))?; + Ok(editor.write().map_err(|e| format!("{e}"))?.detach()) +} + +/// Mint a vendor merge commit using `tree` and advance HEAD. +/// +/// In squash mode a parentless squash commit is minted and used as the +/// second parent; in merge mode the upstream commit is used directly. +fn commit_and_advance( + repo: &gix::Repository, + entry: &VendorEntry, + merge: &git_vendor::VendorMerge, + tree: gix::ObjectId, + parent: gix::ObjectId, + message: &str, +) -> Result<()> { + let author = author_sig(repo)?; + let committer = committer_sig(repo)?; + + let mut tbuf_a = gix::date::parse::TimeBuf::default(); + let mut tbuf_c = gix::date::parse::TimeBuf::default(); + + let second_parent = if entry.mode == VendorMode::Squash { + let upstream_tree = repo.upstream_tree(entry, merge.upstream_commit)?; + let squash = gix::objs::Commit { + tree: upstream_tree, + parents: Default::default(), + author: author.to_ref(&mut tbuf_a).into(), + committer: committer.to_ref(&mut tbuf_c).into(), + encoding: None, + message: format!( + "squash: vendor '{}'\n\nSquashed-upstream: {}\n", + entry.name, merge.upstream_commit + ) + .into(), + extra_headers: Vec::new(), + }; + repo.write_object(&squash)?.detach() + } else { + merge.upstream_commit + }; + + let mut tbuf_a2 = gix::date::parse::TimeBuf::default(); + let mut tbuf_c2 = gix::date::parse::TimeBuf::default(); + let commit = gix::objs::Commit { + tree, + parents: [parent, second_parent].into_iter().collect(), + author: author.to_ref(&mut tbuf_a2).into(), + committer: committer.to_ref(&mut tbuf_c2).into(), + encoding: None, + message: message.into(), + extra_headers: Vec::new(), + }; + let new_commit = repo.write_object(&commit)?.detach(); + advance_head(repo, new_commit, message) +} + +// ── commands ───────────────────────────────────────────────────────────────── + +fn cmd_add( + name: String, + url: String, + ref_name: Option, + patterns: Vec, + squash: bool, + message: Option, +) -> Result<()> { + let repo = discover()?; + let cfg_path = config_path(&repo)?; + let mut config = load_config(&cfg_path)?; + + let vendor_name = VendorName::new(&name)?; + let mode = if squash { + VendorMode::Squash + } else { + VendorMode::default() + }; + let mut entry = VendorEntry { + name: vendor_name, + url, + ref_name, + base: None, + patterns: patterns.iter().map(|p| PatternMapping::parse(p)).collect(), + mode, + }; + + // Fetch before touching config — a failed fetch leaves no side effects. + eprintln!("Fetching {name}…"); + let upstream = repo.fetch_vendor(&entry)?; + + let head_oid = repo.head_commit().ok().map(|c| c.id().detach()); + + let msg = message + .clone() + .unwrap_or_else(|| format!("vendor: add {name}")); + + match head_oid { + Some(ours) => { + let merge = repo.merge_vendor(&entry, ours, upstream)?; + + if merge.has_conflicts() { + repo.checkout_vendor_conflicted(&entry, &merge)?; + let paths: Vec<_> = merge.conflicts.iter().map(|c| c.path.as_str()).collect(); + eprintln!("Conflict in: {}", paths.join(", ")); + eprintln!("Resolve conflicts, then commit."); + std::process::exit(1); + } + + let full_tree = repo.checkout_vendor(&entry, merge.result_tree)?; + let new_paths = tree_paths(&repo, merge.result_tree)?; + reconcile_tracked_paths(&repo, &entry, &[], &new_paths)?; + + entry.base = Some(merge.upstream_commit); + config.insert(&entry)?; + save_config(&config, &cfg_path)?; + + if message.is_some() { + let attrs_blob = staged_attrs_blob(&repo)?; + let tree = final_tree(&repo, full_tree, attrs_blob)?; + commit_and_advance(&repo, &entry, &merge, tree, ours, &msg)?; + eprintln!("Added vendor {name}."); + } else { + repo.prepare_merge(&entry, &merge, &msg)?; + eprintln!("Added vendor {name}. Run `git commit` to record the merge."); + } + } + None => { + // Unborn repository: make the initial commit directly (no merge). + let tree = repo.upstream_tree(&entry, upstream)?; + let full_tree = repo.checkout_vendor(&entry, tree)?; + let new_paths = tree_paths(&repo, tree)?; + let path_refs: Vec<&gix::bstr::BStr> = new_paths.iter().map(|b| b.as_ref()).collect(); + repo.track_vendor(&entry, &path_refs)?; + + entry.base = Some(upstream); + config.insert(&entry)?; + save_config(&config, &cfg_path)?; + + let attrs_blob = staged_attrs_blob(&repo)?; + let commit_tree = final_tree(&repo, full_tree, attrs_blob)?; + + let author = author_sig(&repo)?; + let committer = committer_sig(&repo)?; + let mut tbuf_a = gix::date::parse::TimeBuf::default(); + let mut tbuf_c = gix::date::parse::TimeBuf::default(); + let commit = gix::objs::Commit { + tree: commit_tree, + parents: Default::default(), + author: author.to_ref(&mut tbuf_a).into(), + committer: committer.to_ref(&mut tbuf_c).into(), + encoding: None, + message: msg.as_str().into(), + extra_headers: Vec::new(), + }; + let new_commit = repo.write_object(&commit)?.detach(); + advance_head(&repo, new_commit, &msg)?; + eprintln!("Added vendor {name}."); + } + } + + Ok(()) +} + +fn cmd_update(name: Option, message: Option, force: bool) -> Result<()> { + let repo = discover()?; + let cfg_path = config_path(&repo)?; + let mut config = load_config(&cfg_path)?; + + // Multi-vendor updates always auto-commit (one commit per vendor); only a + // single-vendor update without -m uses the prepare-merge path. + let auto_commit = name.is_none() || message.is_some(); + + let entries: Vec = match name { + Some(ref n) => vec![require_entry(&config, n)?], + None => config.entries()?, + }; + + if entries.is_empty() { + eprintln!("No vendors configured."); + return Ok(()); + } + + let head_oid = repo + .head_commit() + .map(|c| c.id().detach()) + .map_err(|e| format!("HEAD: {e}"))?; + + let mut current_head = head_oid; + + for mut entry in entries { + let n = entry.name.as_str().to_owned(); + eprintln!("Fetching {n}…"); + let upstream = repo.fetch_vendor(&entry)?; + + let status = repo.vendor_status(&entry)?; + match status { + VendorStatus::UpToDate => { + eprintln!("{n}: already up to date"); + continue; + } + VendorStatus::ForcePushed { .. } if !force => { + eprintln!("{n}: upstream was force-pushed; re-run with --force to accept"); + continue; + } + _ => {} + } + + let msg = message + .clone() + .unwrap_or_else(|| format!("vendor: update {n}")); + + let old_paths: Vec = + repo.vendor_paths(&entry, current_head).unwrap_or_default(); + + let merge = repo.merge_vendor(&entry, current_head, upstream)?; + + if merge.has_conflicts() { + repo.checkout_vendor_conflicted(&entry, &merge)?; + let paths: Vec<_> = merge.conflicts.iter().map(|c| c.path.as_str()).collect(); + eprintln!("{n}: conflict in {}", paths.join(", ")); + eprintln!("Resolve conflicts, then commit."); + std::process::exit(1); + } + + let full_tree = repo.checkout_vendor(&entry, merge.result_tree)?; + let new_paths = tree_paths(&repo, merge.result_tree)?; + reconcile_tracked_paths(&repo, &entry, &old_paths, &new_paths)?; + + entry.base = Some(merge.upstream_commit); + config.insert(&entry)?; + save_config(&config, &cfg_path)?; + + if auto_commit { + let attrs_blob = staged_attrs_blob(&repo)?; + let tree = final_tree(&repo, full_tree, attrs_blob)?; + commit_and_advance(&repo, &entry, &merge, tree, current_head, &msg)?; + current_head = repo + .head_commit() + .map(|c| c.id().detach()) + .map_err(|e| format!("HEAD after commit: {e}"))?; + eprintln!("Updated {n}."); + } else { + repo.prepare_merge(&entry, &merge, &msg)?; + eprintln!("Updated {n}. Run `git commit` to record the merge."); + } + } + + Ok(()) +} + +fn cmd_status(name: Option, fetch: bool) -> Result<()> { + let repo = discover()?; + let cfg_path = config_path(&repo)?; + let config = load_config(&cfg_path)?; + + let entries: Vec = match name { + Some(ref n) => vec![require_entry(&config, n)?], + None => config.entries()?, + }; + + if entries.is_empty() { + eprintln!("No vendors configured."); + return Ok(()); + } + + for entry in &entries { + if fetch { + repo.fetch_vendor(entry)?; + } + let status = repo.vendor_status(entry)?; + let label = match &status { + VendorStatus::NotFetched => "not fetched".to_owned(), + VendorStatus::UpToDate => "up to date".to_owned(), + VendorStatus::UpdateAvailable { upstream } => { + format!("update available ({})", upstream.to_hex()) + } + VendorStatus::ForcePushed { upstream } => { + format!("force-pushed upstream ({})", upstream.to_hex()) + } + }; + println!("{}\t{}\t{label}", entry.name, entry.url); + } + + Ok(()) +} + +fn cmd_remove(name: String, keep_files: bool) -> Result<()> { + let repo = discover()?; + let cfg_path = config_path(&repo)?; + let mut config = load_config(&cfg_path)?; + + let entry = require_entry(&config, &name)?; + + if !keep_files { + let head_oid = repo.head_commit().ok().map(|c| c.id().detach()); + + if let Some(oid) = head_oid { + let workdir = repo.workdir().ok_or("not a working-copy repository")?; + let paths = repo.vendor_paths(&entry, oid)?; + for p in &paths { + let abs = workdir.join(gix::path::from_bstr(p).as_ref()); + if abs.symlink_metadata().is_ok() { + std::fs::remove_file(&abs)?; + } + } + let path_refs: Vec<&gix::bstr::BStr> = paths.iter().map(|b| b.as_ref()).collect(); + repo.untrack_vendor(&entry, &path_refs)?; + } + } + + config.remove(&name)?; + save_config(&config, &cfg_path)?; + eprintln!("Removed vendor {name}."); + Ok(()) +} + +fn cmd_list() -> Result<()> { + let repo = discover()?; + let cfg_path = config_path(&repo)?; + let config = load_config(&cfg_path)?; + let entries = config.entries()?; + + if entries.is_empty() { + eprintln!("No vendors configured."); + return Ok(()); + } + + for entry in &entries { + let ref_label = entry.ref_name.as_deref().unwrap_or("HEAD"); + let mode_label = entry.mode.as_str(); + println!("{}\t{}\t{ref_label}\t{mode_label}", entry.name, entry.url); + } + + Ok(()) +} diff --git a/crates/git-vendor/src/vendor.rs b/crates/git-vendor/src/vendor.rs index 9fb9a03..2e30ff9 100644 --- a/crates/git-vendor/src/vendor.rs +++ b/crates/git-vendor/src/vendor.rs @@ -601,7 +601,14 @@ pub trait VendorWorktree { /// /// Only paths owned by this vendor are written; unrelated files are left /// untouched. - fn checkout_vendor(&self, entry: &VendorEntry, tree: gix::ObjectId) -> Result<(), Error>; + /// Returns the full overlaid tree OID (the result of splicing `tree` into + /// the parent commit's tree), needed by callers that want to mint a commit + /// from the same tree without re-running the overlay. + fn checkout_vendor( + &self, + entry: &VendorEntry, + tree: gix::ObjectId, + ) -> Result; /// Project a conflicted merge onto the working copy for manual resolution. /// @@ -624,7 +631,8 @@ pub trait VendorWorktree { ) -> Result<(), Error>; /// Add the given paths to the vendor's local content filter by writing - /// `vendor=` entries into the working-copy `.gitattributes`. + /// `vendor=` entries into the working-copy `.gitattributes` and + /// staging the updated file into the index. /// /// This authors local-side membership (read back by /// [`VendorRepository::vendor_paths`](crate::VendorRepository::vendor_paths)); @@ -632,6 +640,19 @@ pub trait VendorWorktree { fn track_vendor(&self, entry: &VendorEntry, paths: &[&BStr]) -> Result<(), Error>; /// Remove the given paths from the vendor's content filter, deleting their - /// `vendor=` entries from the working-copy `.gitattributes`. + /// `vendor=` entries from the working-copy `.gitattributes` and + /// staging the updated file into the index. fn untrack_vendor(&self, entry: &VendorEntry, paths: &[&BStr]) -> Result<(), Error>; + + /// Stage the merge result for a subsequent `git commit`. + /// + /// Writes `MERGE_HEAD` + `MERGE_MSG` (merge mode) or `SQUASH_MSG` (squash + /// mode) so that the user's own `git commit` produces the right commit + /// shape and has the default message pre-filled. + fn prepare_merge( + &self, + entry: &VendorEntry, + merge: &VendorMerge, + message: &str, + ) -> Result<(), Error>; } From 133aa232b46adfb2a8ea39203b686aab6460f552 Mon Sep 17 00:00:00 2001 From: "Joseph D. Carpinelli" Date: Tue, 9 Jun 2026 08:38:07 -0400 Subject: [PATCH 02/31] test: add integration tests for prepare_merge and track_vendor Covers merge mode, squash mode, and overwrite behaviour in prepare_merge. Adds a test asserting that track_vendor rejects paths requiring gitattributes quoting with Error::InvalidPath, replacing the former path_with_space_is_quoted / idempotent_for_path_with_space tests that were invalidated when quoting support was removed. test: add prepare_merge integration tests (merge, squash, overwrite) test: add path_with_space_returns_invalid_path_error to track_vendor Assisted-by: Claude:claude-sonnet-4-6 --- crates/git-vendor/tests/prepare_merge.rs | 4 + .../git-vendor/tests/prepare_merge/table.rs | 134 ++++++++++++++++++ crates/git-vendor/tests/track_vendor/table.rs | 14 ++ 3 files changed, 152 insertions(+) create mode 100644 crates/git-vendor/tests/prepare_merge.rs create mode 100644 crates/git-vendor/tests/prepare_merge/table.rs diff --git a/crates/git-vendor/tests/prepare_merge.rs b/crates/git-vendor/tests/prepare_merge.rs new file mode 100644 index 0000000..b83dc26 --- /dev/null +++ b/crates/git-vendor/tests/prepare_merge.rs @@ -0,0 +1,4 @@ +mod support; +mod prepare_merge { + mod table; +} diff --git a/crates/git-vendor/tests/prepare_merge/table.rs b/crates/git-vendor/tests/prepare_merge/table.rs new file mode 100644 index 0000000..864c7c8 --- /dev/null +++ b/crates/git-vendor/tests/prepare_merge/table.rs @@ -0,0 +1,134 @@ +//! Tests for `prepare_merge`. +//! +//! `prepare_merge` writes the git-dir files that put the working copy into +//! merge state so the user can run `git commit` to seal the vendor merge. +//! In squash mode it writes `SQUASH_MSG`; in merge mode it writes `MERGE_HEAD` +//! and `MERGE_MSG`. + +use git_vendor::{ + PatternMapping, VendorEntry, VendorMerge, VendorMode, VendorName, VendorWorktree as _, +}; + +use crate::support::{git, init, write}; + +fn null_oid() -> gix::ObjectId { + gix::ObjectId::from_hex(b"0000000000000000000000000000000000000000").unwrap() +} + +fn make_merge() -> VendorMerge { + VendorMerge { + upstream_commit: null_oid(), + ancestor_tree: None, + result_tree: null_oid(), + conflicts: vec![], + } +} + +fn entry(mode: VendorMode) -> VendorEntry { + VendorEntry { + name: VendorName::new("mylib").unwrap(), + url: "unused".to_owned(), + ref_name: None, + base: None, + patterns: vec![PatternMapping { + glob: "up/**".to_owned(), + destination: Some("vendor/".to_owned()), + }], + mode, + } +} + +struct Built { + _dir: tempfile::TempDir, + repo: gix::Repository, +} + +fn build_repo() -> Built { + let dir = tempfile::tempdir().unwrap(); + init(dir.path()); + write(dir.path(), "README", b"hello\n"); + git(&["add", "-A"], dir.path()); + git(&["commit", "-m", "initial"], dir.path()); + let repo = gix::open(dir.path()).expect("gix open"); + Built { _dir: dir, repo } +} + +/// Merge mode writes `MERGE_HEAD` containing the upstream commit OID followed +/// by a newline, and `MERGE_MSG` containing the message. +#[test] +fn merge_mode_writes_merge_head_and_merge_msg() { + let b = build_repo(); + let merge = make_merge(); + let message = "vendor: update mylib\n"; + + b.repo + .prepare_merge(&entry(VendorMode::Merge), &merge, message) + .expect("prepare_merge"); + + let git_dir = b.repo.git_dir(); + + let merge_head = std::fs::read_to_string(git_dir.join("MERGE_HEAD")).unwrap(); + assert_eq!( + merge_head, + format!("{}\n", null_oid()), + "MERGE_HEAD must be \\n", + ); + + let merge_msg = std::fs::read_to_string(git_dir.join("MERGE_MSG")).unwrap(); + assert_eq!( + merge_msg, message, + "MERGE_MSG must equal the supplied message" + ); + + assert!( + !git_dir.join("SQUASH_MSG").exists(), + "SQUASH_MSG must not be written in merge mode", + ); +} + +/// Squash mode writes `SQUASH_MSG` containing the message and does not write +/// `MERGE_HEAD` or `MERGE_MSG`. +#[test] +fn squash_mode_writes_squash_msg() { + let b = build_repo(); + let merge = make_merge(); + let message = "vendor: add mylib (squash)\n"; + + b.repo + .prepare_merge(&entry(VendorMode::Squash), &merge, message) + .expect("prepare_merge"); + + let git_dir = b.repo.git_dir(); + + let squash_msg = std::fs::read_to_string(git_dir.join("SQUASH_MSG")).unwrap(); + assert_eq!( + squash_msg, message, + "SQUASH_MSG must equal the supplied message", + ); + + assert!( + !git_dir.join("MERGE_HEAD").exists(), + "MERGE_HEAD must not be written in squash mode", + ); + assert!( + !git_dir.join("MERGE_MSG").exists(), + "MERGE_MSG must not be written in squash mode", + ); +} + +/// Calling `prepare_merge` a second time overwrites the previous state files. +#[test] +fn second_call_overwrites_previous_files() { + let b = build_repo(); + let merge = make_merge(); + + b.repo + .prepare_merge(&entry(VendorMode::Merge), &merge, "first\n") + .expect("first call"); + b.repo + .prepare_merge(&entry(VendorMode::Merge), &merge, "second\n") + .expect("second call"); + + let merge_msg = std::fs::read_to_string(b.repo.git_dir().join("MERGE_MSG")).unwrap(); + assert_eq!(merge_msg, "second\n"); +} diff --git a/crates/git-vendor/tests/track_vendor/table.rs b/crates/git-vendor/tests/track_vendor/table.rs index ce648dc..c7e581f 100644 --- a/crates/git-vendor/tests/track_vendor/table.rs +++ b/crates/git-vendor/tests/track_vendor/table.rs @@ -133,6 +133,20 @@ fn preserves_unrelated_lines() { ); } +/// A path containing a space is rejected with `Error::InvalidPath`. +#[test] +fn path_with_space_returns_invalid_path_error() { + let b = build_without_attributes(); + let err = b + .repo + .track_vendor(&entry(), &[b"vendor/a b.txt".as_bstr()]) + .unwrap_err(); + assert!( + matches!(err, git_vendor::Error::InvalidPath(_)), + "expected InvalidPath, got {err:?}", + ); +} + /// A bare repo returns `Error::NoWorkdir`. #[test] fn bare_repo_returns_no_workdir_error() { From 4e50374361cd724df409a8df775bf225af018660 Mon Sep 17 00:00:00 2001 From: "Joseph D. Carpinelli" Date: Fri, 12 Jun 2026 17:12:24 -0400 Subject: [PATCH 03/31] fix: correct several CLI correctness bugs found in adversarial review split_attr_line now returns None for comment lines (starting with '#') and lines with a leading-whitespace separator, matching its documented contract. .gitvendors is now written into every commit tree and staged into the index alongside .gitattributes; previously it was only written to disk, so collaborators who pulled got vendored files but no config. On merge conflicts, save_config + stage_gitvendors + prepare_merge are now called before exit(1), so the user's git commit produces a proper merge commit and the config entry survives the resolution round-trip. cmd_add always auto-commits; the message.is_some() gate was removed because the CLI docs advertise a default message, making the split behaviour surprising. cmd_remove now removes deleted vendor file entries from the index after deleting them from the worktree, so a plain git commit records the removal rather than leaving the files tracked. fix: split_attr_line returns None for comment and whitespace-only lines fix: .gitvendors included in commit tree and staged alongside .gitattributes fix: conflict path writes MERGE_HEAD and saves config before exiting fix: cmd_add always auto-commits fix: cmd_remove stages index deletions for removed vendor files test: extend split_no_attr_returns_none with comment and whitespace cases Assisted-by: Claude:claude-sonnet-4-6 --- crates/git-vendor/src/exe.rs | 6 ++ crates/git-vendor/src/exe_tests.rs | 4 ++ crates/git-vendor/src/main.rs | 110 ++++++++++++++++++++++------- 3 files changed, 95 insertions(+), 25 deletions(-) diff --git a/crates/git-vendor/src/exe.rs b/crates/git-vendor/src/exe.rs index 2c1003a..ee2e411 100644 --- a/crates/git-vendor/src/exe.rs +++ b/crates/git-vendor/src/exe.rs @@ -277,6 +277,9 @@ fn check_attr_pattern(path: &[u8]) -> Result<(), Error> { /// separator. Handles both plain and C-style-quoted patterns using /// [`gix_quote::ansi_c::undo`]. fn split_attr_line(line: &[u8]) -> Option<(std::borrow::Cow<'_, [u8]>, &[u8])> { + if line.is_empty() || line[0] == b'#' { + return None; + } if line.starts_with(b"\"") { let (pattern, consumed) = gix_quote::ansi_c::undo(line.as_bstr()).ok()?; let rest = line.get(consumed..)?; @@ -288,6 +291,9 @@ fn split_attr_line(line: &[u8]) -> Option<(std::borrow::Cow<'_, [u8]>, &[u8])> { } } else { let pos = line.iter().position(|&b| b == b' ' || b == b'\t')?; + if pos == 0 { + return None; // leading whitespace — no valid pattern before the separator + } Some(( std::borrow::Cow::Borrowed(&line[..pos]), line[pos + 1..].trim(), diff --git a/crates/git-vendor/src/exe_tests.rs b/crates/git-vendor/src/exe_tests.rs index 1ec7057..dd7270e 100644 --- a/crates/git-vendor/src/exe_tests.rs +++ b/crates/git-vendor/src/exe_tests.rs @@ -35,6 +35,10 @@ fn split_line(#[case] line: &[u8], #[case] pattern: &[u8], #[case] attr: &[u8]) #[rstest] #[case(b"vendor/a.txt")] #[case(b"")] +#[case(b"# comment line")] +#[case(b"# vendor=mylib")] +#[case(b" ")] +#[case(b" vendor/a.txt attr")] fn split_no_attr_returns_none(#[case] line: &[u8]) { assert!(split_attr_line(line).is_none()); } diff --git a/crates/git-vendor/src/main.rs b/crates/git-vendor/src/main.rs index 0164254..e12655d 100644 --- a/crates/git-vendor/src/main.rs +++ b/crates/git-vendor/src/main.rs @@ -60,8 +60,11 @@ fn load_config(path: &Path) -> Result { } } -fn save_config(config: &VendorConfig, path: &Path) -> Result<()> { - Ok(std::fs::write(path, config.to_string())?) +/// Write `config` to `path` and return the serialized bytes for blob staging. +fn save_config(config: &VendorConfig, path: &Path) -> Result { + let s = config.to_string(); + std::fs::write(path, &s)?; + Ok(s) } fn require_entry(config: &VendorConfig, name: &str) -> Result { @@ -143,8 +146,9 @@ fn reconcile_tracked_paths( Ok(()) } -/// Read the staged `.gitattributes` blob OID from the index. -fn staged_attrs_blob(repo: &gix::Repository) -> Result { +/// Write `content` as a blob, upsert the `.gitattributes` index entry, and +/// return the blob OID so callers can include it in a commit tree. +fn stage_attrs_blob(repo: &gix::Repository) -> Result { use gix::bstr::ByteSlice as _; let index = repo.open_index().map_err(|e| format!("{e}"))?; index @@ -155,12 +159,37 @@ fn staged_attrs_blob(repo: &gix::Repository) -> Result { .ok_or_else(|| "no .gitattributes in index after tracking".into()) } -/// Upsert the staged `.gitattributes` blob into `full_tree`, returning the -/// corrected full tree that includes updated vendor membership. +/// Write `content` as a blob, upsert the `.gitvendors` index entry, and return +/// the blob OID so callers can include it in a commit tree. +fn stage_gitvendors(repo: &gix::Repository, content: &[u8]) -> Result { + use gix::bstr::ByteSlice as _; + let blob_oid = repo + .write_object(gix::objs::BlobRef { data: content }) + .map_err(|e| format!("{e}"))? + .detach(); + let mut index = repo.open_index().map_err(|e| format!("{e}"))?; + index.remove_entries(|_, path, _| path == b".gitvendors".as_bstr()); + index.dangerously_push_entry( + gix::index::entry::Stat::default(), + blob_oid, + gix::index::entry::Flags::empty(), + gix::index::entry::Mode::FILE, + b".gitvendors".as_bstr(), + ); + index.sort_entries(); + index + .write(gix::index::write::Options::default()) + .map_err(|e| format!("{e}"))?; + Ok(blob_oid) +} + +/// Upsert `.gitattributes` and `.gitvendors` blobs into `full_tree`, returning +/// the corrected tree OID that carries both files. fn final_tree( repo: &gix::Repository, full_tree: gix::ObjectId, attrs_blob: gix::ObjectId, + vendors_blob: gix::ObjectId, ) -> Result { use gix::bstr::ByteSlice as _; let mut editor = repo @@ -176,6 +205,13 @@ fn final_tree( attrs_blob, ) .map_err(|e| format!("{e}"))?; + editor + .upsert( + b".gitvendors".as_bstr(), + gix::objs::tree::EntryKind::Blob, + vendors_blob, + ) + .map_err(|e| format!("{e}"))?; Ok(editor.write().map_err(|e| format!("{e}"))?.detach()) } @@ -277,9 +313,16 @@ fn cmd_add( if merge.has_conflicts() { repo.checkout_vendor_conflicted(&entry, &merge)?; + // Record the config entry and set up MERGE_HEAD so the user's + // `git commit` after resolution produces a proper merge commit. + entry.base = Some(merge.upstream_commit); + config.insert(&entry)?; + let config_str = save_config(&config, &cfg_path)?; + stage_gitvendors(&repo, config_str.as_bytes())?; + repo.prepare_merge(&entry, &merge, &msg)?; let paths: Vec<_> = merge.conflicts.iter().map(|c| c.path.as_str()).collect(); eprintln!("Conflict in: {}", paths.join(", ")); - eprintln!("Resolve conflicts, then commit."); + eprintln!("Resolve conflicts, then run `git commit`."); std::process::exit(1); } @@ -289,17 +332,13 @@ fn cmd_add( entry.base = Some(merge.upstream_commit); config.insert(&entry)?; - save_config(&config, &cfg_path)?; - - if message.is_some() { - let attrs_blob = staged_attrs_blob(&repo)?; - let tree = final_tree(&repo, full_tree, attrs_blob)?; - commit_and_advance(&repo, &entry, &merge, tree, ours, &msg)?; - eprintln!("Added vendor {name}."); - } else { - repo.prepare_merge(&entry, &merge, &msg)?; - eprintln!("Added vendor {name}. Run `git commit` to record the merge."); - } + let config_str = save_config(&config, &cfg_path)?; + + let attrs_blob = stage_attrs_blob(&repo)?; + let vendors_blob = stage_gitvendors(&repo, config_str.as_bytes())?; + let tree = final_tree(&repo, full_tree, attrs_blob, vendors_blob)?; + commit_and_advance(&repo, &entry, &merge, tree, ours, &msg)?; + eprintln!("Added vendor {name}."); } None => { // Unborn repository: make the initial commit directly (no merge). @@ -311,10 +350,11 @@ fn cmd_add( entry.base = Some(upstream); config.insert(&entry)?; - save_config(&config, &cfg_path)?; + let config_str = save_config(&config, &cfg_path)?; - let attrs_blob = staged_attrs_blob(&repo)?; - let commit_tree = final_tree(&repo, full_tree, attrs_blob)?; + let attrs_blob = stage_attrs_blob(&repo)?; + let vendors_blob = stage_gitvendors(&repo, config_str.as_bytes())?; + let commit_tree = final_tree(&repo, full_tree, attrs_blob, vendors_blob)?; let author = author_sig(&repo)?; let committer = committer_sig(&repo)?; @@ -393,9 +433,14 @@ fn cmd_update(name: Option, message: Option, force: bool) -> Res if merge.has_conflicts() { repo.checkout_vendor_conflicted(&entry, &merge)?; + entry.base = Some(merge.upstream_commit); + config.insert(&entry)?; + let config_str = save_config(&config, &cfg_path)?; + stage_gitvendors(&repo, config_str.as_bytes())?; + repo.prepare_merge(&entry, &merge, &msg)?; let paths: Vec<_> = merge.conflicts.iter().map(|c| c.path.as_str()).collect(); eprintln!("{n}: conflict in {}", paths.join(", ")); - eprintln!("Resolve conflicts, then commit."); + eprintln!("Resolve conflicts, then run `git commit`."); std::process::exit(1); } @@ -405,11 +450,12 @@ fn cmd_update(name: Option, message: Option, force: bool) -> Res entry.base = Some(merge.upstream_commit); config.insert(&entry)?; - save_config(&config, &cfg_path)?; + let config_str = save_config(&config, &cfg_path)?; if auto_commit { - let attrs_blob = staged_attrs_blob(&repo)?; - let tree = final_tree(&repo, full_tree, attrs_blob)?; + let attrs_blob = stage_attrs_blob(&repo)?; + let vendors_blob = stage_gitvendors(&repo, config_str.as_bytes())?; + let tree = final_tree(&repo, full_tree, attrs_blob, vendors_blob)?; commit_and_advance(&repo, &entry, &merge, tree, current_head, &msg)?; current_head = repo .head_commit() @@ -417,6 +463,7 @@ fn cmd_update(name: Option, message: Option, force: bool) -> Res .map_err(|e| format!("HEAD after commit: {e}"))?; eprintln!("Updated {n}."); } else { + stage_gitvendors(&repo, config_str.as_bytes())?; repo.prepare_merge(&entry, &merge, &msg)?; eprintln!("Updated {n}. Run `git commit` to record the merge."); } @@ -472,6 +519,7 @@ fn cmd_remove(name: String, keep_files: bool) -> Result<()> { let head_oid = repo.head_commit().ok().map(|c| c.id().detach()); if let Some(oid) = head_oid { + use gix::bstr::ByteSlice as _; let workdir = repo.workdir().ok_or("not a working-copy repository")?; let paths = repo.vendor_paths(&entry, oid)?; for p in &paths { @@ -482,6 +530,18 @@ fn cmd_remove(name: String, keep_files: bool) -> Result<()> { } let path_refs: Vec<&gix::bstr::BStr> = paths.iter().map(|b| b.as_ref()).collect(); repo.untrack_vendor(&entry, &path_refs)?; + + // Remove the deleted vendor files from the index so `git commit` + // records the deletions rather than leaving them tracked. + let mut index = repo.open_index().map_err(|e| format!("{e}"))?; + for p in &path_refs { + let pb = p.as_bytes(); + index.remove_entries(|_, path, _| path == pb.as_bstr()); + } + index.sort_entries(); + index + .write(gix::index::write::Options::default()) + .map_err(|e| format!("{e}"))?; } } From 5136a64bec06be4c5ced748b2b223d81c1831676 Mon Sep 17 00:00:00 2001 From: "Joseph D. Carpinelli" Date: Fri, 12 Jun 2026 17:14:43 -0400 Subject: [PATCH 04/31] feat: make name optional in add, defaulting to URL leaf git vendor add now derives the vendor name from the last path component of the URL, stripping .git / .bundle suffixes, matching the convention used by git clone and git submodule add. Assisted-by: Claude:claude-sonnet-4-6 --- crates/git-vendor/src/cli.rs | 7 ++++--- crates/git-vendor/src/main.rs | 28 ++++++++++++++++++++++++++-- 2 files changed, 30 insertions(+), 5 deletions(-) diff --git a/crates/git-vendor/src/cli.rs b/crates/git-vendor/src/cli.rs index 65dc91c..07df83b 100644 --- a/crates/git-vendor/src/cli.rs +++ b/crates/git-vendor/src/cli.rs @@ -18,12 +18,13 @@ pub enum Command { /// mints a merge commit. Equivalent to `git subtree add` or a tracked /// `git submodule add` that copies files instead of linking a repo. Add { - /// Unique name for this vendor (used in `.gitvendors` and `.gitattributes`). - name: String, - /// Remote URL of the upstream repository. url: String, + /// Unique name for this vendor (used in `.gitvendors` and `.gitattributes`). + /// Defaults to the last path component of the URL, stripped of `.git`. + name: Option, + /// Branch, tag, or SHA to track on the upstream (defaults to `HEAD`). #[arg(long, value_name = "REF")] ref_name: Option, diff --git a/crates/git-vendor/src/main.rs b/crates/git-vendor/src/main.rs index e12655d..694e1a8 100644 --- a/crates/git-vendor/src/main.rs +++ b/crates/git-vendor/src/main.rs @@ -23,8 +23,8 @@ fn main() { fn run(cli: cli::Cli) -> Result<()> { match cli.command { cli::Command::Add { - name, url, + name, ref_name, patterns, squash, @@ -270,8 +270,26 @@ fn commit_and_advance( // ── commands ───────────────────────────────────────────────────────────────── +/// Derive a vendor name from a URL by taking the last non-empty path component +/// and stripping common suffixes (`.git`, `.bundle`). +fn name_from_url(url: &str) -> Option { + let stem = url + .trim_end_matches('/') + .rsplit(['/', ':']) + .find(|s| !s.is_empty())?; + let stem = stem + .strip_suffix(".git") + .or_else(|| stem.strip_suffix(".bundle")) + .unwrap_or(stem); + if stem.is_empty() { + None + } else { + Some(stem.to_owned()) + } +} + fn cmd_add( - name: String, + name: Option, url: String, ref_name: Option, patterns: Vec, @@ -282,6 +300,12 @@ fn cmd_add( let cfg_path = config_path(&repo)?; let mut config = load_config(&cfg_path)?; + let name = match name { + Some(n) => n, + None => name_from_url(&url).ok_or_else(|| { + format!("cannot derive a vendor name from URL {url:?}; pass a name explicitly") + })?, + }; let vendor_name = VendorName::new(&name)?; let mode = if squash { VendorMode::Squash From 03d8deba67e90e454d5b810e4389685a5d1c1dbc Mon Sep 17 00:00:00 2001 From: "Joseph D. Carpinelli" Date: Fri, 12 Jun 2026 17:23:46 -0400 Subject: [PATCH 05/31] feat: friendlier CLI UX Default prefix to vendor//, rename --ref-name to --ref, add --dry-run to add and update, and add rm/ls as visible aliases for remove/list. feat: default vendored files to vendor// when no patterns given feat: rename --ref-name to --ref feat: add --dry-run to add and update feat: add visible aliases rm and ls Assisted-by: Claude:claude-fable-5[1m] --- crates/git-vendor/src/cli.rs | 17 ++++++++++++++++- crates/git-vendor/src/main.rs | 35 ++++++++++++++++++++++++++++++++--- 2 files changed, 48 insertions(+), 4 deletions(-) diff --git a/crates/git-vendor/src/cli.rs b/crates/git-vendor/src/cli.rs index 07df83b..da499e5 100644 --- a/crates/git-vendor/src/cli.rs +++ b/crates/git-vendor/src/cli.rs @@ -26,9 +26,14 @@ pub enum Command { name: Option, /// Branch, tag, or SHA to track on the upstream (defaults to `HEAD`). - #[arg(long, value_name = "REF")] + #[arg(long = "ref", value_name = "REF")] ref_name: Option, + /// Destination directory for vendored files (defaults to `vendor//`). + /// Ignored when `--pattern` is also given. + #[arg(long, value_name = "DIR")] + prefix: Option, + /// File pattern used to filter the upstream vendor content with optional /// remapping into the working copy, e.g. `src/**:vendor/lib/`. May be /// repeated to select multiple paths. @@ -40,6 +45,10 @@ pub enum Command { #[arg(long)] squash: bool, + /// Show what would be fetched and merged without making any changes. + #[arg(long)] + dry_run: bool, + /// Commit message (defaults to `vendor: add `). #[arg(long, short = 'm', value_name = "MSG")] message: Option, @@ -60,6 +69,10 @@ pub enum Command { /// history). Without this flag, a force-push is reported as an error. #[arg(long)] force: bool, + + /// Show what would be fetched and merged without making any changes. + #[arg(long)] + dry_run: bool, }, /// Show synchronization status for one or all vendors. @@ -73,6 +86,7 @@ pub enum Command { }, /// Remove a vendor dependency and its files from the working tree. + #[command(visible_alias = "rm")] Remove { /// Vendor name to remove. name: String, @@ -84,5 +98,6 @@ pub enum Command { }, /// List all configured vendor dependencies. + #[command(visible_alias = "ls")] List, } diff --git a/crates/git-vendor/src/main.rs b/crates/git-vendor/src/main.rs index 694e1a8..1c93fca 100644 --- a/crates/git-vendor/src/main.rs +++ b/crates/git-vendor/src/main.rs @@ -26,15 +26,20 @@ fn run(cli: cli::Cli) -> Result<()> { url, name, ref_name, + prefix, patterns, squash, + dry_run, message, - } => cmd_add(name, url, ref_name, patterns, squash, message), + } => cmd_add( + name, url, ref_name, prefix, patterns, squash, dry_run, message, + ), cli::Command::Update { name, message, force, - } => cmd_update(name, message, force), + dry_run, + } => cmd_update(name, message, force, dry_run), cli::Command::Status { name, fetch } => cmd_status(name, fetch), cli::Command::Remove { name, keep_files } => cmd_remove(name, keep_files), cli::Command::List => cmd_list(), @@ -288,12 +293,15 @@ fn name_from_url(url: &str) -> Option { } } +#[allow(clippy::too_many_arguments)] fn cmd_add( name: Option, url: String, ref_name: Option, + prefix: Option, patterns: Vec, squash: bool, + dry_run: bool, message: Option, ) -> Result<()> { let repo = discover()?; @@ -312,6 +320,12 @@ fn cmd_add( } else { VendorMode::default() }; + let patterns: Vec = if patterns.is_empty() { + let dest = prefix.unwrap_or_else(|| format!("vendor/{name}/")); + vec![format!("**:{dest}")] + } else { + patterns + }; let mut entry = VendorEntry { name: vendor_name, url, @@ -325,6 +339,11 @@ fn cmd_add( eprintln!("Fetching {name}…"); let upstream = repo.fetch_vendor(&entry)?; + if dry_run { + eprintln!("Would add vendor {name} at {upstream}."); + return Ok(()); + } + let head_oid = repo.head_commit().ok().map(|c| c.id().detach()); let msg = message @@ -402,7 +421,12 @@ fn cmd_add( Ok(()) } -fn cmd_update(name: Option, message: Option, force: bool) -> Result<()> { +fn cmd_update( + name: Option, + message: Option, + force: bool, + dry_run: bool, +) -> Result<()> { let repo = discover()?; let cfg_path = config_path(&repo)?; let mut config = load_config(&cfg_path)?; @@ -446,6 +470,11 @@ fn cmd_update(name: Option, message: Option, force: bool) -> Res _ => {} } + if dry_run { + eprintln!("Would update {n} to {upstream}."); + continue; + } + let msg = message .clone() .unwrap_or_else(|| format!("vendor: update {n}")); From d4e7285d580becc91ebc4001f51b4df5243d2398 Mon Sep 17 00:00:00 2001 From: "Joseph D. Carpinelli" Date: Fri, 12 Jun 2026 21:29:55 -0400 Subject: [PATCH 06/31] feat: add apply subcommand `git vendor apply [name]` rebuilds vendored files from the recorded upstream base per the current `.gitvendors` patterns, without fetching. Editing a vendor's pattern entries and running apply moves or refilters its files; the change is recorded as a single-parent commit. A vendor whose files carry local modifications is skipped with a list of the modified paths unless --force is given, since re-materializing discards them. Assisted-by: Claude:claude-fable-5[1m] --- crates/git-vendor/src/cli.rs | 20 +++++ crates/git-vendor/src/main.rs | 154 ++++++++++++++++++++++++++++++++++ 2 files changed, 174 insertions(+) diff --git a/crates/git-vendor/src/cli.rs b/crates/git-vendor/src/cli.rs index da499e5..2caaafa 100644 --- a/crates/git-vendor/src/cli.rs +++ b/crates/git-vendor/src/cli.rs @@ -75,6 +75,26 @@ pub enum Command { dry_run: bool, }, + /// Re-apply the configured patterns from the recorded upstream base. + /// + /// Rebuilds vendored files from `.gitvendors` without fetching. Use after + /// editing a vendor's `pattern` entries to move or refilter its files. + /// Local modifications to vendored files would be discarded, so the + /// command refuses to proceed on a modified vendor unless `--force` is + /// given. + Apply { + /// Vendor name to apply; applies all configured vendors if omitted. + name: Option, + + /// Commit message (defaults to `vendor: apply `). + #[arg(long, short = 'm', value_name = "MSG")] + message: Option, + + /// Discard local modifications to vendored files. + #[arg(long)] + force: bool, + }, + /// Show synchronization status for one or all vendors. Status { /// Vendor name to check; checks all configured vendors if omitted. diff --git a/crates/git-vendor/src/main.rs b/crates/git-vendor/src/main.rs index 1c93fca..3b086e9 100644 --- a/crates/git-vendor/src/main.rs +++ b/crates/git-vendor/src/main.rs @@ -40,6 +40,11 @@ fn run(cli: cli::Cli) -> Result<()> { force, dry_run, } => cmd_update(name, message, force, dry_run), + cli::Command::Apply { + name, + message, + force, + } => cmd_apply(name, message, force), cli::Command::Status { name, fetch } => cmd_status(name, fetch), cli::Command::Remove { name, keep_files } => cmd_remove(name, keep_files), cli::Command::List => cmd_list(), @@ -87,6 +92,36 @@ fn tree_paths(repo: &gix::Repository, tree_id: gix::ObjectId) -> Result Result> { + let index = repo.index_from_tree(&tree_id)?; + Ok(index + .entries() + .iter() + .map(|e| (e.path(&index).into(), e.id)) + .collect()) +} + +/// The `.gitvendors` config as committed at `commit`, or `None` if absent. +fn config_at(repo: &gix::Repository, commit: gix::ObjectId) -> Result> { + let tree = repo + .find_commit(commit) + .map_err(|e| format!("{e}"))? + .tree() + .map_err(|e| format!("{e}"))?; + let Some(entry) = tree + .lookup_entry_by_path(".gitvendors") + .map_err(|e| format!("{e}"))? + else { + return Ok(None); + }; + let blob = entry.object().map_err(|e| format!("{e}"))?; + let s = String::from_utf8_lossy(&blob.data).into_owned(); + Ok(Some(VendorConfig::parse(&s)?)) +} + fn advance_head(repo: &gix::Repository, new_commit: gix::ObjectId, msg: &str) -> Result<()> { use gix::refs::Target; use gix::refs::transaction::{Change, LogChange, PreviousValue, RefEdit, RefLog}; @@ -525,6 +560,125 @@ fn cmd_update( Ok(()) } +fn cmd_apply(name: Option, message: Option, force: bool) -> Result<()> { + let repo = discover()?; + let cfg_path = config_path(&repo)?; + let config = load_config(&cfg_path)?; + + let entries: Vec = match name { + Some(ref n) => vec![require_entry(&config, n)?], + None => config.entries()?, + }; + + if entries.is_empty() { + eprintln!("No vendors configured."); + return Ok(()); + } + + let head_oid = repo + .head_commit() + .map(|c| c.id().detach()) + .map_err(|e| format!("HEAD: {e}"))?; + + // Patterns as last committed, for the local-modification check: a vendor + // whose ours tree differs from the pristine upstream tree of its recorded + // base carries patches that re-materializing would discard. + let old_config = config_at(&repo, head_oid)?; + + let config_str = save_config(&config, &cfg_path)?; + let mut current_head = head_oid; + + for entry in entries { + let n = entry.name.as_str().to_owned(); + let Some(base) = entry.base else { + eprintln!("{n}: no recorded base; run `git vendor update {n}` first"); + continue; + }; + + let pristine = old_config + .as_ref() + .and_then(|c| c.get(&n).ok().flatten()) + .and_then(|old| old.base.map(|b| (old, b))) + .map(|(old, b)| repo.upstream_tree(&old, b)) + .transpose()?; + if let Some(pristine) = pristine { + let ours = repo.ours_tree(&entry, current_head)?; + if ours != pristine && !force { + let pristine_blobs = tree_blobs(&repo, pristine)?; + let our_blobs = tree_blobs(&repo, ours)?; + let mut modified: Vec = our_blobs + .iter() + .filter(|(p, oid)| pristine_blobs.get(*p) != Some(oid)) + .map(|(p, _)| p.to_string()) + .collect(); + modified.extend( + pristine_blobs + .keys() + .filter(|p| !our_blobs.contains_key(*p)) + .map(|p| p.to_string()), + ); + modified.sort(); + eprintln!( + "{n}: vendored files have local modifications ({}); \ + re-run with --force to discard them", + modified.join(", ") + ); + continue; + } + } + + let new_tree = repo.upstream_tree(&entry, base)?; + let old_paths: Vec = + repo.vendor_paths(&entry, current_head).unwrap_or_default(); + + let full_tree = repo.checkout_vendor(&entry, new_tree)?; + let new_paths = tree_paths(&repo, new_tree)?; + reconcile_tracked_paths(&repo, &entry, &old_paths, &new_paths)?; + + let attrs_blob = stage_attrs_blob(&repo)?; + let vendors_blob = stage_gitvendors(&repo, config_str.as_bytes())?; + let tree = final_tree(&repo, full_tree, attrs_blob, vendors_blob)?; + + let head_tree = repo + .find_commit(current_head) + .map_err(|e| format!("{e}"))? + .tree() + .map_err(|e| format!("{e}"))? + .id() + .detach(); + if tree == head_tree { + eprintln!("{n}: nothing to apply"); + continue; + } + + let msg = message + .clone() + .unwrap_or_else(|| format!("vendor: apply {n}")); + + // A single-parent commit: no upstream changed, so unlike add/update + // there is no merge edge to record. + let author = author_sig(&repo)?; + let committer = committer_sig(&repo)?; + let mut tbuf_a = gix::date::parse::TimeBuf::default(); + let mut tbuf_c = gix::date::parse::TimeBuf::default(); + let commit = gix::objs::Commit { + tree, + parents: [current_head].into_iter().collect(), + author: author.to_ref(&mut tbuf_a).into(), + committer: committer.to_ref(&mut tbuf_c).into(), + encoding: None, + message: msg.as_str().into(), + extra_headers: Vec::new(), + }; + let new_commit = repo.write_object(&commit)?.detach(); + advance_head(&repo, new_commit, &msg)?; + current_head = new_commit; + eprintln!("Applied {n}."); + } + + Ok(()) +} + fn cmd_status(name: Option, fetch: bool) -> Result<()> { let repo = discover()?; let cfg_path = config_path(&repo)?; From 9e1afb5f8a8f60bf80dd9f9edb4534e3d3ad9d66 Mon Sep 17 00:00:00 2001 From: "Joseph D. Carpinelli" Date: Fri, 12 Jun 2026 22:22:07 -0400 Subject: [PATCH 07/31] fix: read upstream OID from refmap to avoid HEAD-symref misresolution MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a non-bare local repo fetches with `+HEAD:refs/vendor/` and shares the upstream's default branch name, gix writes the vendor ref as a symbolic ref pointing to the local branch. Re-reading that ref via `peel_to_id()` returns the local HEAD instead of the upstream tip, and no upstream objects land in the odb. The refmap built during the fetch carries the upstream OID (via `Mapping::remote.peeled_id()`) before any local ref resolution, so reading from there sidesteps the issue entirely. The bare-only guard on the File transport scheme is also removed — it existed only because of this bug. Removes `#[should_panic]` from the pinning regression test added in 4c83407. Upstream: GitoxideLabs/gitoxide#2613. fix: read upstream OID from outcome.ref_map rather than local vendor ref fix: allow local (file://) transport for non-bare repos Assisted-by: Claude:claude-sonnet-4-6 --- crates/git-vendor/src/lib.rs | 40 ++++++++++++++----- crates/git-vendor/tests/fetch_vendor/table.rs | 4 -- 2 files changed, 30 insertions(+), 14 deletions(-) diff --git a/crates/git-vendor/src/lib.rs b/crates/git-vendor/src/lib.rs index 84ff3fd..cbf80a8 100644 --- a/crates/git-vendor/src/lib.rs +++ b/crates/git-vendor/src/lib.rs @@ -57,14 +57,7 @@ impl VendorRepository for gix::Repository { | gix::url::Scheme::Http | gix::url::Scheme::Ssh | gix::url::Scheme::Git => {} - gix::url::Scheme::File => { - if !self.is_bare() { - return Err(Error::InvalidUrl(format!( - "{}: refusing transport `{:?}`; local transports are not yet supported", - entry.url, url.scheme - ))); - } - } + gix::url::Scheme::File => {} ref other => { return Err(Error::InvalidUrl(format!( "{}: refusing transport `{other:?}`; plug-in transports are not supported", @@ -128,8 +121,35 @@ impl VendorRepository for gix::Repository { } } - let mut reference = self.find_reference(&entry.vendor_ref())?; - let id = reference.peel_to_id()?.detach(); + // Read the upstream OID from the refmap rather than by re-reading the + // local vendor ref. When the remote advertises HEAD as a symbolic ref + // (e.g. `HEAD → refs/heads/main`) and the local repo happens to have a + // branch of the same name, gix writes `refs/vendor/` as a symref + // pointing to that local branch; `peel_to_id()` would then silently + // return the *local* HEAD instead of the upstream tip. The refmap + // carries the actual upstream OID directly before any local ref + // resolution, so keying on it sidesteps the bug entirely. + // Upstream: https://github.com/GitoxideLabs/gitoxide/issues/2613 + let vendor_ref = entry.vendor_ref(); + let id = outcome + .ref_map + .mappings + .iter() + .find(|m| { + m.local + .as_deref() + .map(|l| l == vendor_ref.as_bytes()) + .unwrap_or(false) + }) + .and_then(|m| m.remote.peeled_id()) + .map(gix::oid::to_owned) + .ok_or_else(|| { + Error::Fetch(format!( + "remote has no ref matching `{}` for vendor `{}`", + entry.tracking_ref(), + entry.name + )) + })?; Ok(id) } diff --git a/crates/git-vendor/tests/fetch_vendor/table.rs b/crates/git-vendor/tests/fetch_vendor/table.rs index 14d6f3f..1fd8ec9 100644 --- a/crates/git-vendor/tests/fetch_vendor/table.rs +++ b/crates/git-vendor/tests/fetch_vendor/table.rs @@ -254,11 +254,7 @@ fn fetch_errors_when_ref_missing() { /// and is unaffected; this test pins the non-bare case so the regression is /// hard to reintroduce once fixed. /// -/// Marked `#[should_panic]` so CI passes while the upstream gix bug stands; -/// when the bug is fixed this flips to a regular failure and the attribute -/// is removed. #[test] -#[should_panic] fn fetch_returns_upstream_tip_into_non_bare_local() { let upstream = tempfile::tempdir().unwrap(); let local = tempfile::tempdir().unwrap(); From 4d0b22955f1ff735cf03f3c5ad10d9e539086e57 Mon Sep 17 00:00:00 2001 From: "Joseph D. Carpinelli" Date: Fri, 12 Jun 2026 22:59:46 -0400 Subject: [PATCH 08/31] feat: normalize --prefix and stage-only add fix: append trailing / to --prefix so it is always treated as a directory feat: `add` stages vendored files and sets MERGE_HEAD rather than auto-committing; user runs `git commit` to finalize Assisted-by: Claude:claude-sonnet-4-6 --- crates/git-vendor/src/main.rs | 45 +++++++++++++---------------------- 1 file changed, 17 insertions(+), 28 deletions(-) diff --git a/crates/git-vendor/src/main.rs b/crates/git-vendor/src/main.rs index 3b086e9..c496c90 100644 --- a/crates/git-vendor/src/main.rs +++ b/crates/git-vendor/src/main.rs @@ -356,7 +356,12 @@ fn cmd_add( VendorMode::default() }; let patterns: Vec = if patterns.is_empty() { - let dest = prefix.unwrap_or_else(|| format!("vendor/{name}/")); + let dest = prefix.map_or_else( + || format!("vendor/{name}/"), + |p| { + if p.ends_with('/') { p } else { format!("{p}/") } + }, + ); vec![format!("**:{dest}")] } else { patterns @@ -404,7 +409,7 @@ fn cmd_add( std::process::exit(1); } - let full_tree = repo.checkout_vendor(&entry, merge.result_tree)?; + let _full_tree = repo.checkout_vendor(&entry, merge.result_tree)?; let new_paths = tree_paths(&repo, merge.result_tree)?; reconcile_tracked_paths(&repo, &entry, &[], &new_paths)?; @@ -412,16 +417,16 @@ fn cmd_add( config.insert(&entry)?; let config_str = save_config(&config, &cfg_path)?; - let attrs_blob = stage_attrs_blob(&repo)?; - let vendors_blob = stage_gitvendors(&repo, config_str.as_bytes())?; - let tree = final_tree(&repo, full_tree, attrs_blob, vendors_blob)?; - commit_and_advance(&repo, &entry, &merge, tree, ours, &msg)?; - eprintln!("Added vendor {name}."); + stage_attrs_blob(&repo)?; + stage_gitvendors(&repo, config_str.as_bytes())?; + + repo.prepare_merge(&entry, &merge, &msg)?; + eprintln!("Staged; run `git commit` to complete."); } None => { // Unborn repository: make the initial commit directly (no merge). let tree = repo.upstream_tree(&entry, upstream)?; - let full_tree = repo.checkout_vendor(&entry, tree)?; + let _full_tree = repo.checkout_vendor(&entry, tree)?; let new_paths = tree_paths(&repo, tree)?; let path_refs: Vec<&gix::bstr::BStr> = new_paths.iter().map(|b| b.as_ref()).collect(); repo.track_vendor(&entry, &path_refs)?; @@ -430,26 +435,10 @@ fn cmd_add( config.insert(&entry)?; let config_str = save_config(&config, &cfg_path)?; - let attrs_blob = stage_attrs_blob(&repo)?; - let vendors_blob = stage_gitvendors(&repo, config_str.as_bytes())?; - let commit_tree = final_tree(&repo, full_tree, attrs_blob, vendors_blob)?; - - let author = author_sig(&repo)?; - let committer = committer_sig(&repo)?; - let mut tbuf_a = gix::date::parse::TimeBuf::default(); - let mut tbuf_c = gix::date::parse::TimeBuf::default(); - let commit = gix::objs::Commit { - tree: commit_tree, - parents: Default::default(), - author: author.to_ref(&mut tbuf_a).into(), - committer: committer.to_ref(&mut tbuf_c).into(), - encoding: None, - message: msg.as_str().into(), - extra_headers: Vec::new(), - }; - let new_commit = repo.write_object(&commit)?.detach(); - advance_head(&repo, new_commit, &msg)?; - eprintln!("Added vendor {name}."); + stage_attrs_blob(&repo)?; + stage_gitvendors(&repo, config_str.as_bytes())?; + + eprintln!("Staged; run `git commit` to complete."); } } From 43ff0d666cb435440c033ab061833eae54eb05b3 Mon Sep 17 00:00:00 2001 From: "Joseph D. Carpinelli" Date: Fri, 12 Jun 2026 23:01:21 -0400 Subject: [PATCH 09/31] fix: add missing TLS and `reqwest` features --- Cargo.lock | 1270 +++++++++++++++++++++++++++++++++++++++++++++++++++- Cargo.toml | 2 +- 2 files changed, 1256 insertions(+), 16 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index d8aa929..2518e98 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -53,7 +53,7 @@ version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" dependencies = [ - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -64,7 +64,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ "anstyle", "once_cell_polyfill", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -88,12 +88,46 @@ version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + [[package]] name = "autocfg" version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" +[[package]] +name = "aws-lc-rs" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ec2f1fc3ec205783a5da9a7e6c1509cc69dedf09a1949e412c1e18469326d00" +dependencies = [ + "aws-lc-sys", + "zeroize", +] + +[[package]] +name = "aws-lc-sys" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a2f9779ce85b93ab6170dd940ad0169b5766ff848247aff13bb788b832fe3f4" +dependencies = [ + "cc", + "cmake", + "dunce", + "fs_extra", +] + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + [[package]] name = "bit-set" version = "0.8.0" @@ -135,6 +169,12 @@ dependencies = [ "serde", ] +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + [[package]] name = "byteorder" version = "1.5.0" @@ -153,12 +193,30 @@ version = "2.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6bd91ee7b2422bcb158d90ef4d14f75ef67f340943fc4149891dcce8f8b972a3" +[[package]] +name = "cc" +version = "1.2.63" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "556e016178bb5662a08681bbe0f00f8e17631781a4dfc8c45e466e4b185ec27f" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex", +] + [[package]] name = "cfg-if" version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + [[package]] name = "clap" version = "4.5.60" @@ -218,12 +276,47 @@ dependencies = [ "hashbrown 0.16.1", ] +[[package]] +name = "cmake" +version = "0.1.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678" +dependencies = [ + "cc", +] + [[package]] name = "colorchoice" version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" +[[package]] +name = "combine" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" +dependencies = [ + "bytes", + "memchr", +] + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + [[package]] name = "cpufeatures" version = "0.2.17" @@ -291,6 +384,17 @@ dependencies = [ "crypto-common", ] +[[package]] +name = "displaydoc" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "dunce" version = "1.0.5" @@ -319,7 +423,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -348,6 +452,12 @@ dependencies = [ "libc", ] +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + [[package]] name = "fnv" version = "1.0.7" @@ -366,6 +476,21 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "fs_extra" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" + [[package]] name = "futures" version = "0.3.32" @@ -470,6 +595,19 @@ dependencies = [ "version_check", ] +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + [[package]] name = "getrandom" version = "0.3.4" @@ -477,9 +615,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" dependencies = [ "cfg-if", + "js-sys", "libc", "r-efi 5.3.0", "wasip2", + "wasm-bindgen", ] [[package]] @@ -1257,7 +1397,7 @@ dependencies = [ "bitflags", "gix-path", "libc", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -1338,13 +1478,16 @@ version = "0.57.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ffd6a5c676b92d4ead5f5a2b2935024415dec69edc997b6090ca9cac010a3018" dependencies = [ + "base64", "bstr", "gix-command", + "gix-credentials", "gix-features", "gix-packetline", "gix-quote", "gix-sec", "gix-url", + "reqwest", "thiserror", ] @@ -1457,6 +1600,25 @@ version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" +[[package]] +name = "h2" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "171fefbc92fe4a4de27e0698d6a5b392d6a0e333506bc49133760b3bcf948733" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + [[package]] name = "hash32" version = "0.3.1" @@ -1508,18 +1670,219 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "http" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6970f50e31d6fc17d3fa27329444bfa74e196cf62e95052a3f6fee181dba6425" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + [[package]] name = "human_format" version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eaec953f16e5bcf6b8a3cb3aa959b17e5577dbd2693e94554c462c08be22624b" +[[package]] +name = "hyper" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55281c53a1894c864990125767da440a4e630446785086f52523b20033b74498" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "h2", + "http", + "http-body", + "httparse", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "tokio", + "tokio-rustls", + "tower-service", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "base64", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", +] + +[[package]] +name = "icu_collections" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" + +[[package]] +name = "icu_properties" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" + +[[package]] +name = "icu_provider" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + [[package]] name = "id-arena" version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + [[package]] name = "indexmap" version = "2.13.0" @@ -1542,6 +1905,12 @@ dependencies = [ "winapi", ] +[[package]] +name = "ipnet" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" + [[package]] name = "is_terminal_polyfill" version = "1.70.2" @@ -1566,7 +1935,7 @@ dependencies = [ "portable-atomic", "portable-atomic-util", "serde_core", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -1595,6 +1964,76 @@ dependencies = [ "jiff-tzdb", ] +[[package]] +name = "jni" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" +dependencies = [ + "cfg-if", + "combine", + "jni-macros", + "jni-sys", + "log", + "simd_cesu8", + "thiserror", + "walkdir", + "windows-link", +] + +[[package]] +name = "jni-macros" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "simd_cesu8", + "syn", +] + +[[package]] +name = "jni-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn", +] + +[[package]] +name = "jobserver" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" +dependencies = [ + "getrandom 0.3.4", + "libc", +] + +[[package]] +name = "js-sys" +version = "0.3.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2025f20d7a4fa7785846e7b63d10a76d3f1cee98ee5cb79ea59703f95e42162" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + [[package]] name = "kstring" version = "2.0.2" @@ -1622,6 +2061,12 @@ version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" +[[package]] +name = "litemap" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" + [[package]] name = "lock_api" version = "0.4.14" @@ -1637,6 +2082,12 @@ version = "0.4.29" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + [[package]] name = "maybe-async" version = "0.2.11" @@ -1664,18 +2115,35 @@ dependencies = [ ] [[package]] -name = "nonempty" -version = "0.12.0" +name = "mime" +version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9737e026353e5cd0736f98eddae28665118eb6f6600902a7f50db585621fecb6" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" [[package]] -name = "num-traits" -version = "0.2.19" +name = "mio" +version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda" dependencies = [ - "autocfg", + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "nonempty" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9737e026353e5cd0736f98eddae28665118eb6f6600902a7f50db585621fecb6" + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", ] [[package]] @@ -1690,6 +2158,12 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + [[package]] name = "parking_lot" version = "0.12.5" @@ -1740,6 +2214,15 @@ dependencies = [ "portable-atomic", ] +[[package]] +name = "potential_utf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +dependencies = [ + "zerovec", +] + [[package]] name = "ppv-lite86" version = "0.2.21" @@ -1813,6 +2296,62 @@ version = "1.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0" +[[package]] +name = "quinn" +version = "0.11.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2", + "thiserror", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098" +dependencies = [ + "aws-lc-rs", + "bytes", + "getrandom 0.3.4", + "lru-slab", + "rand", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.60.2", +] + [[package]] name = "quote" version = "1.0.45" @@ -1916,6 +2455,60 @@ version = "1.9.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ba39f3699c378cd8970968dcbff9c43159ea4cfbd88d43c00b22f2ef10a435d2" +[[package]] +name = "reqwest" +version = "0.13.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "219c5811de6525e5416c7d5d53bb656d3afdbc6c5af816e0802bcfa42dbdc1c3" +dependencies = [ + "base64", + "bytes", + "encoding_rs", + "futures-channel", + "futures-core", + "futures-util", + "h2", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "mime", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "rustls-platform-verifier", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + [[package]] name = "roff" version = "0.2.2" @@ -1952,6 +2545,12 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "rustc-hash" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" + [[package]] name = "rustc_version" version = "0.4.1" @@ -1971,7 +2570,82 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls" +version = "0.23.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b" +dependencies = [ + "aws-lc-rs", + "once_cell", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-native-certs" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d" +dependencies = [ + "openssl-probe", + "rustls-pki-types", + "schannel", + "security-framework", +] + +[[package]] +name = "rustls-pki-types" +version = "1.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-platform-verifier" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d1e2536ce4f35f4846aa13bff16bd0ff40157cdb14cc056c7b14ba41233ba0" +dependencies = [ + "core-foundation", + "core-foundation-sys", + "jni", + "log", + "once_cell", + "rustls", + "rustls-native-certs", + "rustls-platform-verifier-android", + "rustls-webpki", + "security-framework", + "security-framework-sys", + "webpki-root-certs", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls-platform-verifier-android" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" + +[[package]] +name = "rustls-webpki" +version = "0.103.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +dependencies = [ + "aws-lc-rs", + "ring", + "rustls-pki-types", + "untrusted", ] [[package]] @@ -2001,12 +2675,44 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "schannel" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "scopeguard" version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags", + "core-foundation", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "semver" version = "1.0.27" @@ -2082,6 +2788,12 @@ version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dc6fe69c597f9c37bfeeeeeb33da3530379845f10be461a66d16d03eca2ded77" +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + [[package]] name = "signal-hook" version = "0.4.4" @@ -2102,6 +2814,22 @@ dependencies = [ "libc", ] +[[package]] +name = "simd_cesu8" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94f90157bb87cddf702797c5dadfa0be7d266cdf49e22da2fcaa32eff75b2c33" +dependencies = [ + "rustc_version", + "simdutf8", +] + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + [[package]] name = "slab" version = "0.4.12" @@ -2114,6 +2842,16 @@ version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +[[package]] +name = "socket2" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + [[package]] name = "stable_deref_trait" version = "1.2.1" @@ -2132,6 +2870,12 @@ version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + [[package]] name = "syn" version = "2.0.117" @@ -2143,6 +2887,26 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "tempfile" version = "3.26.0" @@ -2153,7 +2917,7 @@ dependencies = [ "getrandom 0.4.2", "once_cell", "rustix", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -2176,6 +2940,16 @@ dependencies = [ "syn", ] +[[package]] +name = "tinystr" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +dependencies = [ + "displaydoc", + "zerovec", +] + [[package]] name = "tinyvec" version = "1.11.0" @@ -2191,6 +2965,43 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" +[[package]] +name = "tokio" +version = "1.52.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "socket2", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-util" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + [[package]] name = "toml_datetime" version = "1.1.1+spec-1.1.0" @@ -2221,6 +3032,76 @@ dependencies = [ "winnow", ] +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "bitflags", + "bytes", + "futures-util", + "http", + "http-body", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", + "url", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-core", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + [[package]] name = "typenum" version = "1.20.0" @@ -2269,6 +3150,30 @@ version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + [[package]] name = "utf8parse" version = "0.2.2" @@ -2300,6 +3205,21 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + [[package]] name = "wasip2" version = "1.0.2+wasi-0.2.9" @@ -2318,6 +3238,61 @@ dependencies = [ "wit-bindgen", ] +[[package]] +name = "wasm-bindgen" +version = "0.2.123" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a254a4b10c19a76f09a27640e7ffbf9bc30bf67e16a3bf28aaefa4920fe81563" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.73" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "54568702fabf5d4849ce2b90fadfa64168a097eaf4b351ce9df8b687a0086aaf" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.123" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24a40fc75b0ec6f3746ceb10d36f53a93dcd68a93b11b6445983945d79eba0dc" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.123" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "908f34bd9b9ce3d4caf07b72dfab63d61504d156856c6bd3cd87fa350cf3985b" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.123" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7acbf7616c27b194bbb550bf77ed0c2c3e5b7fd1260a93082b95fb7f47959b92" +dependencies = [ + "unicode-ident", +] + [[package]] name = "wasm-encoder" version = "0.244.0" @@ -2352,6 +3327,35 @@ dependencies = [ "semver", ] +[[package]] +name = "web-sys" +version = "0.3.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e0871acf327f283dc6da28a1696cdc64fb355ba9f935d052021fa77f35cce69" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webpki-root-certs" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31141ce3fc3e300ae89b78c0dd67f9708061d1d2eda54b8209346fd6be9a92c" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "winapi" version = "0.3.9" @@ -2374,7 +3378,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -2389,6 +3393,24 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", +] + [[package]] name = "windows-sys" version = "0.61.2" @@ -2398,6 +3420,135 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + [[package]] name = "winnow" version = "1.0.3" @@ -2495,6 +3646,35 @@ dependencies = [ "wasmparser", ] +[[package]] +name = "writeable" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" + +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + [[package]] name = "zerocopy" version = "0.8.48" @@ -2515,6 +3695,66 @@ dependencies = [ "syn", ] +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" + +[[package]] +name = "zerotrie" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "zlib-rs" version = "0.6.3" diff --git a/Cargo.toml b/Cargo.toml index 167002b..31418a1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,7 +14,7 @@ missing_docs = "warn" [workspace.dependencies] clap = { version = "4.5.60", features = ["derive"] } clap_mangen = "0.2.31" -gix = { version = "0.83", features = ["attributes", "merge", "tree-editor", "blocking-network-client", "worktree-mutation"] } +gix = { version = "0.83", features = ["attributes", "merge", "tree-editor", "blocking-network-client", "worktree-mutation", "blocking-http-transport-reqwest-rust-tls"] } gix-glob = "0.26.0" gix-quote = "0.7" tempfile = "3" From 5d8ab0b7fa6407b94128d8541b42c303b6a5704b Mon Sep 17 00:00:00 2001 From: "Joseph D. Carpinelli" Date: Sat, 13 Jun 2026 10:37:15 -0400 Subject: [PATCH 10/31] fix: stage rewritten `.gitvendors` on `remove` `cmd_remove` wrote the updated `.gitvendors` to the working tree via `save_config` but never staged it, so the index kept pointing at the old blob that still listed the removed vendor; the user's next commit would re-record the deleted entry. Stage the rewritten config like the other mutating commands do. Assisted-by: Claude:claude-opus-4-8 --- crates/git-vendor/src/main.rs | 3 +- crates/git-vendor/tests/cli.rs | 4 ++ crates/git-vendor/tests/cli/remove.rs | 51 ++++++++++++++++++++++++++ crates/git-vendor/tests/support/mod.rs | 13 +++++++ 4 files changed, 70 insertions(+), 1 deletion(-) create mode 100644 crates/git-vendor/tests/cli.rs create mode 100644 crates/git-vendor/tests/cli/remove.rs diff --git a/crates/git-vendor/src/main.rs b/crates/git-vendor/src/main.rs index c496c90..4861d5f 100644 --- a/crates/git-vendor/src/main.rs +++ b/crates/git-vendor/src/main.rs @@ -742,7 +742,8 @@ fn cmd_remove(name: String, keep_files: bool) -> Result<()> { } config.remove(&name)?; - save_config(&config, &cfg_path)?; + let config_str = save_config(&config, &cfg_path)?; + stage_gitvendors(&repo, config_str.as_bytes())?; eprintln!("Removed vendor {name}."); Ok(()) } diff --git a/crates/git-vendor/tests/cli.rs b/crates/git-vendor/tests/cli.rs new file mode 100644 index 0000000..2871cb2 --- /dev/null +++ b/crates/git-vendor/tests/cli.rs @@ -0,0 +1,4 @@ +mod support; +mod cli { + mod remove; +} diff --git a/crates/git-vendor/tests/cli/remove.rs b/crates/git-vendor/tests/cli/remove.rs new file mode 100644 index 0000000..94bbd1c --- /dev/null +++ b/crates/git-vendor/tests/cli/remove.rs @@ -0,0 +1,51 @@ +//! End-to-end tests for `git-vendor remove`. + +use crate::support::{git, git_capture, init, make_upstream, vendor, write}; + +/// Run a vendor subcommand and assert it exited zero, surfacing stderr on +/// failure. +fn vendor_ok(args: &[&str], dir: &std::path::Path) { + let out = vendor(args, dir); + assert!( + out.status.success(), + "git-vendor {args:?} failed:\n{}", + String::from_utf8_lossy(&out.stderr), + ); +} + +/// `remove` must stage the rewritten `.gitvendors` so the user's next commit +/// records the deletion. Regression: `cmd_remove` wrote `.gitvendors` to the +/// working tree but never staged it, leaving the index pointing at the old +/// blob that still listed the removed vendor. +#[test] +fn remove_stages_updated_gitvendors() { + let upstream = tempfile::tempdir().unwrap(); + let local = tempfile::tempdir().unwrap(); + + make_upstream(upstream.path()); + + init(local.path()); + write(local.path(), "README", b"local\n"); + git(&["add", "-A"], local.path()); + git(&["commit", "-m", "init"], local.path()); + + let url = upstream.path().to_str().unwrap(); + vendor_ok(&["add", url, "mylib"], local.path()); + // `add` leaves the merge staged with MERGE_HEAD set; seal it. + git(&["commit", "-m", "vendor: add mylib"], local.path()); + + let committed = + String::from_utf8(git_capture(&["show", "HEAD:.gitvendors"], local.path())).unwrap(); + assert!( + committed.contains("mylib"), + "precondition: committed .gitvendors must list the vendor:\n{committed}", + ); + + vendor_ok(&["remove", "mylib"], local.path()); + + let staged = String::from_utf8(git_capture(&["show", ":.gitvendors"], local.path())).unwrap(); + assert!( + !staged.contains("mylib"), + "staged .gitvendors must not reference the removed vendor, but was:\n{staged}", + ); +} diff --git a/crates/git-vendor/tests/support/mod.rs b/crates/git-vendor/tests/support/mod.rs index f052956..c52025f 100644 --- a/crates/git-vendor/tests/support/mod.rs +++ b/crates/git-vendor/tests/support/mod.rs @@ -45,6 +45,19 @@ pub fn git_capture(args: &[&str], dir: &Path) -> Vec { output.stdout } +/// Run the compiled `git-vendor` binary in `dir` with isolated config and +/// return its captured [`Output`](std::process::Output) without asserting +/// success, so tests can inspect both exit status and streams. +pub fn vendor(args: &[&str], dir: &Path) -> std::process::Output { + std::process::Command::new(env!("CARGO_BIN_EXE_git-vendor")) + .args(args) + .current_dir(dir) + .env("GIT_CONFIG_NOSYSTEM", "1") + .env("GIT_CONFIG_GLOBAL", "/dev/null") + .output() + .expect("git-vendor") +} + /// `git rev-parse ` in `dir`, return the resolved OID. pub fn rev_parse(dir: &Path, rev: &str) -> gix::ObjectId { let out = git_capture(&["rev-parse", rev], dir); From 32a39d85ca3c23fb7e6352eee39ade0257065823 Mon Sep 17 00:00:00 2001 From: "Joseph D. Carpinelli" Date: Sat, 13 Jun 2026 10:39:01 -0400 Subject: [PATCH 11/31] fix: track vendor paths when `add`/`update` conflicts The conflict branches of `cmd_add` and `cmd_update` checked out the conflicted tree and prepared the merge but skipped `reconcile_tracked_paths`. A path introduced by the merge (e.g. a new upstream file landing next to a conflict) was therefore never written to `.gitattributes`, so after the user resolved and committed, later `status`/`update`/`remove` could not see it. The conflict is in file content, not in which paths exist, so the mapping can be recorded up front just as the clean path does. Assisted-by: Claude:claude-opus-4-8 --- crates/git-vendor/src/main.rs | 7 +++ crates/git-vendor/tests/cli.rs | 1 + crates/git-vendor/tests/cli/update.rs | 76 +++++++++++++++++++++++++++ 3 files changed, 84 insertions(+) create mode 100644 crates/git-vendor/tests/cli/update.rs diff --git a/crates/git-vendor/src/main.rs b/crates/git-vendor/src/main.rs index 4861d5f..621c543 100644 --- a/crates/git-vendor/src/main.rs +++ b/crates/git-vendor/src/main.rs @@ -396,6 +396,11 @@ fn cmd_add( if merge.has_conflicts() { repo.checkout_vendor_conflicted(&entry, &merge)?; + // Track the vendor paths now: the conflict markers, not the set + // of paths, are what the user resolves, so `.gitattributes` must + // record the mapping before they `git commit`. + let new_paths = tree_paths(&repo, merge.result_tree)?; + reconcile_tracked_paths(&repo, &entry, &[], &new_paths)?; // Record the config entry and set up MERGE_HEAD so the user's // `git commit` after resolution produces a proper merge commit. entry.base = Some(merge.upstream_commit); @@ -510,6 +515,8 @@ fn cmd_update( if merge.has_conflicts() { repo.checkout_vendor_conflicted(&entry, &merge)?; + let new_paths = tree_paths(&repo, merge.result_tree)?; + reconcile_tracked_paths(&repo, &entry, &old_paths, &new_paths)?; entry.base = Some(merge.upstream_commit); config.insert(&entry)?; let config_str = save_config(&config, &cfg_path)?; diff --git a/crates/git-vendor/tests/cli.rs b/crates/git-vendor/tests/cli.rs index 2871cb2..f653a71 100644 --- a/crates/git-vendor/tests/cli.rs +++ b/crates/git-vendor/tests/cli.rs @@ -1,4 +1,5 @@ mod support; mod cli { mod remove; + mod update; } diff --git a/crates/git-vendor/tests/cli/update.rs b/crates/git-vendor/tests/cli/update.rs new file mode 100644 index 0000000..df619b1 --- /dev/null +++ b/crates/git-vendor/tests/cli/update.rs @@ -0,0 +1,76 @@ +//! End-to-end tests for `git-vendor update`. + +use crate::support::{git, git_capture, init, vendor, write}; + +/// Run a vendor subcommand and assert it exited zero, surfacing stderr on +/// failure. +fn vendor_ok(args: &[&str], dir: &std::path::Path) { + let out = vendor(args, dir); + assert!( + out.status.success(), + "git-vendor {args:?} failed:\n{}", + String::from_utf8_lossy(&out.stderr), + ); +} + +/// A conflicting `update` must still record `.gitattributes` tracking for the +/// vendor paths it introduces, so that once the user resolves the conflict and +/// commits, later `status`/`update`/`remove` can still find the vendor's files. +/// +/// Regression: the conflict branch of `cmd_update` checked out the conflicted +/// tree and prepared the merge but skipped `reconcile_tracked_paths`, so a +/// newly vendored file landing alongside a conflict was never written to +/// `.gitattributes`. +#[test] +fn conflicting_update_tracks_new_paths() { + let upstream = tempfile::tempdir().unwrap(); + let local = tempfile::tempdir().unwrap(); + + // Upstream v1: a single file that the local side will diverge from. + init(upstream.path()); + write(upstream.path(), "hello.txt", b"v1\n"); + git(&["add", "-A"], upstream.path()); + git(&["commit", "-m", "v1"], upstream.path()); + + // Local: vendor it (default prefix vendor/mylib/) and seal the add. + init(local.path()); + write(local.path(), "README", b"local\n"); + git(&["add", "-A"], local.path()); + git(&["commit", "-m", "init"], local.path()); + + let url = upstream.path().to_str().unwrap(); + vendor_ok(&["add", url, "mylib"], local.path()); + git(&["commit", "-m", "vendor: add mylib"], local.path()); + + // Local patch: diverge the vendored file so the next update conflicts. + write(local.path(), "vendor/mylib/hello.txt", b"local\n"); + git(&["add", "-A"], local.path()); + git(&["commit", "-m", "local patch"], local.path()); + + // Upstream v2: change the same file (forcing a conflict) and add a brand + // new file (the path that must still get tracked). + write(upstream.path(), "hello.txt", b"v2\n"); + write(upstream.path(), "extra.txt", b"new\n"); + git(&["add", "-A"], upstream.path()); + git(&["commit", "-m", "v2"], upstream.path()); + + // Update must conflict (exit non-zero) and leave the merge in progress. + let out = vendor(&["update", "mylib"], local.path()); + assert!( + !out.status.success(), + "update should report the conflict via non-zero exit:\n{}", + String::from_utf8_lossy(&out.stderr), + ); + + // Resolve the conflict and complete the merge the user is told to make. + write(local.path(), "vendor/mylib/hello.txt", b"resolved\n"); + git(&["add", "vendor/mylib/hello.txt"], local.path()); + git(&["commit", "--no-edit"], local.path()); + + let attrs = + String::from_utf8(git_capture(&["show", "HEAD:.gitattributes"], local.path())).unwrap(); + assert!( + attrs.contains("vendor/mylib/extra.txt"), + "the newly vendored path must be tracked in .gitattributes, but was:\n{attrs}", + ); +} From 7a6f180ce8d74f55ccf6d1c98065ed25081b882e Mon Sep 17 00:00:00 2001 From: "Joseph D. Carpinelli" Date: Sat, 13 Jun 2026 10:41:57 -0400 Subject: [PATCH 12/31] refactor: clarify that `staged_attrs_blob` only reads the index MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The helper was named and documented as if it wrote `.gitattributes` ("Write `content` as a blob, upsert the index entry"), but `track_vendor` already stages the file and this only reads back the resulting blob OID. Rename it to `staged_attrs_blob`, correct the doc, and drop the two `cmd_add` calls that discarded the OID — they asserted a post-condition `track_vendor` already guarantees. Assisted-by: Claude:claude-opus-4-8 --- crates/git-vendor/src/main.rs | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/crates/git-vendor/src/main.rs b/crates/git-vendor/src/main.rs index 621c543..4dd9fb9 100644 --- a/crates/git-vendor/src/main.rs +++ b/crates/git-vendor/src/main.rs @@ -186,9 +186,13 @@ fn reconcile_tracked_paths( Ok(()) } -/// Write `content` as a blob, upsert the `.gitattributes` index entry, and -/// return the blob OID so callers can include it in a commit tree. -fn stage_attrs_blob(repo: &gix::Repository) -> Result { +/// Return the OID of the `.gitattributes` blob already staged in the index by +/// `track_vendor`, so callers can include it in a commit tree. +/// +/// Unlike `stage_gitvendors`, this writes nothing: `track_vendor` stages +/// `.gitattributes` as a working-copy side effect and this only reads the +/// resulting index entry back. +fn staged_attrs_blob(repo: &gix::Repository) -> Result { use gix::bstr::ByteSlice as _; let index = repo.open_index().map_err(|e| format!("{e}"))?; index @@ -422,7 +426,6 @@ fn cmd_add( config.insert(&entry)?; let config_str = save_config(&config, &cfg_path)?; - stage_attrs_blob(&repo)?; stage_gitvendors(&repo, config_str.as_bytes())?; repo.prepare_merge(&entry, &merge, &msg)?; @@ -440,7 +443,6 @@ fn cmd_add( config.insert(&entry)?; let config_str = save_config(&config, &cfg_path)?; - stage_attrs_blob(&repo)?; stage_gitvendors(&repo, config_str.as_bytes())?; eprintln!("Staged; run `git commit` to complete."); @@ -537,7 +539,7 @@ fn cmd_update( let config_str = save_config(&config, &cfg_path)?; if auto_commit { - let attrs_blob = stage_attrs_blob(&repo)?; + let attrs_blob = staged_attrs_blob(&repo)?; let vendors_blob = stage_gitvendors(&repo, config_str.as_bytes())?; let tree = final_tree(&repo, full_tree, attrs_blob, vendors_blob)?; commit_and_advance(&repo, &entry, &merge, tree, current_head, &msg)?; @@ -631,7 +633,7 @@ fn cmd_apply(name: Option, message: Option, force: bool) -> Resu let new_paths = tree_paths(&repo, new_tree)?; reconcile_tracked_paths(&repo, &entry, &old_paths, &new_paths)?; - let attrs_blob = stage_attrs_blob(&repo)?; + let attrs_blob = staged_attrs_blob(&repo)?; let vendors_blob = stage_gitvendors(&repo, config_str.as_bytes())?; let tree = final_tree(&repo, full_tree, attrs_blob, vendors_blob)?; From 245777b944dbac5e9de9b3c6f14988b9fe0478af Mon Sep 17 00:00:00 2001 From: "Joseph D. Carpinelli" Date: Sat, 13 Jun 2026 10:59:29 -0400 Subject: [PATCH 13/31] refactor: simplify `check_attr_pattern` special-character test Use `is_ascii_control()` for the C0-and-DEL range and drop the redundant tab arm, leaving only the four genuinely special characters in the `matches!` list. Assisted-by: Claude:claude-opus-4-8 --- crates/git-vendor/src/exe.rs | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/crates/git-vendor/src/exe.rs b/crates/git-vendor/src/exe.rs index ee2e411..02596eb 100644 --- a/crates/git-vendor/src/exe.rs +++ b/crates/git-vendor/src/exe.rs @@ -260,10 +260,8 @@ impl VendorWorktree for gix::Repository { /// `.gitattributes` (space, tab, `#`, `"`, `\`, or control characters). /// Git source paths from tree objects never contain these in practice. fn check_attr_pattern(path: &[u8]) -> Result<(), Error> { - if path - .iter() - .any(|&b| matches!(b, b' ' | b'\t' | b'#' | b'"' | b'\\' | 0..=31 | 127)) - { + let needs_quoting = |b: u8| b.is_ascii_control() || matches!(b, b' ' | b'#' | b'"' | b'\\'); + if path.iter().copied().any(needs_quoting) { return Err(Error::InvalidPath( String::from_utf8_lossy(path).into_owned(), )); From 6215e57293a6c1b558c73bf2fee2238fb18f382f Mon Sep 17 00:00:00 2001 From: "Joseph D. Carpinelli" Date: Sat, 13 Jun 2026 11:31:31 -0400 Subject: [PATCH 14/31] refactor: move command logic to binary executor with injectable IO Splits the binary into a minimal entry point and a dedicated executor module. All command logic migrates from free functions in `main.rs` to methods on `Executor` that accept `&mut Io`, enabling output capture for future tests and alternate frontends. The `VendorWorktree` impl and its helpers move from the library's `exe` module into `lib.rs` alongside `VendorRepository`, eliminating the library-side `exe` module. The worktree test file is renamed to `attr_tests.rs` to match its scope. refactor: move `VendorWorktree` impl from `exe.rs` into `lib.rs` refactor: introduce `Executor` struct and `Io` for injectable output refactor: replace `std::process::exit` on conflict with `ConflictExit` refactor: rename `exe_tests.rs` to `attr_tests.rs` Assisted-by: Claude:claude-sonnet-4-6 --- .../src/{exe_tests.rs => attr_tests.rs} | 0 crates/git-vendor/src/exe.rs | 987 +++++++++++++----- crates/git-vendor/src/lib.rs | 325 +++++- crates/git-vendor/src/main.rs | 771 +------------- 4 files changed, 1056 insertions(+), 1027 deletions(-) rename crates/git-vendor/src/{exe_tests.rs => attr_tests.rs} (100%) diff --git a/crates/git-vendor/src/exe_tests.rs b/crates/git-vendor/src/attr_tests.rs similarity index 100% rename from crates/git-vendor/src/exe_tests.rs rename to crates/git-vendor/src/attr_tests.rs diff --git a/crates/git-vendor/src/exe.rs b/crates/git-vendor/src/exe.rs index 02596eb..6685d54 100644 --- a/crates/git-vendor/src/exe.rs +++ b/crates/git-vendor/src/exe.rs @@ -1,335 +1,802 @@ -//! Working-copy projection layer: the side-effecting half of vendoring. -//! -//! Where [`VendorRepository`](crate::VendorRepository) is a pure -//! object-database algebra, every method here writes the index and working -//! tree (including tracked files like `.gitattributes`). It is the sole owner -//! of the one working copy and its ambient `HEAD`/index state, kept distinct -//! from the pure object-database operations. +use std::io::Write as _; +use std::path::{Path, PathBuf}; -use gix::bstr::{BStr, ByteSlice as _}; +use git_vendor::{ + PatternMapping, VendorConfig, VendorEntry, VendorMode, VendorName, VendorRepository, + VendorStatus, VendorWorktree, +}; -use crate::{Error, VendorEntry, VendorMerge, VendorMode, VendorRepository, VendorWorktree}; +use crate::cli; -impl VendorWorktree for gix::Repository { - fn checkout_vendor( - &self, - entry: &VendorEntry, - tree: gix::ObjectId, - ) -> Result { - // IMPORTANT - // This is the trust boundary where upstream content (carried verbatim - // through `upstream_tree`, including symlink and gitlink modes, - // mirroring git-subtree/submodule) reaches the working copy. Like - // core git's `verify_path`/checkout, projection MUST refuse to write - // through a symlinked leading path and reject `..`/absolute - // components — use gix-worktree's checked checkout, never naive - // `std::fs` writes. See the `upstream_tree` adversarial review (#5). - // NOTE - // Path-traversal safety (e.g. `../` components, symlinked leading - // paths) is delegated to gix and is not covered by automated tests. - let workdir = self.workdir().ok_or(Error::NoWorkdir)?; - - let head_id = self.head_commit().ok().map(|c| c.id().detach()); - - let old_paths: std::collections::BTreeSet = head_id - .and_then(|id| crate::resolve_vendor_paths(self, entry, id).ok()) - .into_iter() - .flatten() - .collect(); - - let mut vendor_index = self.index_from_tree(&tree)?; - - let new_paths: std::collections::BTreeSet = vendor_index - .entries() - .iter() - .map(|e| e.path(&vendor_index).to_owned()) - .collect(); - - let opts = self - .checkout_options(gix::worktree::stack::state::attributes::Source::IdMapping) - .map_err(|e| Error::Gix(Box::new(e)))?; - let progress = gix::progress::Discard; - gix::worktree::state::checkout( - &mut vendor_index, - workdir, - self.objects.clone().into_arc().map_err(Error::Io)?, - &progress, - &progress, - &gix::interrupt::IS_INTERRUPTED, - gix::worktree::state::checkout::Options { - overwrite_existing: true, - ..opts - }, - ) - .map_err(|e| Error::Gix(Box::new(e)))?; +type Result> = std::result::Result; - for removed in old_paths.difference(&new_paths) { - let abs = workdir.join(gix::path::from_bstr(removed).as_ref()); - if abs.symlink_metadata().is_ok() { - std::fs::remove_file(&abs)?; - } - } +pub struct Io { + pub out: Box, + pub err: Box, +} - // Overlay the vendor tree onto the full HEAD tree and rebuild the index - // from the result. An unborn HEAD has no base commit, so the vendor tree - // is itself the whole tree. - let full_tree = match head_id { - Some(id) => self.vendor_overlay(entry, id, tree)?, - None => tree, - }; - let mut main_index = self.index_from_tree(&full_tree)?; - main_index.set_path(self.git_dir().join("index")); - - // `index_from_tree` zeroes stat data; carry over the stats checkout just - // populated on the vendor entries so `git status` need not re-hash them. - let vendor_stats: std::collections::HashMap = - vendor_index - .entries() - .iter() - .map(|e| (e.path(&vendor_index).to_owned(), e.stat)) - .collect(); - for (e, path) in main_index.entries_mut_with_paths() { - if let Some(stat) = vendor_stats.get(path) { - e.stat = *stat; - } +impl Io { + pub fn stdio() -> Self { + Io { + out: Box::new(std::io::stdout()), + err: Box::new(std::io::stderr()), } + } +} - main_index - .write(gix::index::write::Options::default()) - .map_err(|e| Error::Gix(Box::new(e)))?; +#[derive(Debug)] +pub struct ConflictExit; - Ok(full_tree) +impl std::fmt::Display for ConflictExit { + fn fmt(&self, _f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + Ok(()) } +} - fn checkout_vendor_conflicted( - &self, - entry: &VendorEntry, - merge: &VendorMerge, - ) -> Result<(), Error> { - use gix::bstr::ByteSlice as _; - - // Write the result tree (with conflict markers) to the working copy. - self.checkout_vendor(entry, merge.result_tree)?; - - // Reopen the index we just wrote so we can splice in unmerged stages. - let mut main_index = self.open_index().map_err(|e| Error::Gix(Box::new(e)))?; - main_index.set_path(self.git_dir().join("index")); - - for conflict in &merge.conflicts { - let path_bytes = gix::bstr::BString::from(conflict.path.as_bytes()); - let path_bstr = path_bytes.as_bstr(); - - // Remove the stage-0 entry for this path. - main_index.remove_entries(|_, p, _| p == path_bstr); - - // Insert stage 1/2/3 entries for each present stage. - for (stage_idx, stage_variant) in [ - (0usize, gix::index::entry::Stage::Base), - (1usize, gix::index::entry::Stage::Ours), - (2usize, gix::index::entry::Stage::Theirs), - ] { - if let Some((tree_mode, oid)) = conflict.stages[stage_idx] { - let mode = gix::index::entry::Mode::from(tree_mode); - let flags = gix::index::entry::Flags::from_stage(stage_variant); - main_index.dangerously_push_entry( - gix::index::entry::Stat::default(), - oid, - flags, - mode, - path_bstr, - ); - } - } - } +impl std::error::Error for ConflictExit {} - main_index.sort_entries(); - main_index - .write(gix::index::write::Options::default()) - .map_err(|e| Error::Gix(Box::new(e)))?; +pub struct Executor(pub gix::Repository); - Ok(()) +impl Executor { + pub fn discover() -> Result { + Ok(Self(gix::discover(".")?)) } - fn track_vendor(&self, entry: &VendorEntry, paths: &[&BStr]) -> Result<(), Error> { - let workdir = self.workdir().ok_or(Error::NoWorkdir)?; - let gitattributes = workdir.join(".gitattributes"); + pub fn run(&self, cli: cli::Cli, io: &mut Io) -> Result<()> { + match cli.command { + cli::Command::Add { + url, + name, + ref_name, + prefix, + patterns, + squash, + dry_run, + message, + } => self.add( + name, url, ref_name, prefix, patterns, squash, dry_run, message, io, + ), + cli::Command::Update { + name, + message, + force, + dry_run, + } => self.update(name, message, force, dry_run, io), + cli::Command::Apply { + name, + message, + force, + } => self.apply(name, message, force, io), + cli::Command::Status { name, fetch } => self.status(name, fetch, io), + cli::Command::Remove { name, keep_files } => self.remove(name, keep_files, io), + cli::Command::List => self.list(io), + } + } - let existing: Vec = if gitattributes.exists() { - std::fs::read(&gitattributes)? + #[allow(clippy::too_many_arguments)] + fn add( + &self, + name: Option, + url: String, + ref_name: Option, + prefix: Option, + patterns: Vec, + squash: bool, + dry_run: bool, + message: Option, + io: &mut Io, + ) -> Result<()> { + let repo = &self.0; + let cfg_path = config_path(repo)?; + let mut config = load_config(&cfg_path)?; + + let name = match name { + Some(n) => n, + None => name_from_url(&url).ok_or_else(|| { + format!("cannot derive a vendor name from URL {url:?}; pass a name explicitly") + })?, + }; + let vendor_name = VendorName::new(&name)?; + let mode = if squash { + VendorMode::Squash + } else { + VendorMode::default() + }; + let patterns: Vec = if patterns.is_empty() { + let dest = prefix.map_or_else( + || format!("vendor/{name}/"), + |p| { + if p.ends_with('/') { p } else { format!("{p}/") } + }, + ); + vec![format!("**:{dest}")] } else { - Vec::new() + patterns + }; + let mut entry = VendorEntry { + name: vendor_name, + url, + ref_name, + base: None, + patterns: patterns.iter().map(|p| PatternMapping::parse(p)).collect(), + mode, }; - let attr_value = format!("vendor={}", entry.name.as_str()); - let attr_bytes = attr_value.as_bytes(); + // Fetch before touching config — a failed fetch leaves no side effects. + writeln!(io.err, "Fetching {name}…")?; + let upstream = repo.fetch_vendor(&entry)?; - for path in paths { - check_attr_pattern(path.as_bytes())?; + if dry_run { + writeln!(io.err, "Would add vendor {name} at {upstream}.")?; + return Ok(()); } - let already_tracked: std::collections::HashSet> = existing - .lines() - .filter_map(|line| { - let (pattern, attr) = split_attr_line(line)?; - if attr == attr_bytes { - Some(pattern.into_owned()) - } else { - None + let head_oid = repo.head_commit().ok().map(|c| c.id().detach()); + + let msg = message + .clone() + .unwrap_or_else(|| format!("vendor: add {name}")); + + match head_oid { + Some(ours) => { + let merge = repo.merge_vendor(&entry, ours, upstream)?; + + if merge.has_conflicts() { + repo.checkout_vendor_conflicted(&entry, &merge)?; + let new_paths = tree_paths(repo, merge.result_tree)?; + reconcile_tracked_paths(repo, &entry, &[], &new_paths)?; + entry.base = Some(merge.upstream_commit); + config.insert(&entry)?; + let config_str = save_config(&config, &cfg_path)?; + stage_gitvendors(repo, config_str.as_bytes())?; + repo.prepare_merge(&entry, &merge, &msg)?; + let paths: Vec<_> = merge.conflicts.iter().map(|c| c.path.as_str()).collect(); + writeln!(io.err, "Conflict in: {}", paths.join(", "))?; + writeln!(io.err, "Resolve conflicts, then run `git commit`.")?; + return Err(ConflictExit.into()); } - }) - .collect(); - let mut out = existing.clone(); - if !out.is_empty() && out.last() != Some(&b'\n') { - out.push(b'\n'); - } - for path in paths { - if !already_tracked.contains(path.as_bytes()) { - out.extend_from_slice(path.as_bytes()); - out.push(b' '); - out.extend_from_slice(attr_bytes); - out.push(b'\n'); + let _full_tree = repo.checkout_vendor(&entry, merge.result_tree)?; + let new_paths = tree_paths(repo, merge.result_tree)?; + reconcile_tracked_paths(repo, &entry, &[], &new_paths)?; + + entry.base = Some(merge.upstream_commit); + config.insert(&entry)?; + let config_str = save_config(&config, &cfg_path)?; + + stage_gitvendors(repo, config_str.as_bytes())?; + repo.prepare_merge(&entry, &merge, &msg)?; + writeln!(io.err, "Staged; run `git commit` to complete.")?; } + None => { + let tree = repo.upstream_tree(&entry, upstream)?; + let _full_tree = repo.checkout_vendor(&entry, tree)?; + let new_paths = tree_paths(repo, tree)?; + let path_refs: Vec<&gix::bstr::BStr> = + new_paths.iter().map(|b| b.as_ref()).collect(); + repo.track_vendor(&entry, &path_refs)?; + + entry.base = Some(upstream); + config.insert(&entry)?; + let config_str = save_config(&config, &cfg_path)?; + + stage_gitvendors(repo, config_str.as_bytes())?; + writeln!(io.err, "Staged; run `git commit` to complete.")?; + } + } + + Ok(()) + } + + fn update( + &self, + name: Option, + message: Option, + force: bool, + dry_run: bool, + io: &mut Io, + ) -> Result<()> { + let repo = &self.0; + let cfg_path = config_path(repo)?; + let mut config = load_config(&cfg_path)?; + + // Multi-vendor updates always auto-commit (one commit per vendor); only a + // single-vendor update without -m uses the prepare-merge path. + let auto_commit = name.is_none() || message.is_some(); + + let entries: Vec = match name { + Some(ref n) => vec![require_entry(&config, n)?], + None => config.entries()?, + }; + + if entries.is_empty() { + writeln!(io.err, "No vendors configured.")?; + return Ok(()); } - if out != existing { - std::fs::write(&gitattributes, &out)?; + let head_oid = repo + .head_commit() + .map(|c| c.id().detach()) + .map_err(|e| format!("HEAD: {e}"))?; + + let mut current_head = head_oid; + + for mut entry in entries { + let n = entry.name.as_str().to_owned(); + writeln!(io.err, "Fetching {n}…")?; + let upstream = repo.fetch_vendor(&entry)?; + + let status = repo.vendor_status(&entry)?; + match status { + VendorStatus::UpToDate => { + writeln!(io.err, "{n}: already up to date")?; + continue; + } + VendorStatus::ForcePushed { .. } if !force => { + writeln!( + io.err, + "{n}: upstream was force-pushed; re-run with --force to accept" + )?; + continue; + } + _ => {} + } + + if dry_run { + writeln!(io.err, "Would update {n} to {upstream}.")?; + continue; + } + + let msg = message + .clone() + .unwrap_or_else(|| format!("vendor: update {n}")); + + let old_paths: Vec = + repo.vendor_paths(&entry, current_head).unwrap_or_default(); + + let merge = repo.merge_vendor(&entry, current_head, upstream)?; + + if merge.has_conflicts() { + repo.checkout_vendor_conflicted(&entry, &merge)?; + let new_paths = tree_paths(repo, merge.result_tree)?; + reconcile_tracked_paths(repo, &entry, &old_paths, &new_paths)?; + entry.base = Some(merge.upstream_commit); + config.insert(&entry)?; + let config_str = save_config(&config, &cfg_path)?; + stage_gitvendors(repo, config_str.as_bytes())?; + repo.prepare_merge(&entry, &merge, &msg)?; + let paths: Vec<_> = merge.conflicts.iter().map(|c| c.path.as_str()).collect(); + writeln!(io.err, "{n}: conflict in {}", paths.join(", "))?; + writeln!(io.err, "Resolve conflicts, then run `git commit`.")?; + return Err(ConflictExit.into()); + } + + let full_tree = repo.checkout_vendor(&entry, merge.result_tree)?; + let new_paths = tree_paths(repo, merge.result_tree)?; + reconcile_tracked_paths(repo, &entry, &old_paths, &new_paths)?; + + entry.base = Some(merge.upstream_commit); + config.insert(&entry)?; + let config_str = save_config(&config, &cfg_path)?; + + if auto_commit { + let attrs_blob = staged_attrs_blob(repo)?; + let vendors_blob = stage_gitvendors(repo, config_str.as_bytes())?; + let tree = final_tree(repo, full_tree, attrs_blob, vendors_blob)?; + commit_and_advance(repo, &entry, &merge, tree, current_head, &msg)?; + current_head = repo + .head_commit() + .map(|c| c.id().detach()) + .map_err(|e| format!("HEAD after commit: {e}"))?; + writeln!(io.err, "Updated {n}.")?; + } else { + stage_gitvendors(repo, config_str.as_bytes())?; + repo.prepare_merge(&entry, &merge, &msg)?; + writeln!(io.err, "Updated {n}. Run `git commit` to record the merge.")?; + } } - stage_gitattributes(self, &out)?; Ok(()) } - fn untrack_vendor(&self, entry: &VendorEntry, paths: &[&BStr]) -> Result<(), Error> { - let workdir = self.workdir().ok_or(Error::NoWorkdir)?; - let gitattributes = workdir.join(".gitattributes"); + fn apply( + &self, + name: Option, + message: Option, + force: bool, + io: &mut Io, + ) -> Result<()> { + let repo = &self.0; + let cfg_path = config_path(repo)?; + let config = load_config(&cfg_path)?; + + let entries: Vec = match name { + Some(ref n) => vec![require_entry(&config, n)?], + None => config.entries()?, + }; - if !gitattributes.exists() { + if entries.is_empty() { + writeln!(io.err, "No vendors configured.")?; return Ok(()); } - let existing: Vec = std::fs::read(&gitattributes)?; - let attr_value = format!("vendor={}", entry.name.as_str()); - let attr_bytes = attr_value.as_bytes(); + let head_oid = repo + .head_commit() + .map(|c| c.id().detach()) + .map_err(|e| format!("HEAD: {e}"))?; + + // Patterns as last committed, for the local-modification check: a vendor + // whose ours tree differs from the pristine upstream tree of its recorded + // base carries patches that re-materializing would discard. + let old_config = config_at(repo, head_oid)?; + + let config_str = save_config(&config, &cfg_path)?; + let mut current_head = head_oid; + + for entry in entries { + let n = entry.name.as_str().to_owned(); + let Some(base) = entry.base else { + writeln!( + io.err, + "{n}: no recorded base; run `git vendor update {n}` first" + )?; + continue; + }; + + let pristine = old_config + .as_ref() + .and_then(|c| c.get(&n).ok().flatten()) + .and_then(|old| old.base.map(|b| (old, b))) + .map(|(old, b)| repo.upstream_tree(&old, b)) + .transpose()?; + if let Some(pristine) = pristine { + let ours = repo.ours_tree(&entry, current_head)?; + if ours != pristine && !force { + let pristine_blobs = tree_blobs(repo, pristine)?; + let our_blobs = tree_blobs(repo, ours)?; + let mut modified: Vec = our_blobs + .iter() + .filter(|(p, oid)| pristine_blobs.get(*p) != Some(oid)) + .map(|(p, _)| p.to_string()) + .collect(); + modified.extend( + pristine_blobs + .keys() + .filter(|p| !our_blobs.contains_key(*p)) + .map(|p| p.to_string()), + ); + modified.sort(); + writeln!( + io.err, + "{n}: vendored files have local modifications ({}); \ + re-run with --force to discard them", + modified.join(", ") + )?; + continue; + } + } - let remove: std::collections::HashSet<&[u8]> = paths.iter().map(|b| b.as_bytes()).collect(); + let new_tree = repo.upstream_tree(&entry, base)?; + let old_paths: Vec = + repo.vendor_paths(&entry, current_head).unwrap_or_default(); + + let full_tree = repo.checkout_vendor(&entry, new_tree)?; + let new_paths = tree_paths(repo, new_tree)?; + reconcile_tracked_paths(repo, &entry, &old_paths, &new_paths)?; + + let attrs_blob = staged_attrs_blob(repo)?; + let vendors_blob = stage_gitvendors(repo, config_str.as_bytes())?; + let tree = final_tree(repo, full_tree, attrs_blob, vendors_blob)?; + + let head_tree = repo + .find_commit(current_head) + .map_err(|e| format!("{e}"))? + .tree() + .map_err(|e| format!("{e}"))? + .id() + .detach(); + if tree == head_tree { + writeln!(io.err, "{n}: nothing to apply")?; + continue; + } - let mut filtered: Vec = Vec::with_capacity(existing.len()); - for line in existing.lines() { - let keep = match split_attr_line(line) { - Some((pattern, attr)) => !(attr == attr_bytes && remove.contains(pattern.as_ref())), - None => true, + let msg = message + .clone() + .unwrap_or_else(|| format!("vendor: apply {n}")); + + // A single-parent commit: no upstream changed, so unlike add/update + // there is no merge edge to record. + let author = author_sig(repo)?; + let committer = committer_sig(repo)?; + let mut tbuf_a = gix::date::parse::TimeBuf::default(); + let mut tbuf_c = gix::date::parse::TimeBuf::default(); + let commit = gix::objs::Commit { + tree, + parents: [current_head].into_iter().collect(), + author: author.to_ref(&mut tbuf_a).into(), + committer: committer.to_ref(&mut tbuf_c).into(), + encoding: None, + message: msg.as_str().into(), + extra_headers: Vec::new(), }; - if keep { - filtered.extend_from_slice(line); - filtered.push(b'\n'); + let new_commit = repo.write_object(&commit)?.detach(); + advance_head(repo, new_commit, &msg)?; + current_head = new_commit; + writeln!(io.err, "Applied {n}.")?; + } + + Ok(()) + } + + fn status(&self, name: Option, fetch: bool, io: &mut Io) -> Result<()> { + let repo = &self.0; + let cfg_path = config_path(repo)?; + let config = load_config(&cfg_path)?; + + let entries: Vec = match name { + Some(ref n) => vec![require_entry(&config, n)?], + None => config.entries()?, + }; + + if entries.is_empty() { + writeln!(io.err, "No vendors configured.")?; + return Ok(()); + } + + for entry in &entries { + if fetch { + repo.fetch_vendor(entry)?; } + let status = repo.vendor_status(entry)?; + let label = match &status { + VendorStatus::NotFetched => "not fetched".to_owned(), + VendorStatus::UpToDate => "up to date".to_owned(), + VendorStatus::UpdateAvailable { upstream } => { + format!("update available ({})", upstream.to_hex()) + } + VendorStatus::ForcePushed { upstream } => { + format!("force-pushed upstream ({})", upstream.to_hex()) + } + }; + writeln!(io.out, "{}\t{}\t{label}", entry.name, entry.url)?; } - if filtered != existing { - std::fs::write(&gitattributes, &filtered)?; + Ok(()) + } + + fn remove(&self, name: String, keep_files: bool, io: &mut Io) -> Result<()> { + let repo = &self.0; + let cfg_path = config_path(repo)?; + let mut config = load_config(&cfg_path)?; + + let entry = require_entry(&config, &name)?; + + if !keep_files { + let head_oid = repo.head_commit().ok().map(|c| c.id().detach()); + + if let Some(oid) = head_oid { + use gix::bstr::ByteSlice as _; + let workdir = repo.workdir().ok_or("not a working-copy repository")?; + let paths = repo.vendor_paths(&entry, oid)?; + for p in &paths { + let abs = workdir.join(gix::path::from_bstr(p).as_ref()); + if abs.symlink_metadata().is_ok() { + std::fs::remove_file(&abs)?; + } + } + let path_refs: Vec<&gix::bstr::BStr> = paths.iter().map(|b| b.as_ref()).collect(); + repo.untrack_vendor(&entry, &path_refs)?; + + let mut index = repo.open_index().map_err(|e| format!("{e}"))?; + for p in &path_refs { + let pb = p.as_bytes(); + index.remove_entries(|_, path, _| path == pb.as_bstr()); + } + index.sort_entries(); + index + .write(gix::index::write::Options::default()) + .map_err(|e| format!("{e}"))?; + } } - stage_gitattributes(self, &filtered)?; + config.remove(&name)?; + let config_str = save_config(&config, &cfg_path)?; + stage_gitvendors(repo, config_str.as_bytes())?; + writeln!(io.err, "Removed vendor {name}.")?; Ok(()) } - fn prepare_merge( - &self, - entry: &VendorEntry, - merge: &VendorMerge, - message: &str, - ) -> Result<(), Error> { - let git_dir = self.git_dir(); - if entry.mode == VendorMode::Squash { - std::fs::write(git_dir.join("SQUASH_MSG"), message.as_bytes())?; - } else { - std::fs::write( - git_dir.join("MERGE_HEAD"), - format!("{}\n", merge.upstream_commit), + fn list(&self, io: &mut Io) -> Result<()> { + let repo = &self.0; + let cfg_path = config_path(repo)?; + let config = load_config(&cfg_path)?; + let entries = config.entries()?; + + if entries.is_empty() { + writeln!(io.err, "No vendors configured.")?; + return Ok(()); + } + + for entry in &entries { + let ref_label = entry.ref_name.as_deref().unwrap_or("HEAD"); + let mode_label = entry.mode.as_str(); + writeln!( + io.out, + "{}\t{}\t{ref_label}\t{mode_label}", + entry.name, entry.url )?; - std::fs::write(git_dir.join("MERGE_MSG"), message.as_bytes())?; } + Ok(()) } } // ── helpers ────────────────────────────────────────────────────────────────── -/// Return `Err` if `path` contains characters that require C-style quoting in -/// `.gitattributes` (space, tab, `#`, `"`, `\`, or control characters). -/// Git source paths from tree objects never contain these in practice. -fn check_attr_pattern(path: &[u8]) -> Result<(), Error> { - let needs_quoting = |b: u8| b.is_ascii_control() || matches!(b, b' ' | b'#' | b'"' | b'\\'); - if path.iter().copied().any(needs_quoting) { - return Err(Error::InvalidPath( - String::from_utf8_lossy(path).into_owned(), - )); - } - Ok(()) +fn config_path(repo: &gix::Repository) -> Result { + let workdir = repo.workdir().ok_or("not a working-copy repository")?; + Ok(workdir.join(".gitvendors")) } -/// Parse one `.gitattributes` line into `(unquoted_pattern, trimmed_attrs)`. -/// -/// Returns `None` for blank lines, comment lines, or lines with no attribute -/// separator. Handles both plain and C-style-quoted patterns using -/// [`gix_quote::ansi_c::undo`]. -fn split_attr_line(line: &[u8]) -> Option<(std::borrow::Cow<'_, [u8]>, &[u8])> { - if line.is_empty() || line[0] == b'#' { - return None; - } - if line.starts_with(b"\"") { - let (pattern, consumed) = gix_quote::ansi_c::undo(line.as_bstr()).ok()?; - let rest = line.get(consumed..)?; - if rest.first().is_some_and(|&b| b == b' ' || b == b'\t') { - let owned: Vec = pattern.as_ref().to_vec(); - Some((std::borrow::Cow::Owned(owned), rest[1..].trim())) - } else { - None - } +fn load_config(path: &Path) -> Result { + if path.exists() { + Ok(VendorConfig::open(path)?) } else { - let pos = line.iter().position(|&b| b == b' ' || b == b'\t')?; - if pos == 0 { - return None; // leading whitespace — no valid pattern before the separator - } - Some(( - std::borrow::Cow::Borrowed(&line[..pos]), - line[pos + 1..].trim(), - )) + Ok(VendorConfig::parse("")?) } } -#[cfg(test)] -#[path = "exe_tests.rs"] -mod tests; +/// Write `config` to `path` and return the serialized bytes for blob staging. +fn save_config(config: &VendorConfig, path: &Path) -> Result { + let s = config.to_string(); + std::fs::write(path, &s)?; + Ok(s) +} + +fn require_entry(config: &VendorConfig, name: &str) -> Result { + config + .get(name)? + .ok_or_else(|| format!("no vendor named {name:?}").into()) +} + +fn tree_paths(repo: &gix::Repository, tree_id: gix::ObjectId) -> Result> { + let index = repo.index_from_tree(&tree_id)?; + Ok(index + .entries() + .iter() + .map(|e| e.path(&index).into()) + .collect()) +} + +fn tree_blobs( + repo: &gix::Repository, + tree_id: gix::ObjectId, +) -> Result> { + let index = repo.index_from_tree(&tree_id)?; + Ok(index + .entries() + .iter() + .map(|e| (e.path(&index).into(), e.id)) + .collect()) +} -/// Write `content` as a blob into the object database and upsert the -/// `.gitattributes` index entry to point at it. +fn config_at(repo: &gix::Repository, commit: gix::ObjectId) -> Result> { + let tree = repo + .find_commit(commit) + .map_err(|e| format!("{e}"))? + .tree() + .map_err(|e| format!("{e}"))?; + let Some(entry) = tree + .lookup_entry_by_path(".gitvendors") + .map_err(|e| format!("{e}"))? + else { + return Ok(None); + }; + let blob = entry.object().map_err(|e| format!("{e}"))?; + let s = String::from_utf8_lossy(&blob.data).into_owned(); + Ok(Some(VendorConfig::parse(&s)?)) +} + +fn advance_head(repo: &gix::Repository, new_commit: gix::ObjectId, msg: &str) -> Result<()> { + use gix::refs::Target; + use gix::refs::transaction::{Change, LogChange, PreviousValue, RefEdit, RefLog}; + + let name: gix::refs::FullName = "HEAD".try_into()?; + repo.edit_references([RefEdit { + change: Change::Update { + log: LogChange { + mode: RefLog::AndReference, + force_create_reflog: false, + message: msg.as_bytes().into(), + }, + expected: PreviousValue::Any, + new: Target::Object(new_commit), + }, + name, + deref: true, + }])?; + Ok(()) +} + +fn committer_sig(repo: &gix::Repository) -> Result { + let sig_ref = repo + .committer() + .ok_or("no committer identity; set user.name and user.email")? + .map_err(|e| format!("committer: {e}"))?; + sig_ref + .to_owned() + .map_err(|e| format!("committer time: {e}").into()) +} + +fn author_sig(repo: &gix::Repository) -> Result { + let sig_ref = repo + .author() + .ok_or("no author identity; set user.name and user.email")? + .map_err(|e| format!("author: {e}"))?; + sig_ref + .to_owned() + .map_err(|e| format!("author time: {e}").into()) +} + +fn reconcile_tracked_paths( + repo: &gix::Repository, + entry: &VendorEntry, + old_paths: &[gix::bstr::BString], + new_paths: &[gix::bstr::BString], +) -> Result<()> { + use gix::bstr::BStr; + let track: Vec<&BStr> = new_paths.iter().map(|b| b.as_ref()).collect(); + repo.track_vendor(entry, &track)?; + + let new_set: std::collections::HashSet<&[u8]> = + new_paths.iter().map(|b| b.as_slice()).collect(); + let removed: Vec<&BStr> = old_paths + .iter() + .filter(|b| !new_set.contains(b.as_slice())) + .map(|b| b.as_ref()) + .collect(); + if !removed.is_empty() { + repo.untrack_vendor(entry, &removed)?; + } + Ok(()) +} + +/// Return the OID of the `.gitattributes` blob already staged in the index by +/// `track_vendor`, so callers can include it in a commit tree. /// -/// This exists because [`VendorWorktree::track_vendor`] and -/// [`VendorWorktree::untrack_vendor`] write `.gitattributes` as a working-copy -/// side effect rather than folding it into the vendor tree before -/// `index_from_tree` runs. Ideally those methods would return a blob OID so -/// the caller could include `.gitattributes` in `full_tree` like any other -/// file, making this function unnecessary. -fn stage_gitattributes(repo: &gix::Repository, content: &[u8]) -> Result<(), Error> { +/// Unlike `stage_gitvendors`, this writes nothing: `track_vendor` stages +/// `.gitattributes` as a working-copy side effect and this only reads the +/// resulting index entry back. +fn staged_attrs_blob(repo: &gix::Repository) -> Result { + use gix::bstr::ByteSlice as _; + let index = repo.open_index().map_err(|e| format!("{e}"))?; + index + .entries() + .iter() + .find(|e| e.path(&index) == b".gitattributes".as_bstr()) + .map(|e| e.id) + .ok_or_else(|| "no .gitattributes in index after tracking".into()) +} + +/// Write `content` as a blob, upsert the `.gitvendors` index entry, and return +/// the blob OID so callers can include it in a commit tree. +fn stage_gitvendors(repo: &gix::Repository, content: &[u8]) -> Result { + use gix::bstr::ByteSlice as _; let blob_oid = repo - .write_object(gix::objs::BlobRef { data: content })? + .write_object(gix::objs::BlobRef { data: content }) + .map_err(|e| format!("{e}"))? .detach(); - - let mut index = repo.open_index().map_err(|e| Error::Gix(Box::new(e)))?; - index.remove_entries(|_, path, _| path == b".gitattributes".as_bstr()); + let mut index = repo.open_index().map_err(|e| format!("{e}"))?; + index.remove_entries(|_, path, _| path == b".gitvendors".as_bstr()); index.dangerously_push_entry( gix::index::entry::Stat::default(), blob_oid, gix::index::entry::Flags::empty(), gix::index::entry::Mode::FILE, - b".gitattributes".as_bstr(), + b".gitvendors".as_bstr(), ); index.sort_entries(); index .write(gix::index::write::Options::default()) - .map_err(|e| Error::Gix(Box::new(e)))?; + .map_err(|e| format!("{e}"))?; + Ok(blob_oid) +} - Ok(()) +fn final_tree( + repo: &gix::Repository, + full_tree: gix::ObjectId, + attrs_blob: gix::ObjectId, + vendors_blob: gix::ObjectId, +) -> Result { + use gix::bstr::ByteSlice as _; + let mut editor = repo + .find_object(full_tree) + .map_err(|e| format!("{e}"))? + .into_tree() + .edit() + .map_err(|e| format!("{e}"))?; + editor + .upsert( + b".gitattributes".as_bstr(), + gix::objs::tree::EntryKind::Blob, + attrs_blob, + ) + .map_err(|e| format!("{e}"))?; + editor + .upsert( + b".gitvendors".as_bstr(), + gix::objs::tree::EntryKind::Blob, + vendors_blob, + ) + .map_err(|e| format!("{e}"))?; + Ok(editor.write().map_err(|e| format!("{e}"))?.detach()) +} + +/// Mint a vendor merge commit using `tree` and advance HEAD. +/// +/// In squash mode a parentless squash commit is minted and used as the +/// second parent; in merge mode the upstream commit is used directly. +fn commit_and_advance( + repo: &gix::Repository, + entry: &VendorEntry, + merge: &git_vendor::VendorMerge, + tree: gix::ObjectId, + parent: gix::ObjectId, + message: &str, +) -> Result<()> { + let author = author_sig(repo)?; + let committer = committer_sig(repo)?; + + let mut tbuf_a = gix::date::parse::TimeBuf::default(); + let mut tbuf_c = gix::date::parse::TimeBuf::default(); + + let second_parent = if entry.mode == VendorMode::Squash { + let upstream_tree = repo.upstream_tree(entry, merge.upstream_commit)?; + let squash = gix::objs::Commit { + tree: upstream_tree, + parents: Default::default(), + author: author.to_ref(&mut tbuf_a).into(), + committer: committer.to_ref(&mut tbuf_c).into(), + encoding: None, + message: format!( + "squash: vendor '{}'\n\nSquashed-upstream: {}\n", + entry.name, merge.upstream_commit + ) + .into(), + extra_headers: Vec::new(), + }; + repo.write_object(&squash)?.detach() + } else { + merge.upstream_commit + }; + + let mut tbuf_a2 = gix::date::parse::TimeBuf::default(); + let mut tbuf_c2 = gix::date::parse::TimeBuf::default(); + let commit = gix::objs::Commit { + tree, + parents: [parent, second_parent].into_iter().collect(), + author: author.to_ref(&mut tbuf_a2).into(), + committer: committer.to_ref(&mut tbuf_c2).into(), + encoding: None, + message: message.into(), + extra_headers: Vec::new(), + }; + let new_commit = repo.write_object(&commit)?.detach(); + advance_head(repo, new_commit, message) +} + +fn name_from_url(url: &str) -> Option { + let stem = url + .trim_end_matches('/') + .rsplit(['/', ':']) + .find(|s| !s.is_empty())?; + let stem = stem + .strip_suffix(".git") + .or_else(|| stem.strip_suffix(".bundle")) + .unwrap_or(stem); + if stem.is_empty() { + None + } else { + Some(stem.to_owned()) + } } diff --git a/crates/git-vendor/src/lib.rs b/crates/git-vendor/src/lib.rs index cbf80a8..e1dfdf6 100644 --- a/crates/git-vendor/src/lib.rs +++ b/crates/git-vendor/src/lib.rs @@ -3,10 +3,8 @@ mod error; mod vendor; -pub mod exe; - pub use error::Error; -use gix::bstr::ByteSlice as _; +use gix::bstr::{BStr, ByteSlice as _}; use gix::remote::fetch::{Status, refs::update::Mode}; pub use vendor::{ ConflictStages, PatternMapping, VendorConfig, VendorEntry, VendorMerge, VendorMode, VendorName, @@ -535,3 +533,324 @@ fn resolve_vendor_paths( } Ok(paths) } + +// ── worktree impl ──────────────────────────────────────────────────────────── + +impl VendorWorktree for gix::Repository { + fn checkout_vendor( + &self, + entry: &VendorEntry, + tree: gix::ObjectId, + ) -> Result { + // IMPORTANT + // This is the trust boundary where upstream content (carried verbatim + // through `upstream_tree`, including symlink and gitlink modes, + // mirroring git-subtree/submodule) reaches the working copy. Like + // core git's `verify_path`/checkout, projection MUST refuse to write + // through a symlinked leading path and reject `..`/absolute + // components — use gix-worktree's checked checkout, never naive + // `std::fs` writes. See the `upstream_tree` adversarial review (#5). + // NOTE + // Path-traversal safety (e.g. `../` components, symlinked leading + // paths) is delegated to gix and is not covered by automated tests. + let workdir = self.workdir().ok_or(Error::NoWorkdir)?; + + let head_id = self.head_commit().ok().map(|c| c.id().detach()); + + let old_paths: std::collections::BTreeSet = head_id + .and_then(|id| resolve_vendor_paths(self, entry, id).ok()) + .into_iter() + .flatten() + .collect(); + + let mut vendor_index = self.index_from_tree(&tree)?; + + let new_paths: std::collections::BTreeSet = vendor_index + .entries() + .iter() + .map(|e| e.path(&vendor_index).to_owned()) + .collect(); + + let opts = self + .checkout_options(gix::worktree::stack::state::attributes::Source::IdMapping) + .map_err(|e| Error::Gix(Box::new(e)))?; + let progress = gix::progress::Discard; + gix::worktree::state::checkout( + &mut vendor_index, + workdir, + self.objects.clone().into_arc().map_err(Error::Io)?, + &progress, + &progress, + &gix::interrupt::IS_INTERRUPTED, + gix::worktree::state::checkout::Options { + overwrite_existing: true, + ..opts + }, + ) + .map_err(|e| Error::Gix(Box::new(e)))?; + + for removed in old_paths.difference(&new_paths) { + let abs = workdir.join(gix::path::from_bstr(removed).as_ref()); + if abs.symlink_metadata().is_ok() { + std::fs::remove_file(&abs)?; + } + } + + // Overlay the vendor tree onto the full HEAD tree and rebuild the index + // from the result. An unborn HEAD has no base commit, so the vendor tree + // is itself the whole tree. + let full_tree = match head_id { + Some(id) => self.vendor_overlay(entry, id, tree)?, + None => tree, + }; + let mut main_index = self.index_from_tree(&full_tree)?; + main_index.set_path(self.git_dir().join("index")); + + // `index_from_tree` zeroes stat data; carry over the stats checkout just + // populated on the vendor entries so `git status` need not re-hash them. + let vendor_stats: std::collections::HashMap = + vendor_index + .entries() + .iter() + .map(|e| (e.path(&vendor_index).to_owned(), e.stat)) + .collect(); + for (e, path) in main_index.entries_mut_with_paths() { + if let Some(stat) = vendor_stats.get(path) { + e.stat = *stat; + } + } + + main_index + .write(gix::index::write::Options::default()) + .map_err(|e| Error::Gix(Box::new(e)))?; + + Ok(full_tree) + } + + fn checkout_vendor_conflicted( + &self, + entry: &VendorEntry, + merge: &VendorMerge, + ) -> Result<(), Error> { + use gix::bstr::ByteSlice as _; + + self.checkout_vendor(entry, merge.result_tree)?; + + // Reopen the index we just wrote so we can splice in unmerged stages. + let mut main_index = self.open_index().map_err(|e| Error::Gix(Box::new(e)))?; + main_index.set_path(self.git_dir().join("index")); + + for conflict in &merge.conflicts { + let path_bytes = gix::bstr::BString::from(conflict.path.as_bytes()); + let path_bstr = path_bytes.as_bstr(); + + main_index.remove_entries(|_, p, _| p == path_bstr); + + for (stage_idx, stage_variant) in [ + (0usize, gix::index::entry::Stage::Base), + (1usize, gix::index::entry::Stage::Ours), + (2usize, gix::index::entry::Stage::Theirs), + ] { + if let Some((tree_mode, oid)) = conflict.stages[stage_idx] { + let mode = gix::index::entry::Mode::from(tree_mode); + let flags = gix::index::entry::Flags::from_stage(stage_variant); + main_index.dangerously_push_entry( + gix::index::entry::Stat::default(), + oid, + flags, + mode, + path_bstr, + ); + } + } + } + + main_index.sort_entries(); + main_index + .write(gix::index::write::Options::default()) + .map_err(|e| Error::Gix(Box::new(e)))?; + + Ok(()) + } + + fn track_vendor(&self, entry: &VendorEntry, paths: &[&BStr]) -> Result<(), Error> { + let workdir = self.workdir().ok_or(Error::NoWorkdir)?; + let gitattributes = workdir.join(".gitattributes"); + + let existing: Vec = if gitattributes.exists() { + std::fs::read(&gitattributes)? + } else { + Vec::new() + }; + + let attr_value = format!("vendor={}", entry.name.as_str()); + let attr_bytes = attr_value.as_bytes(); + + for path in paths { + check_attr_pattern(path.as_bytes())?; + } + + let already_tracked: std::collections::HashSet> = existing + .lines() + .filter_map(|line| { + let (pattern, attr) = split_attr_line(line)?; + if attr == attr_bytes { + Some(pattern.into_owned()) + } else { + None + } + }) + .collect(); + + let mut out = existing.clone(); + if !out.is_empty() && out.last() != Some(&b'\n') { + out.push(b'\n'); + } + for path in paths { + if !already_tracked.contains(path.as_bytes()) { + out.extend_from_slice(path.as_bytes()); + out.push(b' '); + out.extend_from_slice(attr_bytes); + out.push(b'\n'); + } + } + + if out != existing { + std::fs::write(&gitattributes, &out)?; + } + + stage_gitattributes(self, &out)?; + Ok(()) + } + + fn untrack_vendor(&self, entry: &VendorEntry, paths: &[&BStr]) -> Result<(), Error> { + let workdir = self.workdir().ok_or(Error::NoWorkdir)?; + let gitattributes = workdir.join(".gitattributes"); + + if !gitattributes.exists() { + return Ok(()); + } + + let existing: Vec = std::fs::read(&gitattributes)?; + let attr_value = format!("vendor={}", entry.name.as_str()); + let attr_bytes = attr_value.as_bytes(); + + let remove: std::collections::HashSet<&[u8]> = paths.iter().map(|b| b.as_bytes()).collect(); + + let mut filtered: Vec = Vec::with_capacity(existing.len()); + for line in existing.lines() { + let keep = match split_attr_line(line) { + Some((pattern, attr)) => !(attr == attr_bytes && remove.contains(pattern.as_ref())), + None => true, + }; + if keep { + filtered.extend_from_slice(line); + filtered.push(b'\n'); + } + } + + if filtered != existing { + std::fs::write(&gitattributes, &filtered)?; + } + + stage_gitattributes(self, &filtered)?; + Ok(()) + } + + fn prepare_merge( + &self, + entry: &VendorEntry, + merge: &VendorMerge, + message: &str, + ) -> Result<(), Error> { + let git_dir = self.git_dir(); + if entry.mode == VendorMode::Squash { + std::fs::write(git_dir.join("SQUASH_MSG"), message.as_bytes())?; + } else { + std::fs::write( + git_dir.join("MERGE_HEAD"), + format!("{}\n", merge.upstream_commit), + )?; + std::fs::write(git_dir.join("MERGE_MSG"), message.as_bytes())?; + } + Ok(()) + } +} + +/// Return `Err` if `path` contains characters that require C-style quoting in +/// `.gitattributes` (space, tab, `#`, `"`, `\`, or control characters). +/// Git source paths from tree objects never contain these in practice. +fn check_attr_pattern(path: &[u8]) -> Result<(), Error> { + let needs_quoting = |b: u8| b.is_ascii_control() || matches!(b, b' ' | b'#' | b'"' | b'\\'); + if path.iter().copied().any(needs_quoting) { + return Err(Error::InvalidPath( + String::from_utf8_lossy(path).into_owned(), + )); + } + Ok(()) +} + +/// Parse one `.gitattributes` line into `(unquoted_pattern, trimmed_attrs)`. +/// +/// Returns `None` for blank lines, comment lines, or lines with no attribute +/// separator. Handles both plain and C-style-quoted patterns using +/// [`gix_quote::ansi_c::undo`]. +fn split_attr_line(line: &[u8]) -> Option<(std::borrow::Cow<'_, [u8]>, &[u8])> { + if line.is_empty() || line[0] == b'#' { + return None; + } + if line.starts_with(b"\"") { + let (pattern, consumed) = gix_quote::ansi_c::undo(line.as_bstr()).ok()?; + let rest = line.get(consumed..)?; + if rest.first().is_some_and(|&b| b == b' ' || b == b'\t') { + let owned: Vec = pattern.as_ref().to_vec(); + Some((std::borrow::Cow::Owned(owned), rest[1..].trim())) + } else { + None + } + } else { + let pos = line.iter().position(|&b| b == b' ' || b == b'\t')?; + if pos == 0 { + return None; + } + Some(( + std::borrow::Cow::Borrowed(&line[..pos]), + line[pos + 1..].trim(), + )) + } +} + +#[cfg(test)] +#[path = "attr_tests.rs"] +mod tests; + +/// Write `content` as a blob into the object database and upsert the +/// `.gitattributes` index entry to point at it. +/// +/// This exists because [`VendorWorktree::track_vendor`] and +/// [`VendorWorktree::untrack_vendor`] write `.gitattributes` as a working-copy +/// side effect rather than folding it into the vendor tree before +/// `index_from_tree` runs. Ideally those methods would return a blob OID so +/// the caller could include `.gitattributes` in `full_tree` like any other +/// file, making this function unnecessary. +fn stage_gitattributes(repo: &gix::Repository, content: &[u8]) -> Result<(), Error> { + let blob_oid = repo + .write_object(gix::objs::BlobRef { data: content })? + .detach(); + + let mut index = repo.open_index().map_err(|e| Error::Gix(Box::new(e)))?; + index.remove_entries(|_, path, _| path == b".gitattributes".as_bstr()); + index.dangerously_push_entry( + gix::index::entry::Stat::default(), + blob_oid, + gix::index::entry::Flags::empty(), + gix::index::entry::Mode::FILE, + b".gitattributes".as_bstr(), + ); + index.sort_entries(); + index + .write(gix::index::write::Options::default()) + .map_err(|e| Error::Gix(Box::new(e)))?; + + Ok(()) +} diff --git a/crates/git-vendor/src/main.rs b/crates/git-vendor/src/main.rs index 4dd9fb9..6394950 100644 --- a/crates/git-vendor/src/main.rs +++ b/crates/git-vendor/src/main.rs @@ -1,778 +1,21 @@ #![allow(missing_docs)] mod cli; - -use std::path::{Path, PathBuf}; +mod exe; use clap::Parser as _; -use git_vendor::{ - PatternMapping, VendorConfig, VendorEntry, VendorMode, VendorName, VendorRepository, - VendorStatus, VendorWorktree, -}; type Result> = std::result::Result; fn main() { - let cli = cli::Cli::parse(); - if let Err(e) = run(cli) { - eprintln!("error: {e}"); - std::process::exit(1); - } -} - -fn run(cli: cli::Cli) -> Result<()> { - match cli.command { - cli::Command::Add { - url, - name, - ref_name, - prefix, - patterns, - squash, - dry_run, - message, - } => cmd_add( - name, url, ref_name, prefix, patterns, squash, dry_run, message, - ), - cli::Command::Update { - name, - message, - force, - dry_run, - } => cmd_update(name, message, force, dry_run), - cli::Command::Apply { - name, - message, - force, - } => cmd_apply(name, message, force), - cli::Command::Status { name, fetch } => cmd_status(name, fetch), - cli::Command::Remove { name, keep_files } => cmd_remove(name, keep_files), - cli::Command::List => cmd_list(), - } -} - -// ── helpers ────────────────────────────────────────────────────────────────── - -fn discover() -> Result { - Ok(gix::discover(".")?) -} - -fn config_path(repo: &gix::Repository) -> Result { - let workdir = repo.workdir().ok_or("not a working-copy repository")?; - Ok(workdir.join(".gitvendors")) -} - -fn load_config(path: &Path) -> Result { - if path.exists() { - Ok(VendorConfig::open(path)?) - } else { - Ok(VendorConfig::parse("")?) - } -} - -/// Write `config` to `path` and return the serialized bytes for blob staging. -fn save_config(config: &VendorConfig, path: &Path) -> Result { - let s = config.to_string(); - std::fs::write(path, &s)?; - Ok(s) -} - -fn require_entry(config: &VendorConfig, name: &str) -> Result { - config - .get(name)? - .ok_or_else(|| format!("no vendor named {name:?}").into()) -} - -fn tree_paths(repo: &gix::Repository, tree_id: gix::ObjectId) -> Result> { - let index = repo.index_from_tree(&tree_id)?; - Ok(index - .entries() - .iter() - .map(|e| e.path(&index).into()) - .collect()) -} - -fn tree_blobs( - repo: &gix::Repository, - tree_id: gix::ObjectId, -) -> Result> { - let index = repo.index_from_tree(&tree_id)?; - Ok(index - .entries() - .iter() - .map(|e| (e.path(&index).into(), e.id)) - .collect()) -} - -/// The `.gitvendors` config as committed at `commit`, or `None` if absent. -fn config_at(repo: &gix::Repository, commit: gix::ObjectId) -> Result> { - let tree = repo - .find_commit(commit) - .map_err(|e| format!("{e}"))? - .tree() - .map_err(|e| format!("{e}"))?; - let Some(entry) = tree - .lookup_entry_by_path(".gitvendors") - .map_err(|e| format!("{e}"))? - else { - return Ok(None); - }; - let blob = entry.object().map_err(|e| format!("{e}"))?; - let s = String::from_utf8_lossy(&blob.data).into_owned(); - Ok(Some(VendorConfig::parse(&s)?)) -} - -fn advance_head(repo: &gix::Repository, new_commit: gix::ObjectId, msg: &str) -> Result<()> { - use gix::refs::Target; - use gix::refs::transaction::{Change, LogChange, PreviousValue, RefEdit, RefLog}; - - let name: gix::refs::FullName = "HEAD".try_into()?; - repo.edit_references([RefEdit { - change: Change::Update { - log: LogChange { - mode: RefLog::AndReference, - force_create_reflog: false, - message: msg.as_bytes().into(), - }, - expected: PreviousValue::Any, - new: Target::Object(new_commit), - }, - name, - deref: true, - }])?; - Ok(()) -} - -fn committer_sig(repo: &gix::Repository) -> Result { - let sig_ref = repo - .committer() - .ok_or("no committer identity; set user.name and user.email")? - .map_err(|e| format!("committer: {e}"))?; - sig_ref - .to_owned() - .map_err(|e| format!("committer time: {e}").into()) -} - -fn author_sig(repo: &gix::Repository) -> Result { - let sig_ref = repo - .author() - .ok_or("no author identity; set user.name and user.email")? - .map_err(|e| format!("author: {e}"))?; - sig_ref - .to_owned() - .map_err(|e| format!("author time: {e}").into()) -} - -fn reconcile_tracked_paths( - repo: &gix::Repository, - entry: &VendorEntry, - old_paths: &[gix::bstr::BString], - new_paths: &[gix::bstr::BString], -) -> Result<()> { - use gix::bstr::BStr; - let track: Vec<&BStr> = new_paths.iter().map(|b| b.as_ref()).collect(); - repo.track_vendor(entry, &track)?; - - let new_set: std::collections::HashSet<&[u8]> = - new_paths.iter().map(|b| b.as_slice()).collect(); - let removed: Vec<&BStr> = old_paths - .iter() - .filter(|b| !new_set.contains(b.as_slice())) - .map(|b| b.as_ref()) - .collect(); - if !removed.is_empty() { - repo.untrack_vendor(entry, &removed)?; - } - Ok(()) -} - -/// Return the OID of the `.gitattributes` blob already staged in the index by -/// `track_vendor`, so callers can include it in a commit tree. -/// -/// Unlike `stage_gitvendors`, this writes nothing: `track_vendor` stages -/// `.gitattributes` as a working-copy side effect and this only reads the -/// resulting index entry back. -fn staged_attrs_blob(repo: &gix::Repository) -> Result { - use gix::bstr::ByteSlice as _; - let index = repo.open_index().map_err(|e| format!("{e}"))?; - index - .entries() - .iter() - .find(|e| e.path(&index) == b".gitattributes".as_bstr()) - .map(|e| e.id) - .ok_or_else(|| "no .gitattributes in index after tracking".into()) -} - -/// Write `content` as a blob, upsert the `.gitvendors` index entry, and return -/// the blob OID so callers can include it in a commit tree. -fn stage_gitvendors(repo: &gix::Repository, content: &[u8]) -> Result { - use gix::bstr::ByteSlice as _; - let blob_oid = repo - .write_object(gix::objs::BlobRef { data: content }) - .map_err(|e| format!("{e}"))? - .detach(); - let mut index = repo.open_index().map_err(|e| format!("{e}"))?; - index.remove_entries(|_, path, _| path == b".gitvendors".as_bstr()); - index.dangerously_push_entry( - gix::index::entry::Stat::default(), - blob_oid, - gix::index::entry::Flags::empty(), - gix::index::entry::Mode::FILE, - b".gitvendors".as_bstr(), - ); - index.sort_entries(); - index - .write(gix::index::write::Options::default()) - .map_err(|e| format!("{e}"))?; - Ok(blob_oid) -} - -/// Upsert `.gitattributes` and `.gitvendors` blobs into `full_tree`, returning -/// the corrected tree OID that carries both files. -fn final_tree( - repo: &gix::Repository, - full_tree: gix::ObjectId, - attrs_blob: gix::ObjectId, - vendors_blob: gix::ObjectId, -) -> Result { - use gix::bstr::ByteSlice as _; - let mut editor = repo - .find_object(full_tree) - .map_err(|e| format!("{e}"))? - .into_tree() - .edit() - .map_err(|e| format!("{e}"))?; - editor - .upsert( - b".gitattributes".as_bstr(), - gix::objs::tree::EntryKind::Blob, - attrs_blob, - ) - .map_err(|e| format!("{e}"))?; - editor - .upsert( - b".gitvendors".as_bstr(), - gix::objs::tree::EntryKind::Blob, - vendors_blob, - ) - .map_err(|e| format!("{e}"))?; - Ok(editor.write().map_err(|e| format!("{e}"))?.detach()) -} - -/// Mint a vendor merge commit using `tree` and advance HEAD. -/// -/// In squash mode a parentless squash commit is minted and used as the -/// second parent; in merge mode the upstream commit is used directly. -fn commit_and_advance( - repo: &gix::Repository, - entry: &VendorEntry, - merge: &git_vendor::VendorMerge, - tree: gix::ObjectId, - parent: gix::ObjectId, - message: &str, -) -> Result<()> { - let author = author_sig(repo)?; - let committer = committer_sig(repo)?; - - let mut tbuf_a = gix::date::parse::TimeBuf::default(); - let mut tbuf_c = gix::date::parse::TimeBuf::default(); - - let second_parent = if entry.mode == VendorMode::Squash { - let upstream_tree = repo.upstream_tree(entry, merge.upstream_commit)?; - let squash = gix::objs::Commit { - tree: upstream_tree, - parents: Default::default(), - author: author.to_ref(&mut tbuf_a).into(), - committer: committer.to_ref(&mut tbuf_c).into(), - encoding: None, - message: format!( - "squash: vendor '{}'\n\nSquashed-upstream: {}\n", - entry.name, merge.upstream_commit - ) - .into(), - extra_headers: Vec::new(), - }; - repo.write_object(&squash)?.detach() - } else { - merge.upstream_commit - }; - - let mut tbuf_a2 = gix::date::parse::TimeBuf::default(); - let mut tbuf_c2 = gix::date::parse::TimeBuf::default(); - let commit = gix::objs::Commit { - tree, - parents: [parent, second_parent].into_iter().collect(), - author: author.to_ref(&mut tbuf_a2).into(), - committer: committer.to_ref(&mut tbuf_c2).into(), - encoding: None, - message: message.into(), - extra_headers: Vec::new(), - }; - let new_commit = repo.write_object(&commit)?.detach(); - advance_head(repo, new_commit, message) -} - -// ── commands ───────────────────────────────────────────────────────────────── - -/// Derive a vendor name from a URL by taking the last non-empty path component -/// and stripping common suffixes (`.git`, `.bundle`). -fn name_from_url(url: &str) -> Option { - let stem = url - .trim_end_matches('/') - .rsplit(['/', ':']) - .find(|s| !s.is_empty())?; - let stem = stem - .strip_suffix(".git") - .or_else(|| stem.strip_suffix(".bundle")) - .unwrap_or(stem); - if stem.is_empty() { - None - } else { - Some(stem.to_owned()) - } -} - -#[allow(clippy::too_many_arguments)] -fn cmd_add( - name: Option, - url: String, - ref_name: Option, - prefix: Option, - patterns: Vec, - squash: bool, - dry_run: bool, - message: Option, -) -> Result<()> { - let repo = discover()?; - let cfg_path = config_path(&repo)?; - let mut config = load_config(&cfg_path)?; - - let name = match name { - Some(n) => n, - None => name_from_url(&url).ok_or_else(|| { - format!("cannot derive a vendor name from URL {url:?}; pass a name explicitly") - })?, - }; - let vendor_name = VendorName::new(&name)?; - let mode = if squash { - VendorMode::Squash - } else { - VendorMode::default() - }; - let patterns: Vec = if patterns.is_empty() { - let dest = prefix.map_or_else( - || format!("vendor/{name}/"), - |p| { - if p.ends_with('/') { p } else { format!("{p}/") } - }, - ); - vec![format!("**:{dest}")] - } else { - patterns - }; - let mut entry = VendorEntry { - name: vendor_name, - url, - ref_name, - base: None, - patterns: patterns.iter().map(|p| PatternMapping::parse(p)).collect(), - mode, - }; - - // Fetch before touching config — a failed fetch leaves no side effects. - eprintln!("Fetching {name}…"); - let upstream = repo.fetch_vendor(&entry)?; - - if dry_run { - eprintln!("Would add vendor {name} at {upstream}."); - return Ok(()); - } - - let head_oid = repo.head_commit().ok().map(|c| c.id().detach()); - - let msg = message - .clone() - .unwrap_or_else(|| format!("vendor: add {name}")); - - match head_oid { - Some(ours) => { - let merge = repo.merge_vendor(&entry, ours, upstream)?; - - if merge.has_conflicts() { - repo.checkout_vendor_conflicted(&entry, &merge)?; - // Track the vendor paths now: the conflict markers, not the set - // of paths, are what the user resolves, so `.gitattributes` must - // record the mapping before they `git commit`. - let new_paths = tree_paths(&repo, merge.result_tree)?; - reconcile_tracked_paths(&repo, &entry, &[], &new_paths)?; - // Record the config entry and set up MERGE_HEAD so the user's - // `git commit` after resolution produces a proper merge commit. - entry.base = Some(merge.upstream_commit); - config.insert(&entry)?; - let config_str = save_config(&config, &cfg_path)?; - stage_gitvendors(&repo, config_str.as_bytes())?; - repo.prepare_merge(&entry, &merge, &msg)?; - let paths: Vec<_> = merge.conflicts.iter().map(|c| c.path.as_str()).collect(); - eprintln!("Conflict in: {}", paths.join(", ")); - eprintln!("Resolve conflicts, then run `git commit`."); - std::process::exit(1); - } - - let _full_tree = repo.checkout_vendor(&entry, merge.result_tree)?; - let new_paths = tree_paths(&repo, merge.result_tree)?; - reconcile_tracked_paths(&repo, &entry, &[], &new_paths)?; - - entry.base = Some(merge.upstream_commit); - config.insert(&entry)?; - let config_str = save_config(&config, &cfg_path)?; - - stage_gitvendors(&repo, config_str.as_bytes())?; - - repo.prepare_merge(&entry, &merge, &msg)?; - eprintln!("Staged; run `git commit` to complete."); - } - None => { - // Unborn repository: make the initial commit directly (no merge). - let tree = repo.upstream_tree(&entry, upstream)?; - let _full_tree = repo.checkout_vendor(&entry, tree)?; - let new_paths = tree_paths(&repo, tree)?; - let path_refs: Vec<&gix::bstr::BStr> = new_paths.iter().map(|b| b.as_ref()).collect(); - repo.track_vendor(&entry, &path_refs)?; - - entry.base = Some(upstream); - config.insert(&entry)?; - let config_str = save_config(&config, &cfg_path)?; - - stage_gitvendors(&repo, config_str.as_bytes())?; - - eprintln!("Staged; run `git commit` to complete."); - } - } - - Ok(()) -} - -fn cmd_update( - name: Option, - message: Option, - force: bool, - dry_run: bool, -) -> Result<()> { - let repo = discover()?; - let cfg_path = config_path(&repo)?; - let mut config = load_config(&cfg_path)?; - - // Multi-vendor updates always auto-commit (one commit per vendor); only a - // single-vendor update without -m uses the prepare-merge path. - let auto_commit = name.is_none() || message.is_some(); - - let entries: Vec = match name { - Some(ref n) => vec![require_entry(&config, n)?], - None => config.entries()?, - }; - - if entries.is_empty() { - eprintln!("No vendors configured."); - return Ok(()); - } - - let head_oid = repo - .head_commit() - .map(|c| c.id().detach()) - .map_err(|e| format!("HEAD: {e}"))?; - - let mut current_head = head_oid; - - for mut entry in entries { - let n = entry.name.as_str().to_owned(); - eprintln!("Fetching {n}…"); - let upstream = repo.fetch_vendor(&entry)?; - - let status = repo.vendor_status(&entry)?; - match status { - VendorStatus::UpToDate => { - eprintln!("{n}: already up to date"); - continue; - } - VendorStatus::ForcePushed { .. } if !force => { - eprintln!("{n}: upstream was force-pushed; re-run with --force to accept"); - continue; - } - _ => {} - } - - if dry_run { - eprintln!("Would update {n} to {upstream}."); - continue; - } - - let msg = message - .clone() - .unwrap_or_else(|| format!("vendor: update {n}")); - - let old_paths: Vec = - repo.vendor_paths(&entry, current_head).unwrap_or_default(); - - let merge = repo.merge_vendor(&entry, current_head, upstream)?; - - if merge.has_conflicts() { - repo.checkout_vendor_conflicted(&entry, &merge)?; - let new_paths = tree_paths(&repo, merge.result_tree)?; - reconcile_tracked_paths(&repo, &entry, &old_paths, &new_paths)?; - entry.base = Some(merge.upstream_commit); - config.insert(&entry)?; - let config_str = save_config(&config, &cfg_path)?; - stage_gitvendors(&repo, config_str.as_bytes())?; - repo.prepare_merge(&entry, &merge, &msg)?; - let paths: Vec<_> = merge.conflicts.iter().map(|c| c.path.as_str()).collect(); - eprintln!("{n}: conflict in {}", paths.join(", ")); - eprintln!("Resolve conflicts, then run `git commit`."); - std::process::exit(1); - } - - let full_tree = repo.checkout_vendor(&entry, merge.result_tree)?; - let new_paths = tree_paths(&repo, merge.result_tree)?; - reconcile_tracked_paths(&repo, &entry, &old_paths, &new_paths)?; - - entry.base = Some(merge.upstream_commit); - config.insert(&entry)?; - let config_str = save_config(&config, &cfg_path)?; - - if auto_commit { - let attrs_blob = staged_attrs_blob(&repo)?; - let vendors_blob = stage_gitvendors(&repo, config_str.as_bytes())?; - let tree = final_tree(&repo, full_tree, attrs_blob, vendors_blob)?; - commit_and_advance(&repo, &entry, &merge, tree, current_head, &msg)?; - current_head = repo - .head_commit() - .map(|c| c.id().detach()) - .map_err(|e| format!("HEAD after commit: {e}"))?; - eprintln!("Updated {n}."); - } else { - stage_gitvendors(&repo, config_str.as_bytes())?; - repo.prepare_merge(&entry, &merge, &msg)?; - eprintln!("Updated {n}. Run `git commit` to record the merge."); - } - } - - Ok(()) -} - -fn cmd_apply(name: Option, message: Option, force: bool) -> Result<()> { - let repo = discover()?; - let cfg_path = config_path(&repo)?; - let config = load_config(&cfg_path)?; - - let entries: Vec = match name { - Some(ref n) => vec![require_entry(&config, n)?], - None => config.entries()?, - }; - - if entries.is_empty() { - eprintln!("No vendors configured."); - return Ok(()); - } - - let head_oid = repo - .head_commit() - .map(|c| c.id().detach()) - .map_err(|e| format!("HEAD: {e}"))?; - - // Patterns as last committed, for the local-modification check: a vendor - // whose ours tree differs from the pristine upstream tree of its recorded - // base carries patches that re-materializing would discard. - let old_config = config_at(&repo, head_oid)?; - - let config_str = save_config(&config, &cfg_path)?; - let mut current_head = head_oid; - - for entry in entries { - let n = entry.name.as_str().to_owned(); - let Some(base) = entry.base else { - eprintln!("{n}: no recorded base; run `git vendor update {n}` first"); - continue; - }; - - let pristine = old_config - .as_ref() - .and_then(|c| c.get(&n).ok().flatten()) - .and_then(|old| old.base.map(|b| (old, b))) - .map(|(old, b)| repo.upstream_tree(&old, b)) - .transpose()?; - if let Some(pristine) = pristine { - let ours = repo.ours_tree(&entry, current_head)?; - if ours != pristine && !force { - let pristine_blobs = tree_blobs(&repo, pristine)?; - let our_blobs = tree_blobs(&repo, ours)?; - let mut modified: Vec = our_blobs - .iter() - .filter(|(p, oid)| pristine_blobs.get(*p) != Some(oid)) - .map(|(p, _)| p.to_string()) - .collect(); - modified.extend( - pristine_blobs - .keys() - .filter(|p| !our_blobs.contains_key(*p)) - .map(|p| p.to_string()), - ); - modified.sort(); - eprintln!( - "{n}: vendored files have local modifications ({}); \ - re-run with --force to discard them", - modified.join(", ") - ); - continue; - } - } - - let new_tree = repo.upstream_tree(&entry, base)?; - let old_paths: Vec = - repo.vendor_paths(&entry, current_head).unwrap_or_default(); - - let full_tree = repo.checkout_vendor(&entry, new_tree)?; - let new_paths = tree_paths(&repo, new_tree)?; - reconcile_tracked_paths(&repo, &entry, &old_paths, &new_paths)?; - - let attrs_blob = staged_attrs_blob(&repo)?; - let vendors_blob = stage_gitvendors(&repo, config_str.as_bytes())?; - let tree = final_tree(&repo, full_tree, attrs_blob, vendors_blob)?; - - let head_tree = repo - .find_commit(current_head) - .map_err(|e| format!("{e}"))? - .tree() - .map_err(|e| format!("{e}"))? - .id() - .detach(); - if tree == head_tree { - eprintln!("{n}: nothing to apply"); - continue; - } - - let msg = message - .clone() - .unwrap_or_else(|| format!("vendor: apply {n}")); - - // A single-parent commit: no upstream changed, so unlike add/update - // there is no merge edge to record. - let author = author_sig(&repo)?; - let committer = committer_sig(&repo)?; - let mut tbuf_a = gix::date::parse::TimeBuf::default(); - let mut tbuf_c = gix::date::parse::TimeBuf::default(); - let commit = gix::objs::Commit { - tree, - parents: [current_head].into_iter().collect(), - author: author.to_ref(&mut tbuf_a).into(), - committer: committer.to_ref(&mut tbuf_c).into(), - encoding: None, - message: msg.as_str().into(), - extra_headers: Vec::new(), - }; - let new_commit = repo.write_object(&commit)?.detach(); - advance_head(&repo, new_commit, &msg)?; - current_head = new_commit; - eprintln!("Applied {n}."); - } - - Ok(()) -} - -fn cmd_status(name: Option, fetch: bool) -> Result<()> { - let repo = discover()?; - let cfg_path = config_path(&repo)?; - let config = load_config(&cfg_path)?; - - let entries: Vec = match name { - Some(ref n) => vec![require_entry(&config, n)?], - None => config.entries()?, - }; - - if entries.is_empty() { - eprintln!("No vendors configured."); - return Ok(()); - } - - for entry in &entries { - if fetch { - repo.fetch_vendor(entry)?; - } - let status = repo.vendor_status(entry)?; - let label = match &status { - VendorStatus::NotFetched => "not fetched".to_owned(), - VendorStatus::UpToDate => "up to date".to_owned(), - VendorStatus::UpdateAvailable { upstream } => { - format!("update available ({})", upstream.to_hex()) - } - VendorStatus::ForcePushed { upstream } => { - format!("force-pushed upstream ({})", upstream.to_hex()) - } - }; - println!("{}\t{}\t{label}", entry.name, entry.url); - } - - Ok(()) -} - -fn cmd_remove(name: String, keep_files: bool) -> Result<()> { - let repo = discover()?; - let cfg_path = config_path(&repo)?; - let mut config = load_config(&cfg_path)?; - - let entry = require_entry(&config, &name)?; - - if !keep_files { - let head_oid = repo.head_commit().ok().map(|c| c.id().detach()); - - if let Some(oid) = head_oid { - use gix::bstr::ByteSlice as _; - let workdir = repo.workdir().ok_or("not a working-copy repository")?; - let paths = repo.vendor_paths(&entry, oid)?; - for p in &paths { - let abs = workdir.join(gix::path::from_bstr(p).as_ref()); - if abs.symlink_metadata().is_ok() { - std::fs::remove_file(&abs)?; - } - } - let path_refs: Vec<&gix::bstr::BStr> = paths.iter().map(|b| b.as_ref()).collect(); - repo.untrack_vendor(&entry, &path_refs)?; - - // Remove the deleted vendor files from the index so `git commit` - // records the deletions rather than leaving them tracked. - let mut index = repo.open_index().map_err(|e| format!("{e}"))?; - for p in &path_refs { - let pb = p.as_bytes(); - index.remove_entries(|_, path, _| path == pb.as_bstr()); - } - index.sort_entries(); - index - .write(gix::index::write::Options::default()) - .map_err(|e| format!("{e}"))?; + if let Err(e) = run() { + if e.downcast_ref::().is_none() { + eprintln!("error: {e}"); } + std::process::exit(1); } - - config.remove(&name)?; - let config_str = save_config(&config, &cfg_path)?; - stage_gitvendors(&repo, config_str.as_bytes())?; - eprintln!("Removed vendor {name}."); - Ok(()) } -fn cmd_list() -> Result<()> { - let repo = discover()?; - let cfg_path = config_path(&repo)?; - let config = load_config(&cfg_path)?; - let entries = config.entries()?; - - if entries.is_empty() { - eprintln!("No vendors configured."); - return Ok(()); - } - - for entry in &entries { - let ref_label = entry.ref_name.as_deref().unwrap_or("HEAD"); - let mode_label = entry.mode.as_str(); - println!("{}\t{}\t{ref_label}\t{mode_label}", entry.name, entry.url); - } - - Ok(()) +fn run() -> Result<()> { + exe::Executor::discover()?.run(cli::Cli::parse(), &mut exe::Io::stdio()) } From b4fb40d53a6825aa3dd918c67a63ec4d0a7a97d2 Mon Sep 17 00:00:00 2001 From: "Joseph D. Carpinelli" Date: Sat, 13 Jun 2026 11:54:54 -0400 Subject: [PATCH 15/31] refactor: return blob OID from `track_vendor` and `untrack_vendor` Both methods now return the OID of the staged `.gitattributes` blob instead of `()`. `reconcile_tracked_paths` propagates the OID to its callers, eliminating the `staged_attrs_blob` round-trip that read the OID back out of the index after the fact. refactor: `track_vendor` -> `Result` refactor: `untrack_vendor` -> `Result, Error>` refactor: `stage_gitattributes` -> `Result` refactor: `reconcile_tracked_paths` -> `Result` refactor: remove `staged_attrs_blob` Assisted-by: Claude:claude-sonnet-4-6 --- crates/git-vendor/src/exe.rs | 36 +++++++++------------------------ crates/git-vendor/src/lib.rs | 31 ++++++++++++---------------- crates/git-vendor/src/vendor.rs | 8 ++++++-- 3 files changed, 28 insertions(+), 47 deletions(-) diff --git a/crates/git-vendor/src/exe.rs b/crates/git-vendor/src/exe.rs index 6685d54..9129b00 100644 --- a/crates/git-vendor/src/exe.rs +++ b/crates/git-vendor/src/exe.rs @@ -273,14 +273,13 @@ impl Executor { let full_tree = repo.checkout_vendor(&entry, merge.result_tree)?; let new_paths = tree_paths(repo, merge.result_tree)?; - reconcile_tracked_paths(repo, &entry, &old_paths, &new_paths)?; + let attrs_blob = reconcile_tracked_paths(repo, &entry, &old_paths, &new_paths)?; entry.base = Some(merge.upstream_commit); config.insert(&entry)?; let config_str = save_config(&config, &cfg_path)?; if auto_commit { - let attrs_blob = staged_attrs_blob(repo)?; let vendors_blob = stage_gitvendors(repo, config_str.as_bytes())?; let tree = final_tree(repo, full_tree, attrs_blob, vendors_blob)?; commit_and_advance(repo, &entry, &merge, tree, current_head, &msg)?; @@ -382,9 +381,7 @@ impl Executor { let full_tree = repo.checkout_vendor(&entry, new_tree)?; let new_paths = tree_paths(repo, new_tree)?; - reconcile_tracked_paths(repo, &entry, &old_paths, &new_paths)?; - - let attrs_blob = staged_attrs_blob(repo)?; + let attrs_blob = reconcile_tracked_paths(repo, &entry, &old_paths, &new_paths)?; let vendors_blob = stage_gitvendors(repo, config_str.as_bytes())?; let tree = final_tree(repo, full_tree, attrs_blob, vendors_blob)?; @@ -643,10 +640,10 @@ fn reconcile_tracked_paths( entry: &VendorEntry, old_paths: &[gix::bstr::BString], new_paths: &[gix::bstr::BString], -) -> Result<()> { +) -> Result { use gix::bstr::BStr; let track: Vec<&BStr> = new_paths.iter().map(|b| b.as_ref()).collect(); - repo.track_vendor(entry, &track)?; + let attrs_oid = repo.track_vendor(entry, &track)?; let new_set: std::collections::HashSet<&[u8]> = new_paths.iter().map(|b| b.as_slice()).collect(); @@ -655,27 +652,12 @@ fn reconcile_tracked_paths( .filter(|b| !new_set.contains(b.as_slice())) .map(|b| b.as_ref()) .collect(); - if !removed.is_empty() { - repo.untrack_vendor(entry, &removed)?; + if !removed.is_empty() + && let Some(oid) = repo.untrack_vendor(entry, &removed)? + { + return Ok(oid); } - Ok(()) -} - -/// Return the OID of the `.gitattributes` blob already staged in the index by -/// `track_vendor`, so callers can include it in a commit tree. -/// -/// Unlike `stage_gitvendors`, this writes nothing: `track_vendor` stages -/// `.gitattributes` as a working-copy side effect and this only reads the -/// resulting index entry back. -fn staged_attrs_blob(repo: &gix::Repository) -> Result { - use gix::bstr::ByteSlice as _; - let index = repo.open_index().map_err(|e| format!("{e}"))?; - index - .entries() - .iter() - .find(|e| e.path(&index) == b".gitattributes".as_bstr()) - .map(|e| e.id) - .ok_or_else(|| "no .gitattributes in index after tracking".into()) + Ok(attrs_oid) } /// Write `content` as a blob, upsert the `.gitvendors` index entry, and return diff --git a/crates/git-vendor/src/lib.rs b/crates/git-vendor/src/lib.rs index e1dfdf6..dedd3d4 100644 --- a/crates/git-vendor/src/lib.rs +++ b/crates/git-vendor/src/lib.rs @@ -673,7 +673,7 @@ impl VendorWorktree for gix::Repository { Ok(()) } - fn track_vendor(&self, entry: &VendorEntry, paths: &[&BStr]) -> Result<(), Error> { + fn track_vendor(&self, entry: &VendorEntry, paths: &[&BStr]) -> Result { let workdir = self.workdir().ok_or(Error::NoWorkdir)?; let gitattributes = workdir.join(".gitattributes"); @@ -719,16 +719,19 @@ impl VendorWorktree for gix::Repository { std::fs::write(&gitattributes, &out)?; } - stage_gitattributes(self, &out)?; - Ok(()) + stage_gitattributes(self, &out) } - fn untrack_vendor(&self, entry: &VendorEntry, paths: &[&BStr]) -> Result<(), Error> { + fn untrack_vendor( + &self, + entry: &VendorEntry, + paths: &[&BStr], + ) -> Result, Error> { let workdir = self.workdir().ok_or(Error::NoWorkdir)?; let gitattributes = workdir.join(".gitattributes"); if !gitattributes.exists() { - return Ok(()); + return Ok(None); } let existing: Vec = std::fs::read(&gitattributes)?; @@ -753,8 +756,7 @@ impl VendorWorktree for gix::Repository { std::fs::write(&gitattributes, &filtered)?; } - stage_gitattributes(self, &filtered)?; - Ok(()) + Ok(Some(stage_gitattributes(self, &filtered)?)) } fn prepare_merge( @@ -824,16 +826,9 @@ fn split_attr_line(line: &[u8]) -> Option<(std::borrow::Cow<'_, [u8]>, &[u8])> { #[path = "attr_tests.rs"] mod tests; -/// Write `content` as a blob into the object database and upsert the -/// `.gitattributes` index entry to point at it. -/// -/// This exists because [`VendorWorktree::track_vendor`] and -/// [`VendorWorktree::untrack_vendor`] write `.gitattributes` as a working-copy -/// side effect rather than folding it into the vendor tree before -/// `index_from_tree` runs. Ideally those methods would return a blob OID so -/// the caller could include `.gitattributes` in `full_tree` like any other -/// file, making this function unnecessary. -fn stage_gitattributes(repo: &gix::Repository, content: &[u8]) -> Result<(), Error> { +/// Write `content` as a blob into the object database, upsert the +/// `.gitattributes` index entry to point at it, and return the blob OID. +fn stage_gitattributes(repo: &gix::Repository, content: &[u8]) -> Result { let blob_oid = repo .write_object(gix::objs::BlobRef { data: content })? .detach(); @@ -852,5 +847,5 @@ fn stage_gitattributes(repo: &gix::Repository, content: &[u8]) -> Result<(), Err .write(gix::index::write::Options::default()) .map_err(|e| Error::Gix(Box::new(e)))?; - Ok(()) + Ok(blob_oid) } diff --git a/crates/git-vendor/src/vendor.rs b/crates/git-vendor/src/vendor.rs index 2e30ff9..7157cfd 100644 --- a/crates/git-vendor/src/vendor.rs +++ b/crates/git-vendor/src/vendor.rs @@ -637,12 +637,16 @@ pub trait VendorWorktree { /// This authors local-side membership (read back by /// [`VendorRepository::vendor_paths`](crate::VendorRepository::vendor_paths)); /// it is independent of the upstream pattern filter. - fn track_vendor(&self, entry: &VendorEntry, paths: &[&BStr]) -> Result<(), Error>; + fn track_vendor(&self, entry: &VendorEntry, paths: &[&BStr]) -> Result; /// Remove the given paths from the vendor's content filter, deleting their /// `vendor=` entries from the working-copy `.gitattributes` and /// staging the updated file into the index. - fn untrack_vendor(&self, entry: &VendorEntry, paths: &[&BStr]) -> Result<(), Error>; + fn untrack_vendor( + &self, + entry: &VendorEntry, + paths: &[&BStr], + ) -> Result, Error>; /// Stage the merge result for a subsequent `git commit`. /// From 329f141d0e7afa3e77158f5fe6e5b61cdd651218 Mon Sep 17 00:00:00 2001 From: "Joseph D. Carpinelli" Date: Fri, 3 Jul 2026 11:53:07 -0400 Subject: [PATCH 16/31] fix: force `refs/vendor/` to a direct ref after fetch gixs fetch machinery can write `refs/vendor/` as a symref into the local branch namespace instead of a direct ref to the fetched commit, when the remotes HEAD is symbolic and a local branch shares its target name (gitoxide#2613). `fetch_vendor` already reads the correct OID from the refmap to work around this for its own return value, but `vendor_tip`/`vendor_status` read the ref directly and were still silently resolving the corrupted symref to the local branch tip. Force-write the ref to the resolved OID after every fetch so all readers see the same, correct value; this also means the ref never stores an intermediate tag object for annotated tags, so it always matches `fetch_vendor`s peeled return value. fixes: `vendor_tip`/`vendor_status` reading a gix-corrupted local ref Assisted-by: Claude:claude-sonnet-5 --- crates/git-vendor/src/lib.rs | 20 ++++++++++++++--- crates/git-vendor/tests/fetch_vendor/table.rs | 22 +++++++++++++++---- 2 files changed, 35 insertions(+), 7 deletions(-) diff --git a/crates/git-vendor/src/lib.rs b/crates/git-vendor/src/lib.rs index dedd3d4..ca31847 100644 --- a/crates/git-vendor/src/lib.rs +++ b/crates/git-vendor/src/lib.rs @@ -34,9 +34,9 @@ fn is_unsafe_local_path(path: &gix::bstr::BStr) -> bool { impl VendorRepository for gix::Repository { /// Fetches `entry.tracking_ref()` from `entry.url` into `refs/vendor/` - /// and returns the *peeled* tip OID. When the tracked ref is an annotated - /// tag, the returned id is the tag's ultimate target, not the tag object - /// stored at `refs/vendor/`. + /// and returns the *peeled* tip OID. `refs/vendor/` is always written + /// to point directly at that same peeled OID, even when the tracked ref is + /// an annotated tag — the tag object itself is never stored there. /// /// If the local ref is already up to date, the ref tip's existing object hash /// is returned. @@ -148,6 +148,20 @@ impl VendorRepository for gix::Repository { entry.name )) })?; + + // Force `refs/vendor/` to point directly at `id`, overwriting + // whatever gix wrote for it (see the gix#2613 note above: it may be a + // symref into the local branch namespace rather than a direct ref to + // the fetched commit). This keeps `vendor_tip`/`vendor_status`, which + // read the ref directly, from resolving the corrupted symref. + self.reference( + entry.vendor_ref(), + id, + gix::refs::transaction::PreviousValue::Any, + format!("fetch {}", entry.tracking_ref()), + ) + .map_err(|e| Error::Gix(Box::new(e)))?; + Ok(id) } diff --git a/crates/git-vendor/tests/fetch_vendor/table.rs b/crates/git-vendor/tests/fetch_vendor/table.rs index 1fd8ec9..e9ada2a 100644 --- a/crates/git-vendor/tests/fetch_vendor/table.rs +++ b/crates/git-vendor/tests/fetch_vendor/table.rs @@ -111,8 +111,9 @@ fn fetch_force_updates_on_upstream_rewrite() { ); } -/// Fetching an annotated tag stores the tag object at `refs/vendor/` but -/// returns the tag's ultimate (peeled) target, per the documented contract. +/// Fetching an annotated tag returns the tag's ultimate (peeled) target and +/// stores that same peeled commit at `refs/vendor/` — never the tag +/// object itself, per the documented contract. #[test] fn fetch_peels_annotated_tag() { let upstream = tempfile::tempdir().unwrap(); @@ -132,8 +133,8 @@ fn fetch_peels_annotated_tag() { let reference = repo.find_reference(&entry.vendor_ref()).expect("find ref"); assert_eq!( reference.id().detach(), - tag_obj, - "stored ref must point at the tag object itself", + commit, + "stored ref must point directly at the peeled commit, not the tag object", ); } @@ -319,4 +320,17 @@ fn fetch_returns_upstream_tip_into_non_bare_local() { "fetched tree lacks the upstream-only `up/marker.txt`: upstream \ objects were not actually brought into the local odb", ); + + // 3. `vendor_tip` (and by extension `vendor_status`) must read the same + // corrected value back from `refs/vendor/`, not the + // gix#2613-corrupted symref pointing at the local branch. + let tip = repo + .vendor_tip(&entry) + .expect("vendor_tip") + .expect("some tip"); + assert_eq!( + tip, upstream_head, + "vendor_tip must not resolve the gix#2613-corrupted symref to the \ + local branch", + ); } From f895e2b5c6975b39958b477908a0179b7f97b995 Mon Sep 17 00:00:00 2001 From: "Joseph D. Carpinelli" Date: Fri, 3 Jul 2026 11:55:28 -0400 Subject: [PATCH 17/31] fix: peel fetched OID to a commit unconditionally `peeled_id()` on the refmap mapping only returns a peeled value when the ref advertisement carried one. Fetching an annotated tag by name gets that advertisement, but fetching by the tag objects own SHA (`Source::ObjectId`) does not, so `fetch_vendor` could return the raw tag object instead of its target commit. Downstream code assumes `entry.base` and the vendor ref always name a commit. Peel explicitly after the refmap lookup so both fetch paths agree. fixes: `fetch_vendor` returning an unpeeled tag object for `--ref ` Assisted-by: Claude:claude-sonnet-5 --- crates/git-vendor/src/lib.rs | 12 +++++++++ crates/git-vendor/tests/fetch_vendor/table.rs | 27 +++++++++++++++++++ 2 files changed, 39 insertions(+) diff --git a/crates/git-vendor/src/lib.rs b/crates/git-vendor/src/lib.rs index ca31847..b9e8712 100644 --- a/crates/git-vendor/src/lib.rs +++ b/crates/git-vendor/src/lib.rs @@ -149,6 +149,18 @@ impl VendorRepository for gix::Repository { )) })?; + // `peeled_id()` only returns a peeled value when the ref advertisement + // carried one; fetching an annotated tag by its own object SHA + // (`Source::ObjectId`) has no such advertisement, so `id` may still be + // the tag object itself. Peel explicitly so callers always get a commit. + let id = self + .find_object(id) + .map_err(|e| Error::Gix(Box::new(e)))? + .peel_to_commit() + .map_err(|e| Error::Gix(Box::new(e)))? + .id() + .detach(); + // Force `refs/vendor/` to point directly at `id`, overwriting // whatever gix wrote for it (see the gix#2613 note above: it may be a // symref into the local branch namespace rather than a direct ref to diff --git a/crates/git-vendor/tests/fetch_vendor/table.rs b/crates/git-vendor/tests/fetch_vendor/table.rs index e9ada2a..623294e 100644 --- a/crates/git-vendor/tests/fetch_vendor/table.rs +++ b/crates/git-vendor/tests/fetch_vendor/table.rs @@ -138,6 +138,33 @@ fn fetch_peels_annotated_tag() { ); } +/// Fetching by the annotated tag's own object SHA (`Source::ObjectId`, as +/// opposed to fetching it by name) must still return the peeled commit, not +/// the raw tag object. +#[test] +fn fetch_by_oid_peels_annotated_tag() { + let upstream = tempfile::tempdir().unwrap(); + let local = tempfile::tempdir().unwrap(); + + let commit = make_upstream(upstream.path()); + git(&["tag", "-a", "v1", "-m", "release one"], upstream.path()); + let tag_obj = rev_parse(upstream.path(), "v1"); + assert_ne!(tag_obj, commit, "annotated tag must be its own object"); + + let repo = make_local(local.path()); + let entry = make_entry( + upstream.path().to_str().unwrap(), + Some(&tag_obj.to_string()), + vec![], + ); + + let got = repo.fetch_vendor(&entry).expect("fetch_vendor"); + assert_eq!( + got, commit, + "returned id must be the peeled commit, not the tag object fetched by SHA" + ); +} + /// `fetch_vendor` does not pull upstream tags into the local repo; only the /// configured tracking ref is fetched. #[test] From 9ce6bb3de7e413300a2b379f270ca76bd6043057 Mon Sep 17 00:00:00 2001 From: "Joseph D. Carpinelli" Date: Fri, 3 Jul 2026 11:57:52 -0400 Subject: [PATCH 18/31] fix: propagate `vendor_paths` errors in `update`/`apply` Both call sites used `.unwrap_or_default()`, so a real error (e.g. an object-database read failure) was silently treated as "vendor owns no old paths". `reconcile_tracked_paths` would then never untrack files the upstream removed, leaving stale `vendor=` lines in `.gitattributes` that misclassify later user files as vendor-owned. `tests/vendor_paths/table.rs` (`non_commit_ours_is_error`, `nonexistent_oid_ours_is_error`) already proves `vendor_paths` returns `Err` for bad input; a CLI-level repro that reaches this exact call site is impractical since `current_head` there is always a real HEAD commit, so no new test is added for the `update`/`apply` path itself. fixes: `update`/`apply` silently ignoring `vendor_paths` failures Assisted-by: Claude:claude-sonnet-5 --- crates/git-vendor/src/exe.rs | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/crates/git-vendor/src/exe.rs b/crates/git-vendor/src/exe.rs index 9129b00..81e0c19 100644 --- a/crates/git-vendor/src/exe.rs +++ b/crates/git-vendor/src/exe.rs @@ -251,8 +251,7 @@ impl Executor { .clone() .unwrap_or_else(|| format!("vendor: update {n}")); - let old_paths: Vec = - repo.vendor_paths(&entry, current_head).unwrap_or_default(); + let old_paths: Vec = repo.vendor_paths(&entry, current_head)?; let merge = repo.merge_vendor(&entry, current_head, upstream)?; @@ -376,8 +375,7 @@ impl Executor { } let new_tree = repo.upstream_tree(&entry, base)?; - let old_paths: Vec = - repo.vendor_paths(&entry, current_head).unwrap_or_default(); + let old_paths: Vec = repo.vendor_paths(&entry, current_head)?; let full_tree = repo.checkout_vendor(&entry, new_tree)?; let new_paths = tree_paths(repo, new_tree)?; From a75768ad4aa5f86370970417f7b7d1bf13d58314 Mon Sep 17 00:00:00 2001 From: "Joseph D. Carpinelli" Date: Fri, 3 Jul 2026 12:02:29 -0400 Subject: [PATCH 19/31] fix: make advance_head a compare-and-swap on HEAD Every caller already knows the exact parent commit it started from, but advance_head hardcoded `PreviousValue::Any`, so anything else moving HEAD between the snapshot and the ref write (a hook, a concurrent process, a slow multi-vendor update loop) got silently overwritten with no error. Take the expected parent explicitly and require HEAD to still match it at write time. fixes: advance_head force-moving HEAD instead of compare-and-swapping Assisted-by: Claude:claude-sonnet-5 --- crates/git-vendor/src/exe.rs | 94 ++++++++++++++++++++++++++++++++++-- 1 file changed, 89 insertions(+), 5 deletions(-) diff --git a/crates/git-vendor/src/exe.rs b/crates/git-vendor/src/exe.rs index 81e0c19..91dca37 100644 --- a/crates/git-vendor/src/exe.rs +++ b/crates/git-vendor/src/exe.rs @@ -415,7 +415,7 @@ impl Executor { extra_headers: Vec::new(), }; let new_commit = repo.write_object(&commit)?.detach(); - advance_head(repo, new_commit, &msg)?; + advance_head(repo, new_commit, current_head, &msg)?; current_head = new_commit; writeln!(io.err, "Applied {n}.")?; } @@ -592,7 +592,12 @@ fn config_at(repo: &gix::Repository, commit: gix::ObjectId) -> Result Result<()> { +fn advance_head( + repo: &gix::Repository, + new_commit: gix::ObjectId, + parent: gix::ObjectId, + msg: &str, +) -> Result<()> { use gix::refs::Target; use gix::refs::transaction::{Change, LogChange, PreviousValue, RefEdit, RefLog}; @@ -604,12 +609,15 @@ fn advance_head(repo: &gix::Repository, new_commit: gix::ObjectId, msg: &str) -> force_create_reflog: false, message: msg.as_bytes().into(), }, - expected: PreviousValue::Any, + expected: PreviousValue::MustExistAndMatch(Target::Object(parent)), new: Target::Object(new_commit), }, name, deref: true, - }])?; + }]) + .map_err(|e| { + format!("HEAD moved unexpectedly since the update started; aborting to avoid clobbering a concurrent commit: {e}") + })?; Ok(()) } @@ -762,7 +770,7 @@ fn commit_and_advance( extra_headers: Vec::new(), }; let new_commit = repo.write_object(&commit)?.detach(); - advance_head(repo, new_commit, message) + advance_head(repo, new_commit, parent, message) } fn name_from_url(url: &str) -> Option { @@ -780,3 +788,79 @@ fn name_from_url(url: &str) -> Option { Some(stem.to_owned()) } } + +#[cfg(test)] +mod tests { + use super::*; + use std::path::Path; + + fn git(args: &[&str], dir: &Path) { + let output = std::process::Command::new("git") + .args(args) + .current_dir(dir) + .env("GIT_CONFIG_NOSYSTEM", "1") + .env("GIT_CONFIG_GLOBAL", "/dev/null") + .output() + .expect("git"); + assert!( + output.status.success(), + "git {args:?} failed:\n{}", + String::from_utf8_lossy(&output.stderr) + ); + } + + /// `advance_head` must reject a stale `parent` instead of silently + /// overwriting a commit another process made after the caller + /// snapshotted `current_head`. + #[test] + fn advance_head_rejects_stale_parent() { + let dir = tempfile::tempdir().unwrap(); + git(&["init", "-q", "-b", "main"], dir.path()); + git(&["config", "user.email", "t@example.com"], dir.path()); + git(&["config", "user.name", "T"], dir.path()); + std::fs::write(dir.path().join("f"), "one").unwrap(); + git(&["add", "f"], dir.path()); + git(&["commit", "-q", "-m", "one"], dir.path()); + + let repo = gix::open(dir.path()).expect("gix open"); + let stale_parent = repo.head_commit().expect("head").id().detach(); + let tree = repo + .head_commit() + .expect("head") + .tree_id() + .expect("tree") + .detach(); + + // Simulate a concurrent writer advancing HEAD after we snapshotted it. + git( + &["commit", "-q", "--allow-empty", "-m", "concurrent"], + dir.path(), + ); + let concurrent = repo.head_commit().expect("head").id().detach(); + assert_ne!(concurrent, stale_parent); + + let author = author_sig(&repo).expect("author"); + let committer = committer_sig(&repo).expect("committer"); + let mut tbuf_a = gix::date::parse::TimeBuf::default(); + let mut tbuf_c = gix::date::parse::TimeBuf::default(); + let commit = gix::objs::Commit { + tree, + parents: [stale_parent].into_iter().collect(), + author: author.to_ref(&mut tbuf_a).into(), + committer: committer.to_ref(&mut tbuf_c).into(), + encoding: None, + message: "stale update".into(), + extra_headers: Vec::new(), + }; + let new_commit = repo.write_object(&commit).expect("write").detach(); + + let result = advance_head(&repo, new_commit, stale_parent, "stale update"); + assert!(result.is_err(), "advance_head must reject a stale parent"); + + let head_after = repo.head_commit().expect("head").id().detach(); + assert_eq!( + head_after, concurrent, + "the concurrent commit must remain HEAD after the stale write is rejected" + ); + } +} From 1784c1703556405b280affc12a380448169043a7 Mon Sep 17 00:00:00 2001 From: "Joseph D. Carpinelli" Date: Fri, 3 Jul 2026 12:04:12 -0400 Subject: [PATCH 20/31] fix: untrack .gitattributes on `remove --keep-files` `untrack_vendor` was called only inside the `!keep_files` branch, so `--keep-files` left stale `vendor=` lines in `.gitattributes` even though its own help text promises to remove config and attribute tracking while leaving the files in place. A later `add` of the same vendor name would then see the kept files as already vendor-owned via those stale attributes. Split the function so untracking always runs when the entry has tracked paths, and only file/index deletion is gated on `!keep_files`. fixes: `remove --keep-files` leaving stale `.gitattributes` entries Assisted-by: Claude:claude-sonnet-5 --- crates/git-vendor/src/exe.rs | 18 ++++++++------ crates/git-vendor/tests/cli/remove.rs | 35 +++++++++++++++++++++++++++ 2 files changed, 46 insertions(+), 7 deletions(-) diff --git a/crates/git-vendor/src/exe.rs b/crates/git-vendor/src/exe.rs index 91dca37..897acb9 100644 --- a/crates/git-vendor/src/exe.rs +++ b/crates/git-vendor/src/exe.rs @@ -466,22 +466,26 @@ impl Executor { let entry = require_entry(&config, &name)?; - if !keep_files { - let head_oid = repo.head_commit().ok().map(|c| c.id().detach()); + let head_oid = repo.head_commit().ok().map(|c| c.id().detach()); + + if let Some(oid) = head_oid { + use gix::bstr::ByteSlice as _; + let paths = repo.vendor_paths(&entry, oid)?; + let path_refs: Vec<&gix::bstr::BStr> = paths.iter().map(|b| b.as_ref()).collect(); - if let Some(oid) = head_oid { - use gix::bstr::ByteSlice as _; + if !keep_files { let workdir = repo.workdir().ok_or("not a working-copy repository")?; - let paths = repo.vendor_paths(&entry, oid)?; for p in &paths { let abs = workdir.join(gix::path::from_bstr(p).as_ref()); if abs.symlink_metadata().is_ok() { std::fs::remove_file(&abs)?; } } - let path_refs: Vec<&gix::bstr::BStr> = paths.iter().map(|b| b.as_ref()).collect(); - repo.untrack_vendor(&entry, &path_refs)?; + } + + repo.untrack_vendor(&entry, &path_refs)?; + if !keep_files { let mut index = repo.open_index().map_err(|e| format!("{e}"))?; for p in &path_refs { let pb = p.as_bytes(); diff --git a/crates/git-vendor/tests/cli/remove.rs b/crates/git-vendor/tests/cli/remove.rs index 94bbd1c..c0349e4 100644 --- a/crates/git-vendor/tests/cli/remove.rs +++ b/crates/git-vendor/tests/cli/remove.rs @@ -49,3 +49,38 @@ fn remove_stages_updated_gitvendors() { "staged .gitvendors must not reference the removed vendor, but was:\n{staged}", ); } + +/// `remove --keep-files` must still untrack the vendor's `.gitattributes` +/// entries, even though it leaves the files themselves on disk. Regression: +/// `untrack_vendor` was called only in the `!keep_files` branch, so +/// `--keep-files` left stale `vendor=` lines behind. +#[test] +fn remove_keep_files_still_untracks_gitattributes() { + let upstream = tempfile::tempdir().unwrap(); + let local = tempfile::tempdir().unwrap(); + + make_upstream(upstream.path()); + + init(local.path()); + write(local.path(), "README", b"local\n"); + git(&["add", "-A"], local.path()); + git(&["commit", "-m", "init"], local.path()); + + let url = upstream.path().to_str().unwrap(); + vendor_ok(&["add", url, "mylib"], local.path()); + git(&["commit", "-m", "vendor: add mylib"], local.path()); + + vendor_ok(&["remove", "--keep-files", "mylib"], local.path()); + + assert!( + local.path().join("vendor/mylib/hello.txt").exists(), + "--keep-files must leave the vendored files on disk", + ); + + let staged = + String::from_utf8(git_capture(&["show", ":.gitattributes"], local.path())).unwrap(); + assert!( + !staged.contains("vendor=mylib"), + "staged .gitattributes must not reference the removed vendor, but was:\n{staged}", + ); +} From a796f3ca2d56beedf838e5d1e2a1cb438ce2480d Mon Sep 17 00:00:00 2001 From: "Joseph D. Carpinelli" Date: Fri, 3 Jul 2026 12:08:02 -0400 Subject: [PATCH 21/31] fix: make `remove` work on an unborn HEAD MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit File deletion, `untrack_vendor`, and index cleanup were all gated on `repo.head_commit()` succeeding. On an unborn HEAD (`git init; git vendor add ...` before the first commit), `remove` printed "Removed vendor" and exited zero, but only the `.gitvendors` entry actually went away — vendored files stayed on disk, in the index, and in `.gitattributes`. `vendor_paths` needs a commit to read a tree from, so add `resolve_vendor_paths_uncommitted` to resolve the same `vendor=` attribute selection from the current index instead, and use it when there is no HEAD commit yet. fixes: `remove` on an unborn HEAD leaving the vendor in place Assisted-by: Claude:claude-sonnet-5 --- crates/git-vendor/src/exe.rs | 9 +++++-- crates/git-vendor/src/lib.rs | 36 +++++++++++++++++++++++++ crates/git-vendor/tests/cli/remove.rs | 38 +++++++++++++++++++++++++++ 3 files changed, 81 insertions(+), 2 deletions(-) diff --git a/crates/git-vendor/src/exe.rs b/crates/git-vendor/src/exe.rs index 897acb9..e10adc3 100644 --- a/crates/git-vendor/src/exe.rs +++ b/crates/git-vendor/src/exe.rs @@ -467,10 +467,15 @@ impl Executor { let entry = require_entry(&config, &name)?; let head_oid = repo.head_commit().ok().map(|c| c.id().detach()); + let paths = match head_oid { + Some(oid) => repo.vendor_paths(&entry, oid)?, + // No commit yet: resolve from the staged index instead, since + // there is no tree to read paths from (e.g. right after `add`). + None => git_vendor::resolve_vendor_paths_uncommitted(repo, &entry)?, + }; - if let Some(oid) = head_oid { + { use gix::bstr::ByteSlice as _; - let paths = repo.vendor_paths(&entry, oid)?; let path_refs: Vec<&gix::bstr::BStr> = paths.iter().map(|b| b.as_ref()).collect(); if !keep_files { diff --git a/crates/git-vendor/src/lib.rs b/crates/git-vendor/src/lib.rs index b9e8712..0b53fa4 100644 --- a/crates/git-vendor/src/lib.rs +++ b/crates/git-vendor/src/lib.rs @@ -560,6 +560,42 @@ fn resolve_vendor_paths( Ok(paths) } +/// Like [`resolve_vendor_paths`], but resolves against the current on-disk +/// index instead of a commit's tree. For use on an unborn `HEAD`, where +/// staged entries exist (e.g. right after `add`) but there is no commit yet +/// to read a tree from. Index entries are always files, so no tree-vs-blob +/// filtering is needed. +pub fn resolve_vendor_paths_uncommitted( + repo: &gix::Repository, + entry: &VendorEntry, +) -> Result, Error> { + let index = repo.open_index().map_err(|e| Error::Gix(Box::new(e)))?; + let mut stack = repo.attributes_only( + &index, + gix::worktree::stack::state::attributes::Source::IdMapping, + )?; + let mut outcome = stack.selected_attribute_matches(["vendor"]); + + let mut paths = Vec::new(); + for e in index.entries() { + let path = e.path(&index).to_owned(); + let platform = stack.at_entry(path.as_bstr(), None)?; + outcome.reset(); + platform.matching_attributes(&mut outcome); + let is_ours = outcome.iter_selected().any(|m| { + matches!( + m.assignment.state, + gix::attrs::StateRef::Value(v) + if v.as_bstr() == entry.name.as_bytes().as_bstr() + ) + }); + if is_ours { + paths.push(path); + } + } + Ok(paths) +} + // ── worktree impl ──────────────────────────────────────────────────────────── impl VendorWorktree for gix::Repository { diff --git a/crates/git-vendor/tests/cli/remove.rs b/crates/git-vendor/tests/cli/remove.rs index c0349e4..4b6bb63 100644 --- a/crates/git-vendor/tests/cli/remove.rs +++ b/crates/git-vendor/tests/cli/remove.rs @@ -84,3 +84,41 @@ fn remove_keep_files_still_untracks_gitattributes() { "staged .gitattributes must not reference the removed vendor, but was:\n{staged}", ); } + +/// `remove` on an unborn HEAD (before the first commit) must actually remove +/// the vendor, not just its `.gitvendors` entry. Regression: file deletion, +/// `untrack_vendor`, and index cleanup were all gated on `repo.head_commit()` +/// succeeding, so on an unborn HEAD `remove` reported success while leaving +/// vendored files on disk, in the index, and in `.gitattributes`. +#[test] +fn remove_before_first_commit_removes_vendor() { + let upstream = tempfile::tempdir().unwrap(); + let local = tempfile::tempdir().unwrap(); + + make_upstream(upstream.path()); + init(local.path()); + + let url = upstream.path().to_str().unwrap(); + vendor_ok(&["add", url, "mylib"], local.path()); + + vendor_ok(&["remove", "mylib"], local.path()); + + assert!( + !local.path().join("vendor/mylib/hello.txt").exists(), + "remove must delete vendored files even on an unborn HEAD", + ); + + let tracked = String::from_utf8(git_capture(&["ls-files"], local.path())).unwrap(); + assert!( + !tracked.lines().any(|l| l.starts_with("vendor/mylib/")), + "no vendor/mylib/* path should remain in the index, but ls-files was:\n{tracked}", + ); + + if local.path().join(".gitattributes").exists() { + let contents = std::fs::read_to_string(local.path().join(".gitattributes")).unwrap(); + assert!( + !contents.contains("vendor=mylib"), + ".gitattributes must not reference the removed vendor, but was:\n{contents}", + ); + } +} From cdb481c74f62f5ec43631ae8ab34b261394cf6d5 Mon Sep 17 00:00:00 2001 From: "Joseph D. Carpinelli" Date: Fri, 3 Jul 2026 12:11:58 -0400 Subject: [PATCH 22/31] fix: validate trackable paths before checkout mutates state MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `check_attr_pattern` rejects any upstream path that would need C-style quoting in `.gitattributes`, but it only ran inside `track_vendor`, which every `add`/`update`/`apply` call site invokes *after* `checkout_vendor`/`checkout_vendor_conflicted` has already rewritten the working tree and index. An upstream containing an unquotable path (e.g. a filename with a space) left vendor files checked out and staged with no `.gitvendors` entry and no `.gitattributes` tracking — a half-applied state with no way to retry, since the vendor could never be added at all. Expose the check as `validate_trackable_paths` and call it against the merge/upstream tree's paths before any checkout, in every branch of `add`, `update`, and `apply`. fixes: `add`/`update`/`apply` leaving a half-applied checkout on an unquotable path Assisted-by: Claude:claude-sonnet-5 --- crates/git-vendor/src/exe.rs | 19 +++++++---- crates/git-vendor/src/lib.rs | 13 ++++++++ crates/git-vendor/tests/cli.rs | 1 + crates/git-vendor/tests/cli/add.rs | 51 ++++++++++++++++++++++++++++++ 4 files changed, 78 insertions(+), 6 deletions(-) create mode 100644 crates/git-vendor/tests/cli/add.rs diff --git a/crates/git-vendor/src/exe.rs b/crates/git-vendor/src/exe.rs index e10adc3..deac15f 100644 --- a/crates/git-vendor/src/exe.rs +++ b/crates/git-vendor/src/exe.rs @@ -140,10 +140,13 @@ impl Executor { match head_oid { Some(ours) => { let merge = repo.merge_vendor(&entry, ours, upstream)?; + let new_paths = tree_paths(repo, merge.result_tree)?; + let path_refs: Vec<&gix::bstr::BStr> = + new_paths.iter().map(|b| b.as_ref()).collect(); + git_vendor::validate_trackable_paths(&path_refs)?; if merge.has_conflicts() { repo.checkout_vendor_conflicted(&entry, &merge)?; - let new_paths = tree_paths(repo, merge.result_tree)?; reconcile_tracked_paths(repo, &entry, &[], &new_paths)?; entry.base = Some(merge.upstream_commit); config.insert(&entry)?; @@ -157,7 +160,6 @@ impl Executor { } let _full_tree = repo.checkout_vendor(&entry, merge.result_tree)?; - let new_paths = tree_paths(repo, merge.result_tree)?; reconcile_tracked_paths(repo, &entry, &[], &new_paths)?; entry.base = Some(merge.upstream_commit); @@ -170,10 +172,12 @@ impl Executor { } None => { let tree = repo.upstream_tree(&entry, upstream)?; - let _full_tree = repo.checkout_vendor(&entry, tree)?; let new_paths = tree_paths(repo, tree)?; let path_refs: Vec<&gix::bstr::BStr> = new_paths.iter().map(|b| b.as_ref()).collect(); + git_vendor::validate_trackable_paths(&path_refs)?; + + let _full_tree = repo.checkout_vendor(&entry, tree)?; repo.track_vendor(&entry, &path_refs)?; entry.base = Some(upstream); @@ -254,10 +258,12 @@ impl Executor { let old_paths: Vec = repo.vendor_paths(&entry, current_head)?; let merge = repo.merge_vendor(&entry, current_head, upstream)?; + let new_paths = tree_paths(repo, merge.result_tree)?; + let path_refs: Vec<&gix::bstr::BStr> = new_paths.iter().map(|b| b.as_ref()).collect(); + git_vendor::validate_trackable_paths(&path_refs)?; if merge.has_conflicts() { repo.checkout_vendor_conflicted(&entry, &merge)?; - let new_paths = tree_paths(repo, merge.result_tree)?; reconcile_tracked_paths(repo, &entry, &old_paths, &new_paths)?; entry.base = Some(merge.upstream_commit); config.insert(&entry)?; @@ -271,7 +277,6 @@ impl Executor { } let full_tree = repo.checkout_vendor(&entry, merge.result_tree)?; - let new_paths = tree_paths(repo, merge.result_tree)?; let attrs_blob = reconcile_tracked_paths(repo, &entry, &old_paths, &new_paths)?; entry.base = Some(merge.upstream_commit); @@ -376,9 +381,11 @@ impl Executor { let new_tree = repo.upstream_tree(&entry, base)?; let old_paths: Vec = repo.vendor_paths(&entry, current_head)?; + let new_paths = tree_paths(repo, new_tree)?; + let path_refs: Vec<&gix::bstr::BStr> = new_paths.iter().map(|b| b.as_ref()).collect(); + git_vendor::validate_trackable_paths(&path_refs)?; let full_tree = repo.checkout_vendor(&entry, new_tree)?; - let new_paths = tree_paths(repo, new_tree)?; let attrs_blob = reconcile_tracked_paths(repo, &entry, &old_paths, &new_paths)?; let vendors_blob = stage_gitvendors(repo, config_str.as_bytes())?; let tree = final_tree(repo, full_tree, attrs_blob, vendors_blob)?; diff --git a/crates/git-vendor/src/lib.rs b/crates/git-vendor/src/lib.rs index 0b53fa4..f0c8110 100644 --- a/crates/git-vendor/src/lib.rs +++ b/crates/git-vendor/src/lib.rs @@ -841,6 +841,19 @@ impl VendorWorktree for gix::Repository { } } +/// Check that every path in `paths` can be written as a plain (unquoted) +/// `.gitattributes` pattern. Callers that will later checkout files and +/// mutate the working tree/index should validate paths with this *before* +/// doing so, so an invalid path aborts cleanly instead of leaving a +/// half-applied checkout behind — see [`check_attr_pattern`] for what's +/// rejected. +pub fn validate_trackable_paths(paths: &[&BStr]) -> Result<(), Error> { + for p in paths { + check_attr_pattern(p.as_bytes())?; + } + Ok(()) +} + /// Return `Err` if `path` contains characters that require C-style quoting in /// `.gitattributes` (space, tab, `#`, `"`, `\`, or control characters). /// Git source paths from tree objects never contain these in practice. diff --git a/crates/git-vendor/tests/cli.rs b/crates/git-vendor/tests/cli.rs index f653a71..34d86ec 100644 --- a/crates/git-vendor/tests/cli.rs +++ b/crates/git-vendor/tests/cli.rs @@ -1,5 +1,6 @@ mod support; mod cli { + mod add; mod remove; mod update; } diff --git a/crates/git-vendor/tests/cli/add.rs b/crates/git-vendor/tests/cli/add.rs new file mode 100644 index 0000000..3de90f7 --- /dev/null +++ b/crates/git-vendor/tests/cli/add.rs @@ -0,0 +1,51 @@ +//! End-to-end tests for `git-vendor add`. + +use crate::support::{git, git_capture, init, vendor, write}; + +/// `add` must validate that every upstream path can be written as an +/// unquoted `.gitattributes` pattern *before* checking out any files, so an +/// unquotable path (e.g. containing a space) aborts cleanly instead of +/// leaving a half-applied checkout: vendored files present on disk and +/// staged in the index, but no `.gitvendors` entry and no `.gitattributes` +/// tracking. Regression: the check ran inside `track_vendor`, which is +/// called only *after* `checkout_vendor` has already mutated the working +/// tree and index. +#[test] +fn add_rejects_unquotable_path_without_partial_mutation() { + let upstream = tempfile::tempdir().unwrap(); + let local = tempfile::tempdir().unwrap(); + + init(upstream.path()); + write(upstream.path(), "read me.txt", b"hello\n"); + git(&["add", "-A"], upstream.path()); + git(&["commit", "-m", "c"], upstream.path()); + + init(local.path()); + write(local.path(), "README", b"local\n"); + git(&["add", "-A"], local.path()); + git(&["commit", "-m", "init"], local.path()); + + let url = upstream.path().to_str().unwrap(); + let out = vendor(&["add", url, "mylib"], local.path()); + assert!( + !out.status.success(), + "add must fail when an upstream path cannot be written as an unquoted \ + .gitattributes pattern", + ); + + assert!( + !local.path().join("vendor").exists(), + "no partial checkout should be left behind on disk", + ); + + let status = String::from_utf8(git_capture(&["status", "--porcelain"], local.path())).unwrap(); + assert!( + status.is_empty(), + "working tree/index must be untouched after the rejected add, but status was:\n{status}", + ); + + assert!( + !local.path().join(".gitvendors").exists(), + ".gitvendors must not be created by a rejected add", + ); +} From 1b4bd8482c6b679f5cc68b5128f8fa6499cdfc46 Mon Sep 17 00:00:00 2001 From: "Joseph D. Carpinelli" Date: Fri, 3 Jul 2026 12:16:19 -0400 Subject: [PATCH 23/31] fix: escape glob metacharacters when writing .gitattributes patterns `check_attr_pattern` rejected whitespace/quote/control characters but not `*`, `?`, `[`, or a pattern-initial `!`/`#`, and `track_vendor` wrote the raw upstream path bytes verbatim as the pattern. A file literally named `a*` became the live glob `vendor/mylib/a*`, matching unrelated siblings, which `checkout_vendor`'s cleanup or `remove` could then delete as if they were vendor-owned. A file named `a[0].c` had the opposite problem: its own generated pattern never matched it. Escape `*`/`?`/`[`/leading `!`/`#` with a backslash on write, and unescape symmetrically wherever a parsed pattern is compared back against a raw path (dedup in `track_vendor`, removal in `untrack_vendor`) so re-running `update` doesn't see every escaped line as new and duplicate it. fixes: unescaped glob metacharacters in generated `.gitattributes` patterns Assisted-by: Claude:claude-sonnet-5 --- crates/git-vendor/src/lib.rs | 42 +++++++++++++++++-- crates/git-vendor/tests/track_vendor/table.rs | 41 ++++++++++++++++++ crates/git-vendor/tests/vendor_paths/table.rs | 38 ++++++++++++++++- 3 files changed, 116 insertions(+), 5 deletions(-) diff --git a/crates/git-vendor/src/lib.rs b/crates/git-vendor/src/lib.rs index f0c8110..ac4d0b1 100644 --- a/crates/git-vendor/src/lib.rs +++ b/crates/git-vendor/src/lib.rs @@ -757,7 +757,7 @@ impl VendorWorktree for gix::Repository { .filter_map(|line| { let (pattern, attr) = split_attr_line(line)?; if attr == attr_bytes { - Some(pattern.into_owned()) + Some(unescape_attr_pattern(&pattern)) } else { None } @@ -770,7 +770,7 @@ impl VendorWorktree for gix::Repository { } for path in paths { if !already_tracked.contains(path.as_bytes()) { - out.extend_from_slice(path.as_bytes()); + out.extend_from_slice(&escape_attr_pattern(path.as_bytes())); out.push(b' '); out.extend_from_slice(attr_bytes); out.push(b'\n'); @@ -805,7 +805,10 @@ impl VendorWorktree for gix::Repository { let mut filtered: Vec = Vec::with_capacity(existing.len()); for line in existing.lines() { let keep = match split_attr_line(line) { - Some((pattern, attr)) => !(attr == attr_bytes && remove.contains(pattern.as_ref())), + Some((pattern, attr)) => { + !(attr == attr_bytes + && remove.contains(unescape_attr_pattern(&pattern).as_slice())) + } None => true, }; if keep { @@ -867,6 +870,39 @@ fn check_attr_pattern(path: &[u8]) -> Result<(), Error> { Ok(()) } +/// Escape glob metacharacters (`*`, `?`, `[`) and a pattern-initial `!` or `#` +/// with a backslash, so `path` matches only itself as a `.gitattributes` +/// pattern. `check_attr_pattern` already rejects a raw `\` in the input, so +/// every backslash in the result is unambiguously one we inserted here. +fn escape_attr_pattern(path: &[u8]) -> Vec { + let mut out = Vec::with_capacity(path.len()); + for (i, &b) in path.iter().enumerate() { + if matches!(b, b'*' | b'?' | b'[') || (i == 0 && matches!(b, b'!' | b'#')) { + out.push(b'\\'); + } + out.push(b); + } + out +} + +/// Inverse of [`escape_attr_pattern`]. +fn unescape_attr_pattern(pattern: &[u8]) -> Vec { + let mut out = Vec::with_capacity(pattern.len()); + let mut i = 0; + while i < pattern.len() { + if pattern[i] == b'\\' + && i + 1 < pattern.len() + && (matches!(pattern[i + 1], b'*' | b'?' | b'[') + || (i == 0 && matches!(pattern[i + 1], b'!' | b'#'))) + { + i += 1; + } + out.push(pattern[i]); + i += 1; + } + out +} + /// Parse one `.gitattributes` line into `(unquoted_pattern, trimmed_attrs)`. /// /// Returns `None` for blank lines, comment lines, or lines with no attribute diff --git a/crates/git-vendor/tests/track_vendor/table.rs b/crates/git-vendor/tests/track_vendor/table.rs index c7e581f..252ce2f 100644 --- a/crates/git-vendor/tests/track_vendor/table.rs +++ b/crates/git-vendor/tests/track_vendor/table.rs @@ -158,3 +158,44 @@ fn bare_repo_returns_no_workdir_error() { .unwrap_err(); assert!(matches!(err, git_vendor::Error::NoWorkdir), "{err:?}"); } + +/// A path containing a glob metacharacter (`*`) is written with it escaped, +/// so the pattern matches only that literal path rather than becoming a live +/// glob. Regression: `track_vendor` wrote the raw path bytes verbatim, so a +/// file named `a*.txt` produced the pattern `a*.txt`, matching unrelated +/// siblings too. +#[test] +fn glob_metacharacter_is_escaped_in_written_pattern() { + let b = build_without_attributes(); + let workdir = b.repo.workdir().unwrap().to_owned(); + + b.repo + .track_vendor(&entry(), &[b"vendor/a*.txt".as_bstr()]) + .expect("track_vendor"); + + let content = std::fs::read_to_string(workdir.join(".gitattributes")).unwrap(); + assert_eq!(content, "vendor/a\\*.txt vendor=mylib\n"); +} + +/// Tracking the same glob-metacharacter path twice does not duplicate the +/// line: dedup must compare against the *unescaped* path, not the raw +/// written pattern. +#[test] +fn glob_metacharacter_path_dedup_survives_escaping() { + let b = build_without_attributes(); + let workdir = b.repo.workdir().unwrap().to_owned(); + + b.repo + .track_vendor(&entry(), &[b"vendor/a*.txt".as_bstr()]) + .expect("first call"); + b.repo + .track_vendor(&entry(), &[b"vendor/a*.txt".as_bstr()]) + .expect("second call"); + + let content = std::fs::read_to_string(workdir.join(".gitattributes")).unwrap(); + let count = content + .lines() + .filter(|l| *l == "vendor/a\\*.txt vendor=mylib") + .count(); + assert_eq!(count, 1, "line must appear exactly once: {content:?}"); +} diff --git a/crates/git-vendor/tests/vendor_paths/table.rs b/crates/git-vendor/tests/vendor_paths/table.rs index 5b120e0..1185c13 100644 --- a/crates/git-vendor/tests/vendor_paths/table.rs +++ b/crates/git-vendor/tests/vendor_paths/table.rs @@ -10,8 +10,10 @@ use std::collections::BTreeSet; use std::path::Path; -use git_vendor::{PatternMapping, VendorEntry, VendorMode, VendorName, VendorRepository as _}; -use gix::bstr::BString; +use git_vendor::{ + PatternMapping, VendorEntry, VendorMode, VendorName, VendorRepository as _, VendorWorktree as _, +}; +use gix::bstr::{BString, ByteSlice as _}; use rstest::rstest; use crate::support::{commit, git, init, write}; @@ -747,3 +749,35 @@ fn empty_tree_selects_nothing() { .is_empty() ); } + +// ── Glob-metacharacter paths ──────────────────────────────────────────────── + +/// A tracked path containing a glob metacharacter (`*`) must resolve to only +/// itself, not to unrelated siblings the raw (unescaped) glob would also +/// match. Regression: `track_vendor` wrote the raw path bytes verbatim as the +/// `.gitattributes` pattern, so a file literally named `a*` produced the live +/// glob `vendor/mylib/a*`, matching any sibling starting with `a`. +#[test] +fn glob_metacharacter_path_does_not_leak_to_siblings() { + let dir = tempfile::tempdir().unwrap(); + let p = dir.path(); + init(p); + write(p, "vendor/mylib/a*", b"literal"); + write(p, "vendor/mylib/aXtxt", b"sibling"); + git(&["add", "-A"], p); + git(&["commit", "-m", "initial"], p); + let repo = gix::open(p).expect("gix open"); + + repo.track_vendor(&entry("mylib", vec![]), &[b"vendor/mylib/a*".as_bstr()]) + .expect("track_vendor"); + git(&["add", "-A"], p); + git(&["commit", "-m", "track"], p); + + let ours = repo.head_commit().expect("head commit").id().detach(); + assert_eq!( + repo.vendor_paths(&entry("mylib", vec![]), ours) + .expect("vendor_paths"), + &[BString::from("vendor/mylib/a*")], + "only the literal path must be selected, not the unrelated sibling", + ); +} From a8d4d840f494b0ff933de67b7494d7be94bcc441 Mon Sep 17 00:00:00 2001 From: "Joseph D. Carpinelli" Date: Fri, 3 Jul 2026 12:22:13 -0400 Subject: [PATCH 24/31] fix: stop checkout_vendor from clobbering staged index entries MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The on-disk index was rebuilt from `full_tree`, the vendor tree overlaid onto HEAD's *committed* tree — never the index. Any entry staged but not yet committed (an addition or a modification) was silently dropped: `git add newfile.txt` followed by `git vendor update` unstaged `newfile.txt` with no warning. On an unborn HEAD this wiped the entire pre-existing index, since there was no committed tree to overlay onto at all. Derive the on-disk index from the actual current index instead (or an empty one if none exists yet), and apply only this vendor's own path removals/additions to it, leaving every other entry untouched. `remove_entries`/`dangerously_push_entry` don't invalidate the index's cached-tree extension, so also drop it explicitly — otherwise a native `git commit` right after would trust the stale cache and record the previous tree, silently dropping the vendor's changes. `full_tree` is still computed and returned unchanged; callers need that OID to mint commits regardless of what the on-disk index holds. fixes: `checkout_vendor` dropping staged-but-uncommitted index entries Assisted-by: Claude:claude-sonnet-5 --- crates/git-vendor/src/lib.rs | 43 +++++++++++------- .../git-vendor/tests/checkout_vendor/table.rs | 45 +++++++++++++++++++ 2 files changed, 72 insertions(+), 16 deletions(-) diff --git a/crates/git-vendor/src/lib.rs b/crates/git-vendor/src/lib.rs index ac4d0b1..aba23be 100644 --- a/crates/git-vendor/src/lib.rs +++ b/crates/git-vendor/src/lib.rs @@ -658,29 +658,40 @@ impl VendorWorktree for gix::Repository { } } - // Overlay the vendor tree onto the full HEAD tree and rebuild the index - // from the result. An unborn HEAD has no base commit, so the vendor tree - // is itself the whole tree. + // The tree returned to callers still overlays the vendor tree onto the + // full HEAD tree (an unborn HEAD has no base commit, so the vendor + // tree is itself the whole tree) — callers need this OID to mint + // commits regardless of what the on-disk index looks like. let full_tree = match head_id { Some(id) => self.vendor_overlay(entry, id, tree)?, None => tree, }; - let mut main_index = self.index_from_tree(&full_tree)?; + + // The on-disk index, however, must not be derived from `full_tree`: + // that overlay reads from HEAD, never the index, so any entry staged + // but not yet committed (an addition or modification) would be + // silently dropped. Instead, start from the actual current index — + // or an empty one if none exists yet — and surgically apply only this + // vendor's own path changes, leaving every other entry untouched. + let mut main_index = match self.open_index() { + Ok(idx) => idx, + Err(_) => self.index_from_tree(&gix::ObjectId::empty_tree(self.object_hash()))?, + }; main_index.set_path(self.git_dir().join("index")); - // `index_from_tree` zeroes stat data; carry over the stats checkout just - // populated on the vendor entries so `git status` need not re-hash them. - let vendor_stats: std::collections::HashMap = - vendor_index - .entries() - .iter() - .map(|e| (e.path(&vendor_index).to_owned(), e.stat)) - .collect(); - for (e, path) in main_index.entries_mut_with_paths() { - if let Some(stat) = vendor_stats.get(path) { - e.stat = *stat; - } + for removed in old_paths.difference(&new_paths) { + main_index.remove_entries(|_, p, _| p == removed.as_bstr()); } + for e in vendor_index.entries() { + let path = e.path(&vendor_index).to_owned(); + main_index.remove_entries(|_, p, _| p == path.as_bstr()); + main_index.dangerously_push_entry(e.stat, e.id, e.flags, e.mode, path.as_bstr()); + } + main_index.sort_entries(); + // `remove_entries`/`dangerously_push_entry` don't update the index's + // cached-tree extension, so a stale one would make a native `git + // commit` skip rehashing changed subtrees and record the wrong tree. + main_index.remove_tree(); main_index .write(gix::index::write::Options::default()) diff --git a/crates/git-vendor/tests/checkout_vendor/table.rs b/crates/git-vendor/tests/checkout_vendor/table.rs index 29daddf..c5ed1e5 100644 --- a/crates/git-vendor/tests/checkout_vendor/table.rs +++ b/crates/git-vendor/tests/checkout_vendor/table.rs @@ -273,3 +273,48 @@ fn index_entries_are_sorted_after_checkout() { sorted.sort(); assert_eq!(paths, sorted, "index entries must be sorted after checkout"); } + +/// A file staged but not yet committed must survive `checkout_vendor`. +/// Regression: the on-disk index was rebuilt from `full_tree`, which overlays +/// the vendor tree onto *HEAD's* committed tree — never the index — so any +/// staged-but-uncommitted addition was silently dropped from the index. +#[test] +fn staged_uncommitted_addition_is_preserved() { + let b = build(); + let workdir = b.repo.workdir().unwrap().to_owned(); + write(&workdir, "newfile.txt", b"staged\n"); + git(&["add", "newfile.txt"], &workdir); + + let tree = build_tree(&b.repo, &[("vendor/keep.txt", b"v2\n")]); + b.repo.checkout_vendor(&entry(), tree).expect("checkout"); + + let paths = index_paths(&b.repo); + assert!( + paths.contains(&"newfile.txt".to_owned()), + "staged addition must survive checkout: {paths:?}", + ); +} + +/// The same regression on an unborn `HEAD`: staging a file before the first +/// commit, then running `checkout_vendor`, must not wipe out the entire +/// pre-existing index (there is no committed tree to fall back to at all in +/// this case, so the old behavior dropped every staged entry). +#[test] +fn staged_uncommitted_addition_survives_unborn_head_checkout() { + let dir = tempfile::tempdir().unwrap(); + init(dir.path()); + write(dir.path(), "README", b"local readme\n"); + git(&["add", "README"], dir.path()); + // No commit — HEAD is unborn. + let repo = gix::open(dir.path()).unwrap(); + + let tree = build_tree(&repo, &[("vendor/new.txt", b"new\n")]); + repo.checkout_vendor(&entry(), tree) + .expect("checkout into unborn HEAD"); + + let paths = index_paths(&repo); + assert!( + paths.contains(&"README".to_owned()), + "pre-existing staged entry must survive checkout: {paths:?}", + ); +} From 867038ddce827db9c8b505ad2472024161fdf252 Mon Sep 17 00:00:00 2001 From: "Joseph D. Carpinelli" Date: Fri, 3 Jul 2026 12:30:12 -0400 Subject: [PATCH 25/31] fix: guard against silently resolving a .gitattributes merge conflict MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A vendor pattern that maps an upstream file onto `.gitattributes` itself (e.g. a root mapping with no destination prefix) can produce a genuine merge conflict on that path, which `checkout_vendor_conflicted` correctly splices into the index as unmerged stage 1/2/3 entries. But `reconcile_tracked_paths` always went on to call `track_vendor`, which reads and rewrites the (conflict-marker-laden) working copy file, and `stage_gitattributes`, which unconditionally replaces every existing stage with a single stage-0 entry — silently resolving the conflict so `git commit` would succeed with conflict-marker text committed as normal content. Check for unmerged stages on `.gitattributes` before writing, and skip the write entirely when found, leaving the stages exactly as `checkout_vendor_conflicted` left them and telling the user to resolve it manually. This is the most speculative of the ten findings from the adversarial review (a conflict landing on `.gitattributes` itself requires an unusual root-mapping pattern); the accompanying test drives the guard directly with a synthetic conflicted `VendorMerge` rather than reproducing the full upstream layout end to end. fixes: silent collapse of an unresolved .gitattributes merge conflict Assisted-by: Claude:claude-sonnet-5 --- crates/git-vendor/src/exe.rs | 179 +++++++++++++++++++++++++++++++++-- 1 file changed, 170 insertions(+), 9 deletions(-) diff --git a/crates/git-vendor/src/exe.rs b/crates/git-vendor/src/exe.rs index deac15f..038c7f2 100644 --- a/crates/git-vendor/src/exe.rs +++ b/crates/git-vendor/src/exe.rs @@ -147,7 +147,7 @@ impl Executor { if merge.has_conflicts() { repo.checkout_vendor_conflicted(&entry, &merge)?; - reconcile_tracked_paths(repo, &entry, &[], &new_paths)?; + reconcile_tracked_paths(repo, &entry, &[], &new_paths, io)?; entry.base = Some(merge.upstream_commit); config.insert(&entry)?; let config_str = save_config(&config, &cfg_path)?; @@ -160,7 +160,7 @@ impl Executor { } let _full_tree = repo.checkout_vendor(&entry, merge.result_tree)?; - reconcile_tracked_paths(repo, &entry, &[], &new_paths)?; + reconcile_tracked_paths(repo, &entry, &[], &new_paths, io)?; entry.base = Some(merge.upstream_commit); config.insert(&entry)?; @@ -264,7 +264,7 @@ impl Executor { if merge.has_conflicts() { repo.checkout_vendor_conflicted(&entry, &merge)?; - reconcile_tracked_paths(repo, &entry, &old_paths, &new_paths)?; + reconcile_tracked_paths(repo, &entry, &old_paths, &new_paths, io)?; entry.base = Some(merge.upstream_commit); config.insert(&entry)?; let config_str = save_config(&config, &cfg_path)?; @@ -277,7 +277,8 @@ impl Executor { } let full_tree = repo.checkout_vendor(&entry, merge.result_tree)?; - let attrs_blob = reconcile_tracked_paths(repo, &entry, &old_paths, &new_paths)?; + let attrs_blob = reconcile_tracked_paths(repo, &entry, &old_paths, &new_paths, io)? + .ok_or("`.gitattributes` has an unresolved conflict; resolve it before updating")?; entry.base = Some(merge.upstream_commit); config.insert(&entry)?; @@ -386,7 +387,8 @@ impl Executor { git_vendor::validate_trackable_paths(&path_refs)?; let full_tree = repo.checkout_vendor(&entry, new_tree)?; - let attrs_blob = reconcile_tracked_paths(repo, &entry, &old_paths, &new_paths)?; + let attrs_blob = reconcile_tracked_paths(repo, &entry, &old_paths, &new_paths, io)? + .ok_or("`.gitattributes` has an unresolved conflict; resolve it before applying")?; let vendors_blob = stage_gitvendors(repo, config_str.as_bytes())?; let tree = final_tree(repo, full_tree, attrs_blob, vendors_blob)?; @@ -657,13 +659,43 @@ fn author_sig(repo: &gix::Repository) -> Result { .map_err(|e| format!("author time: {e}").into()) } +/// Whether `path` currently has any unmerged (non-zero-stage) entry in the +/// index — i.e. it is itself part of an unresolved conflict. +fn has_unmerged_stages(repo: &gix::Repository, path: &gix::bstr::BStr) -> Result { + let index = repo.open_index().map_err(|e| format!("{e}"))?; + Ok(index.entries().iter().any(|e| { + e.path(&index) == path && e.flags.stage() != gix::index::entry::Stage::Unconflicted + })) +} + +/// Update `.gitattributes` tracking for a vendor's path set. +/// +/// A vendor whose destination pattern maps onto `.gitattributes` itself can +/// leave that path with unmerged stages after `checkout_vendor_conflicted` +/// spliced in a genuine merge conflict on it. Writing new tracking lines in +/// that case would silently collapse the conflict to a single resolved +/// stage-0 entry, so `git commit` would succeed despite the unresolved +/// conflict. When that happens, this leaves the unmerged stages untouched and +/// returns `None` instead. fn reconcile_tracked_paths( repo: &gix::Repository, entry: &VendorEntry, old_paths: &[gix::bstr::BString], new_paths: &[gix::bstr::BString], -) -> Result { - use gix::bstr::BStr; + io: &mut Io, +) -> Result> { + use gix::bstr::{BStr, ByteSlice as _}; + + if has_unmerged_stages(repo, b".gitattributes".as_bstr())? { + writeln!( + io.err, + "{}: .gitattributes itself is part of this conflict; resolve it \ + manually, including the vendor={} tracking lines, before committing", + entry.name, entry.name, + )?; + return Ok(None); + } + let track: Vec<&BStr> = new_paths.iter().map(|b| b.as_ref()).collect(); let attrs_oid = repo.track_vendor(entry, &track)?; @@ -677,9 +709,9 @@ fn reconcile_tracked_paths( if !removed.is_empty() && let Some(oid) = repo.untrack_vendor(entry, &removed)? { - return Ok(oid); + return Ok(Some(oid)); } - Ok(attrs_oid) + Ok(Some(attrs_oid)) } /// Write `content` as a blob, upsert the `.gitvendors` index entry, and return @@ -879,4 +911,133 @@ mod tests { "the concurrent commit must remain HEAD after the stale write is rejected" ); } + + fn test_entry() -> VendorEntry { + VendorEntry { + name: VendorName::new("mylib").unwrap(), + url: "unused".to_owned(), + ref_name: None, + base: None, + patterns: Vec::new(), + mode: VendorMode::Merge, + } + } + + /// `reconcile_tracked_paths` must not collapse a genuine unresolved merge + /// conflict on `.gitattributes` itself into a single resolved stage-0 + /// entry: doing so would let `git commit` silently record the + /// conflict-marker text as if it were normal content. Regression: the + /// function always called `track_vendor`, which reads and rewrites + /// `.gitattributes`, then `stage_gitattributes` unconditionally replaced + /// whatever stages were there with one stage-0 entry. + #[test] + fn skips_write_when_gitattributes_itself_is_conflicted() { + use git_vendor::{ConflictStages, VendorMerge}; + + let dir = tempfile::tempdir().unwrap(); + git(&["init", "-q", "-b", "main"], dir.path()); + git(&["config", "user.email", "t@example.com"], dir.path()); + git(&["config", "user.name", "T"], dir.path()); + std::fs::write(dir.path().join(".gitattributes"), "* text=auto\n").unwrap(); + git(&["add", "."], dir.path()); + git(&["commit", "-q", "-m", "init"], dir.path()); + + let repo = gix::open(dir.path()).expect("gix open"); + let entry = test_entry(); + + let base_blob = repo + .write_object(gix::objs::BlobRef { + data: b"* text=auto\n", + }) + .expect("write base blob") + .detach(); + let ours_blob = repo + .write_object(gix::objs::BlobRef { + data: b"* text=auto\nours=1\n", + }) + .expect("write ours blob") + .detach(); + let theirs_blob = repo + .write_object(gix::objs::BlobRef { + data: b"* text=auto\ntheirs=1\n", + }) + .expect("write theirs blob") + .detach(); + let conflict_marker_blob = repo + .write_object(gix::objs::BlobRef { + data: b"<<<<<<< ours\nours=1\n=======\ntheirs=1\n>>>>>>> theirs\n", + }) + .expect("write conflict-marker blob") + .detach(); + + let blob_mode = gix::objs::tree::EntryMode::from(gix::objs::tree::EntryKind::Blob); + let head_tree = repo + .head_commit() + .expect("head") + .tree_id() + .expect("tree") + .detach(); + let mut editor = repo + .find_tree(head_tree) + .expect("find tree") + .edit() + .expect("edit"); + editor + .upsert( + ".gitattributes", + gix::objs::tree::EntryKind::Blob, + conflict_marker_blob, + ) + .expect("upsert conflicted .gitattributes"); + let result_tree = editor.write().expect("write tree").detach(); + + let merge = VendorMerge { + upstream_commit: repo.head_commit().expect("head").id().detach(), + ancestor_tree: None, + result_tree, + conflicts: vec![ConflictStages { + path: ".gitattributes".to_owned(), + stages: [ + Some((blob_mode, base_blob)), + Some((blob_mode, ours_blob)), + Some((blob_mode, theirs_blob)), + ], + }], + }; + + repo.checkout_vendor_conflicted(&entry, &merge) + .expect("checkout_vendor_conflicted"); + + let new_paths = tree_paths(&repo, merge.result_tree).expect("tree_paths"); + let mut io = Io { + out: Box::new(Vec::new()), + err: Box::new(Vec::new()), + }; + let result = reconcile_tracked_paths(&repo, &entry, &[], &new_paths, &mut io) + .expect("reconcile_tracked_paths"); + assert!( + result.is_none(), + "must skip and return None when .gitattributes itself is conflicted", + ); + + let index = repo.open_index().expect("open_index"); + use gix::bstr::ByteSlice as _; + let stages: Vec<_> = index + .entries() + .iter() + .filter(|e| e.path(&index) == b".gitattributes".as_bstr()) + .map(|e| e.flags.stage()) + .collect(); + assert_eq!( + stages.len(), + 3, + "all three unmerged stages must survive, got {stages:?}", + ); + assert!( + stages + .iter() + .all(|s| *s != gix::index::entry::Stage::Unconflicted), + "no stage should have been collapsed to stage 0, got {stages:?}", + ); + } } From 8d9e0726f4f468912bed8bbe013ae76b3ad870c6 Mon Sep 17 00:00:00 2001 From: "Joseph D. Carpinelli" Date: Fri, 3 Jul 2026 12:54:36 -0400 Subject: [PATCH 26/31] refactor: move exe.rs unit tests into exe_tests.rs Matches the existing lib.rs/attr_tests.rs convention: cfg(test) mod with #[path] pointing at a standalone file, keeping test code out of the source file. Assisted-by: Claude:claude-sonnet-5 --- crates/git-vendor/src/exe.rs | 205 +---------------------------- crates/git-vendor/src/exe_tests.rs | 201 ++++++++++++++++++++++++++++ 2 files changed, 203 insertions(+), 203 deletions(-) create mode 100644 crates/git-vendor/src/exe_tests.rs diff --git a/crates/git-vendor/src/exe.rs b/crates/git-vendor/src/exe.rs index 038c7f2..caf4dc5 100644 --- a/crates/git-vendor/src/exe.rs +++ b/crates/git-vendor/src/exe.rs @@ -838,206 +838,5 @@ fn name_from_url(url: &str) -> Option { } #[cfg(test)] -mod tests { - use super::*; - use std::path::Path; - - fn git(args: &[&str], dir: &Path) { - let output = std::process::Command::new("git") - .args(args) - .current_dir(dir) - .env("GIT_CONFIG_NOSYSTEM", "1") - .env("GIT_CONFIG_GLOBAL", "/dev/null") - .output() - .expect("git"); - assert!( - output.status.success(), - "git {args:?} failed:\n{}", - String::from_utf8_lossy(&output.stderr) - ); - } - - /// `advance_head` must reject a stale `parent` instead of silently - /// overwriting a commit another process made after the caller - /// snapshotted `current_head`. - #[test] - fn advance_head_rejects_stale_parent() { - let dir = tempfile::tempdir().unwrap(); - git(&["init", "-q", "-b", "main"], dir.path()); - git(&["config", "user.email", "t@example.com"], dir.path()); - git(&["config", "user.name", "T"], dir.path()); - std::fs::write(dir.path().join("f"), "one").unwrap(); - git(&["add", "f"], dir.path()); - git(&["commit", "-q", "-m", "one"], dir.path()); - - let repo = gix::open(dir.path()).expect("gix open"); - let stale_parent = repo.head_commit().expect("head").id().detach(); - let tree = repo - .head_commit() - .expect("head") - .tree_id() - .expect("tree") - .detach(); - - // Simulate a concurrent writer advancing HEAD after we snapshotted it. - git( - &["commit", "-q", "--allow-empty", "-m", "concurrent"], - dir.path(), - ); - let concurrent = repo.head_commit().expect("head").id().detach(); - assert_ne!(concurrent, stale_parent); - - let author = author_sig(&repo).expect("author"); - let committer = committer_sig(&repo).expect("committer"); - let mut tbuf_a = gix::date::parse::TimeBuf::default(); - let mut tbuf_c = gix::date::parse::TimeBuf::default(); - let commit = gix::objs::Commit { - tree, - parents: [stale_parent].into_iter().collect(), - author: author.to_ref(&mut tbuf_a).into(), - committer: committer.to_ref(&mut tbuf_c).into(), - encoding: None, - message: "stale update".into(), - extra_headers: Vec::new(), - }; - let new_commit = repo.write_object(&commit).expect("write").detach(); - - let result = advance_head(&repo, new_commit, stale_parent, "stale update"); - assert!(result.is_err(), "advance_head must reject a stale parent"); - - let head_after = repo.head_commit().expect("head").id().detach(); - assert_eq!( - head_after, concurrent, - "the concurrent commit must remain HEAD after the stale write is rejected" - ); - } - - fn test_entry() -> VendorEntry { - VendorEntry { - name: VendorName::new("mylib").unwrap(), - url: "unused".to_owned(), - ref_name: None, - base: None, - patterns: Vec::new(), - mode: VendorMode::Merge, - } - } - - /// `reconcile_tracked_paths` must not collapse a genuine unresolved merge - /// conflict on `.gitattributes` itself into a single resolved stage-0 - /// entry: doing so would let `git commit` silently record the - /// conflict-marker text as if it were normal content. Regression: the - /// function always called `track_vendor`, which reads and rewrites - /// `.gitattributes`, then `stage_gitattributes` unconditionally replaced - /// whatever stages were there with one stage-0 entry. - #[test] - fn skips_write_when_gitattributes_itself_is_conflicted() { - use git_vendor::{ConflictStages, VendorMerge}; - - let dir = tempfile::tempdir().unwrap(); - git(&["init", "-q", "-b", "main"], dir.path()); - git(&["config", "user.email", "t@example.com"], dir.path()); - git(&["config", "user.name", "T"], dir.path()); - std::fs::write(dir.path().join(".gitattributes"), "* text=auto\n").unwrap(); - git(&["add", "."], dir.path()); - git(&["commit", "-q", "-m", "init"], dir.path()); - - let repo = gix::open(dir.path()).expect("gix open"); - let entry = test_entry(); - - let base_blob = repo - .write_object(gix::objs::BlobRef { - data: b"* text=auto\n", - }) - .expect("write base blob") - .detach(); - let ours_blob = repo - .write_object(gix::objs::BlobRef { - data: b"* text=auto\nours=1\n", - }) - .expect("write ours blob") - .detach(); - let theirs_blob = repo - .write_object(gix::objs::BlobRef { - data: b"* text=auto\ntheirs=1\n", - }) - .expect("write theirs blob") - .detach(); - let conflict_marker_blob = repo - .write_object(gix::objs::BlobRef { - data: b"<<<<<<< ours\nours=1\n=======\ntheirs=1\n>>>>>>> theirs\n", - }) - .expect("write conflict-marker blob") - .detach(); - - let blob_mode = gix::objs::tree::EntryMode::from(gix::objs::tree::EntryKind::Blob); - let head_tree = repo - .head_commit() - .expect("head") - .tree_id() - .expect("tree") - .detach(); - let mut editor = repo - .find_tree(head_tree) - .expect("find tree") - .edit() - .expect("edit"); - editor - .upsert( - ".gitattributes", - gix::objs::tree::EntryKind::Blob, - conflict_marker_blob, - ) - .expect("upsert conflicted .gitattributes"); - let result_tree = editor.write().expect("write tree").detach(); - - let merge = VendorMerge { - upstream_commit: repo.head_commit().expect("head").id().detach(), - ancestor_tree: None, - result_tree, - conflicts: vec![ConflictStages { - path: ".gitattributes".to_owned(), - stages: [ - Some((blob_mode, base_blob)), - Some((blob_mode, ours_blob)), - Some((blob_mode, theirs_blob)), - ], - }], - }; - - repo.checkout_vendor_conflicted(&entry, &merge) - .expect("checkout_vendor_conflicted"); - - let new_paths = tree_paths(&repo, merge.result_tree).expect("tree_paths"); - let mut io = Io { - out: Box::new(Vec::new()), - err: Box::new(Vec::new()), - }; - let result = reconcile_tracked_paths(&repo, &entry, &[], &new_paths, &mut io) - .expect("reconcile_tracked_paths"); - assert!( - result.is_none(), - "must skip and return None when .gitattributes itself is conflicted", - ); - - let index = repo.open_index().expect("open_index"); - use gix::bstr::ByteSlice as _; - let stages: Vec<_> = index - .entries() - .iter() - .filter(|e| e.path(&index) == b".gitattributes".as_bstr()) - .map(|e| e.flags.stage()) - .collect(); - assert_eq!( - stages.len(), - 3, - "all three unmerged stages must survive, got {stages:?}", - ); - assert!( - stages - .iter() - .all(|s| *s != gix::index::entry::Stage::Unconflicted), - "no stage should have been collapsed to stage 0, got {stages:?}", - ); - } -} +#[path = "exe_tests.rs"] +mod tests; diff --git a/crates/git-vendor/src/exe_tests.rs b/crates/git-vendor/src/exe_tests.rs new file mode 100644 index 0000000..4919396 --- /dev/null +++ b/crates/git-vendor/src/exe_tests.rs @@ -0,0 +1,201 @@ +use super::*; +use std::path::Path; + +fn git(args: &[&str], dir: &Path) { + let output = std::process::Command::new("git") + .args(args) + .current_dir(dir) + .env("GIT_CONFIG_NOSYSTEM", "1") + .env("GIT_CONFIG_GLOBAL", "/dev/null") + .output() + .expect("git"); + assert!( + output.status.success(), + "git {args:?} failed:\n{}", + String::from_utf8_lossy(&output.stderr) + ); +} + +/// `advance_head` must reject a stale `parent` instead of silently +/// overwriting a commit another process made after the caller +/// snapshotted `current_head`. +#[test] +fn advance_head_rejects_stale_parent() { + let dir = tempfile::tempdir().unwrap(); + git(&["init", "-q", "-b", "main"], dir.path()); + git(&["config", "user.email", "t@example.com"], dir.path()); + git(&["config", "user.name", "T"], dir.path()); + std::fs::write(dir.path().join("f"), "one").unwrap(); + git(&["add", "f"], dir.path()); + git(&["commit", "-q", "-m", "one"], dir.path()); + + let repo = gix::open(dir.path()).expect("gix open"); + let stale_parent = repo.head_commit().expect("head").id().detach(); + let tree = repo + .head_commit() + .expect("head") + .tree_id() + .expect("tree") + .detach(); + + // Simulate a concurrent writer advancing HEAD after we snapshotted it. + git( + &["commit", "-q", "--allow-empty", "-m", "concurrent"], + dir.path(), + ); + let concurrent = repo.head_commit().expect("head").id().detach(); + assert_ne!(concurrent, stale_parent); + + let author = author_sig(&repo).expect("author"); + let committer = committer_sig(&repo).expect("committer"); + let mut tbuf_a = gix::date::parse::TimeBuf::default(); + let mut tbuf_c = gix::date::parse::TimeBuf::default(); + let commit = gix::objs::Commit { + tree, + parents: [stale_parent].into_iter().collect(), + author: author.to_ref(&mut tbuf_a).into(), + committer: committer.to_ref(&mut tbuf_c).into(), + encoding: None, + message: "stale update".into(), + extra_headers: Vec::new(), + }; + let new_commit = repo.write_object(&commit).expect("write").detach(); + + let result = advance_head(&repo, new_commit, stale_parent, "stale update"); + assert!(result.is_err(), "advance_head must reject a stale parent"); + + let head_after = repo.head_commit().expect("head").id().detach(); + assert_eq!( + head_after, concurrent, + "the concurrent commit must remain HEAD after the stale write is rejected" + ); +} + +fn test_entry() -> VendorEntry { + VendorEntry { + name: VendorName::new("mylib").unwrap(), + url: "unused".to_owned(), + ref_name: None, + base: None, + patterns: Vec::new(), + mode: VendorMode::Merge, + } +} + +/// `reconcile_tracked_paths` must not collapse a genuine unresolved merge +/// conflict on `.gitattributes` itself into a single resolved stage-0 +/// entry: doing so would let `git commit` silently record the +/// conflict-marker text as if it were normal content. Regression: the +/// function always called `track_vendor`, which reads and rewrites +/// `.gitattributes`, then `stage_gitattributes` unconditionally replaced +/// whatever stages were there with one stage-0 entry. +#[test] +fn skips_write_when_gitattributes_itself_is_conflicted() { + use git_vendor::{ConflictStages, VendorMerge}; + + let dir = tempfile::tempdir().unwrap(); + git(&["init", "-q", "-b", "main"], dir.path()); + git(&["config", "user.email", "t@example.com"], dir.path()); + git(&["config", "user.name", "T"], dir.path()); + std::fs::write(dir.path().join(".gitattributes"), "* text=auto\n").unwrap(); + git(&["add", "."], dir.path()); + git(&["commit", "-q", "-m", "init"], dir.path()); + + let repo = gix::open(dir.path()).expect("gix open"); + let entry = test_entry(); + + let base_blob = repo + .write_object(gix::objs::BlobRef { + data: b"* text=auto\n", + }) + .expect("write base blob") + .detach(); + let ours_blob = repo + .write_object(gix::objs::BlobRef { + data: b"* text=auto\nours=1\n", + }) + .expect("write ours blob") + .detach(); + let theirs_blob = repo + .write_object(gix::objs::BlobRef { + data: b"* text=auto\ntheirs=1\n", + }) + .expect("write theirs blob") + .detach(); + let conflict_marker_blob = repo + .write_object(gix::objs::BlobRef { + data: b"<<<<<<< ours\nours=1\n=======\ntheirs=1\n>>>>>>> theirs\n", + }) + .expect("write conflict-marker blob") + .detach(); + + let blob_mode = gix::objs::tree::EntryMode::from(gix::objs::tree::EntryKind::Blob); + let head_tree = repo + .head_commit() + .expect("head") + .tree_id() + .expect("tree") + .detach(); + let mut editor = repo + .find_tree(head_tree) + .expect("find tree") + .edit() + .expect("edit"); + editor + .upsert( + ".gitattributes", + gix::objs::tree::EntryKind::Blob, + conflict_marker_blob, + ) + .expect("upsert conflicted .gitattributes"); + let result_tree = editor.write().expect("write tree").detach(); + + let merge = VendorMerge { + upstream_commit: repo.head_commit().expect("head").id().detach(), + ancestor_tree: None, + result_tree, + conflicts: vec![ConflictStages { + path: ".gitattributes".to_owned(), + stages: [ + Some((blob_mode, base_blob)), + Some((blob_mode, ours_blob)), + Some((blob_mode, theirs_blob)), + ], + }], + }; + + repo.checkout_vendor_conflicted(&entry, &merge) + .expect("checkout_vendor_conflicted"); + + let new_paths = tree_paths(&repo, merge.result_tree).expect("tree_paths"); + let mut io = Io { + out: Box::new(Vec::new()), + err: Box::new(Vec::new()), + }; + let result = reconcile_tracked_paths(&repo, &entry, &[], &new_paths, &mut io) + .expect("reconcile_tracked_paths"); + assert!( + result.is_none(), + "must skip and return None when .gitattributes itself is conflicted", + ); + + let index = repo.open_index().expect("open_index"); + use gix::bstr::ByteSlice as _; + let stages: Vec<_> = index + .entries() + .iter() + .filter(|e| e.path(&index) == b".gitattributes".as_bstr()) + .map(|e| e.flags.stage()) + .collect(); + assert_eq!( + stages.len(), + 3, + "all three unmerged stages must survive, got {stages:?}", + ); + assert!( + stages + .iter() + .all(|s| *s != gix::index::entry::Stage::Unconflicted), + "no stage should have been collapsed to stage 0, got {stages:?}", + ); +} From 1b5fd0c21ab9a77cf5a56068ba5b9f88f7907c43 Mon Sep 17 00:00:00 2001 From: "Joseph D. Carpinelli" Date: Fri, 3 Jul 2026 13:15:53 -0400 Subject: [PATCH 27/31] refactor: fold `apply` into `update --no-fetch`, stop auto-committing Neither `git submodule add` nor `git submodule update --remote` commit on the caller's behalf, so `git vendor` now matches: `update` (fetch or --no-fetch) always stages its result and tells the user to run `git commit`, instead of auto-committing multi-vendor runs while single-vendor runs staged. `apply` duplicated `update`'s "rebuild from recorded base" logic under a different name, so it's now `update --no-fetch`. fix: reject `add` of a vendor name that already exists Re-running `add` with an existing name silently re-fetched and staged nothing (an empty "in-progress merge" with no diff), forcing the user to discover `git merge --abort` on their own. It now errors up front, same as `git remote add`. refactor: drop `apply`, `advance_head`, `author_sig`, `committer_sig` fix: stop multi-vendor `update` from clobbering earlier vendors' pending MERGE_HEAD fix: reject `add ` instead of silently corrupting merge state Assisted-by: Claude:claude-sonnet-5 --- crates/git-vendor/src/cli.rs | 37 ++--- crates/git-vendor/src/exe.rs | 248 +++++------------------------ crates/git-vendor/src/exe_tests.rs | 55 ------- 3 files changed, 53 insertions(+), 287 deletions(-) diff --git a/crates/git-vendor/src/cli.rs b/crates/git-vendor/src/cli.rs index 2caaafa..8a3bd54 100644 --- a/crates/git-vendor/src/cli.rs +++ b/crates/git-vendor/src/cli.rs @@ -14,9 +14,10 @@ pub struct Cli { pub enum Command { /// Add a new vendor dependency and integrate it into the current branch. /// - /// Fetches the upstream ref, three-way merges it into the working tree, and - /// mints a merge commit. Equivalent to `git subtree add` or a tracked - /// `git submodule add` that copies files instead of linking a repo. + /// Fetches the upstream ref and three-way merges it into the working + /// tree, staging the result for review. Run `git commit` to complete, + /// mirroring `git submodule add` (which also never commits on your + /// behalf). Add { /// Remote URL of the upstream repository. url: String, @@ -57,6 +58,8 @@ pub enum Command { /// Fetch and integrate upstream updates for one or all vendors. /// /// Equivalent to `git subtree pull` or `git submodule update --remote`. + /// Stages the result for review; run `git commit` to complete, mirroring + /// `git submodule` (which never commits on your behalf). Update { /// Vendor name to update; updates all configured vendors if omitted. name: Option, @@ -70,29 +73,17 @@ pub enum Command { #[arg(long)] force: bool, - /// Show what would be fetched and merged without making any changes. + /// Rebuild from the recorded upstream base instead of fetching. Use + /// after editing a vendor's `pattern` entries to move or refilter its + /// files. Local modifications to vendored files would be discarded, + /// so this refuses to proceed on a modified vendor unless `--force` + /// is also given. #[arg(long)] - dry_run: bool, - }, - - /// Re-apply the configured patterns from the recorded upstream base. - /// - /// Rebuilds vendored files from `.gitvendors` without fetching. Use after - /// editing a vendor's `pattern` entries to move or refilter its files. - /// Local modifications to vendored files would be discarded, so the - /// command refuses to proceed on a modified vendor unless `--force` is - /// given. - Apply { - /// Vendor name to apply; applies all configured vendors if omitted. - name: Option, + no_fetch: bool, - /// Commit message (defaults to `vendor: apply `). - #[arg(long, short = 'm', value_name = "MSG")] - message: Option, - - /// Discard local modifications to vendored files. + /// Show what would be fetched and merged without making any changes. #[arg(long)] - force: bool, + dry_run: bool, }, /// Show synchronization status for one or all vendors. diff --git a/crates/git-vendor/src/exe.rs b/crates/git-vendor/src/exe.rs index caf4dc5..c6b0e00 100644 --- a/crates/git-vendor/src/exe.rs +++ b/crates/git-vendor/src/exe.rs @@ -60,13 +60,9 @@ impl Executor { name, message, force, + no_fetch, dry_run, - } => self.update(name, message, force, dry_run, io), - cli::Command::Apply { - name, - message, - force, - } => self.apply(name, message, force, io), + } => self.update(name, message, force, no_fetch, dry_run, io), cli::Command::Status { name, fetch } => self.status(name, fetch, io), cli::Command::Remove { name, keep_files } => self.remove(name, keep_files, io), cli::Command::List => self.list(io), @@ -97,6 +93,12 @@ impl Executor { })?, }; let vendor_name = VendorName::new(&name)?; + if config.get(vendor_name.as_str())?.is_some() { + return Err(format!( + "vendor {name:?} already exists; use `git vendor update {name}` or remove it first" + ) + .into()); + } let mode = if squash { VendorMode::Squash } else { @@ -197,17 +199,18 @@ impl Executor { name: Option, message: Option, force: bool, + no_fetch: bool, dry_run: bool, io: &mut Io, ) -> Result<()> { + if no_fetch { + return self.update_no_fetch(name, force, io); + } + let repo = &self.0; let cfg_path = config_path(repo)?; let mut config = load_config(&cfg_path)?; - // Multi-vendor updates always auto-commit (one commit per vendor); only a - // single-vendor update without -m uses the prepare-merge path. - let auto_commit = name.is_none() || message.is_some(); - let entries: Vec = match name { Some(ref n) => vec![require_entry(&config, n)?], None => config.entries()?, @@ -223,7 +226,7 @@ impl Executor { .map(|c| c.id().detach()) .map_err(|e| format!("HEAD: {e}"))?; - let mut current_head = head_oid; + let current_head = head_oid; for mut entry in entries { let n = entry.name.as_str().to_owned(); @@ -276,40 +279,32 @@ impl Executor { return Err(ConflictExit.into()); } - let full_tree = repo.checkout_vendor(&entry, merge.result_tree)?; - let attrs_blob = reconcile_tracked_paths(repo, &entry, &old_paths, &new_paths, io)? + let _full_tree = repo.checkout_vendor(&entry, merge.result_tree)?; + reconcile_tracked_paths(repo, &entry, &old_paths, &new_paths, io)? .ok_or("`.gitattributes` has an unresolved conflict; resolve it before updating")?; entry.base = Some(merge.upstream_commit); config.insert(&entry)?; let config_str = save_config(&config, &cfg_path)?; - if auto_commit { - let vendors_blob = stage_gitvendors(repo, config_str.as_bytes())?; - let tree = final_tree(repo, full_tree, attrs_blob, vendors_blob)?; - commit_and_advance(repo, &entry, &merge, tree, current_head, &msg)?; - current_head = repo - .head_commit() - .map(|c| c.id().detach()) - .map_err(|e| format!("HEAD after commit: {e}"))?; - writeln!(io.err, "Updated {n}.")?; - } else { - stage_gitvendors(repo, config_str.as_bytes())?; - repo.prepare_merge(&entry, &merge, &msg)?; - writeln!(io.err, "Updated {n}. Run `git commit` to record the merge.")?; - } + stage_gitvendors(repo, config_str.as_bytes())?; + repo.prepare_merge(&entry, &merge, &msg)?; + writeln!(io.err, "Updated {n}. Run `git commit` to record the merge.")?; + + // `prepare_merge` overwrites MERGE_HEAD rather than accumulating an + // octopus merge, so a second vendor's pending merge in the same run + // would silently clobber this one's. Stop here; re-running `update` + // after the commit picks up the rest. + break; } Ok(()) } - fn apply( - &self, - name: Option, - message: Option, - force: bool, - io: &mut Io, - ) -> Result<()> { + /// Rebuild vendored files from `.gitvendors` without fetching (`update + /// --no-fetch`). Use after editing a vendor's `pattern` entries to move + /// or refilter its files. Refuses a modified vendor unless `force`. + fn update_no_fetch(&self, name: Option, force: bool, io: &mut Io) -> Result<()> { let repo = &self.0; let cfg_path = config_path(repo)?; let config = load_config(&cfg_path)?; @@ -335,7 +330,6 @@ impl Executor { let old_config = config_at(repo, head_oid)?; let config_str = save_config(&config, &cfg_path)?; - let mut current_head = head_oid; for entry in entries { let n = entry.name.as_str().to_owned(); @@ -354,7 +348,7 @@ impl Executor { .map(|(old, b)| repo.upstream_tree(&old, b)) .transpose()?; if let Some(pristine) = pristine { - let ours = repo.ours_tree(&entry, current_head)?; + let ours = repo.ours_tree(&entry, head_oid)?; if ours != pristine && !force { let pristine_blobs = tree_blobs(repo, pristine)?; let our_blobs = tree_blobs(repo, ours)?; @@ -381,52 +375,20 @@ impl Executor { } let new_tree = repo.upstream_tree(&entry, base)?; - let old_paths: Vec = repo.vendor_paths(&entry, current_head)?; + let old_paths: Vec = repo.vendor_paths(&entry, head_oid)?; let new_paths = tree_paths(repo, new_tree)?; let path_refs: Vec<&gix::bstr::BStr> = new_paths.iter().map(|b| b.as_ref()).collect(); git_vendor::validate_trackable_paths(&path_refs)?; - let full_tree = repo.checkout_vendor(&entry, new_tree)?; - let attrs_blob = reconcile_tracked_paths(repo, &entry, &old_paths, &new_paths, io)? - .ok_or("`.gitattributes` has an unresolved conflict; resolve it before applying")?; - let vendors_blob = stage_gitvendors(repo, config_str.as_bytes())?; - let tree = final_tree(repo, full_tree, attrs_blob, vendors_blob)?; - - let head_tree = repo - .find_commit(current_head) - .map_err(|e| format!("{e}"))? - .tree() - .map_err(|e| format!("{e}"))? - .id() - .detach(); - if tree == head_tree { - writeln!(io.err, "{n}: nothing to apply")?; - continue; - } + let _full_tree = repo.checkout_vendor(&entry, new_tree)?; + reconcile_tracked_paths(repo, &entry, &old_paths, &new_paths, io)? + .ok_or("`.gitattributes` has an unresolved conflict; resolve it before updating")?; + stage_gitvendors(repo, config_str.as_bytes())?; - let msg = message - .clone() - .unwrap_or_else(|| format!("vendor: apply {n}")); - - // A single-parent commit: no upstream changed, so unlike add/update - // there is no merge edge to record. - let author = author_sig(repo)?; - let committer = committer_sig(repo)?; - let mut tbuf_a = gix::date::parse::TimeBuf::default(); - let mut tbuf_c = gix::date::parse::TimeBuf::default(); - let commit = gix::objs::Commit { - tree, - parents: [current_head].into_iter().collect(), - author: author.to_ref(&mut tbuf_a).into(), - committer: committer.to_ref(&mut tbuf_c).into(), - encoding: None, - message: msg.as_str().into(), - extra_headers: Vec::new(), - }; - let new_commit = repo.write_object(&commit)?.detach(); - advance_head(repo, new_commit, current_head, &msg)?; - current_head = new_commit; - writeln!(io.err, "Applied {n}.")?; + writeln!( + io.err, + "Updated {n}. Run `git commit` to record the change." + )?; } Ok(()) @@ -610,55 +572,6 @@ fn config_at(repo: &gix::Repository, commit: gix::ObjectId) -> Result Result<()> { - use gix::refs::Target; - use gix::refs::transaction::{Change, LogChange, PreviousValue, RefEdit, RefLog}; - - let name: gix::refs::FullName = "HEAD".try_into()?; - repo.edit_references([RefEdit { - change: Change::Update { - log: LogChange { - mode: RefLog::AndReference, - force_create_reflog: false, - message: msg.as_bytes().into(), - }, - expected: PreviousValue::MustExistAndMatch(Target::Object(parent)), - new: Target::Object(new_commit), - }, - name, - deref: true, - }]) - .map_err(|e| { - format!("HEAD moved unexpectedly since the update started; aborting to avoid clobbering a concurrent commit: {e}") - })?; - Ok(()) -} - -fn committer_sig(repo: &gix::Repository) -> Result { - let sig_ref = repo - .committer() - .ok_or("no committer identity; set user.name and user.email")? - .map_err(|e| format!("committer: {e}"))?; - sig_ref - .to_owned() - .map_err(|e| format!("committer time: {e}").into()) -} - -fn author_sig(repo: &gix::Repository) -> Result { - let sig_ref = repo - .author() - .ok_or("no author identity; set user.name and user.email")? - .map_err(|e| format!("author: {e}"))?; - sig_ref - .to_owned() - .map_err(|e| format!("author time: {e}").into()) -} - /// Whether `path` currently has any unmerged (non-zero-stage) entry in the /// index — i.e. it is itself part of an unresolved conflict. fn has_unmerged_stages(repo: &gix::Repository, path: &gix::bstr::BStr) -> Result { @@ -738,89 +651,6 @@ fn stage_gitvendors(repo: &gix::Repository, content: &[u8]) -> Result Result { - use gix::bstr::ByteSlice as _; - let mut editor = repo - .find_object(full_tree) - .map_err(|e| format!("{e}"))? - .into_tree() - .edit() - .map_err(|e| format!("{e}"))?; - editor - .upsert( - b".gitattributes".as_bstr(), - gix::objs::tree::EntryKind::Blob, - attrs_blob, - ) - .map_err(|e| format!("{e}"))?; - editor - .upsert( - b".gitvendors".as_bstr(), - gix::objs::tree::EntryKind::Blob, - vendors_blob, - ) - .map_err(|e| format!("{e}"))?; - Ok(editor.write().map_err(|e| format!("{e}"))?.detach()) -} - -/// Mint a vendor merge commit using `tree` and advance HEAD. -/// -/// In squash mode a parentless squash commit is minted and used as the -/// second parent; in merge mode the upstream commit is used directly. -fn commit_and_advance( - repo: &gix::Repository, - entry: &VendorEntry, - merge: &git_vendor::VendorMerge, - tree: gix::ObjectId, - parent: gix::ObjectId, - message: &str, -) -> Result<()> { - let author = author_sig(repo)?; - let committer = committer_sig(repo)?; - - let mut tbuf_a = gix::date::parse::TimeBuf::default(); - let mut tbuf_c = gix::date::parse::TimeBuf::default(); - - let second_parent = if entry.mode == VendorMode::Squash { - let upstream_tree = repo.upstream_tree(entry, merge.upstream_commit)?; - let squash = gix::objs::Commit { - tree: upstream_tree, - parents: Default::default(), - author: author.to_ref(&mut tbuf_a).into(), - committer: committer.to_ref(&mut tbuf_c).into(), - encoding: None, - message: format!( - "squash: vendor '{}'\n\nSquashed-upstream: {}\n", - entry.name, merge.upstream_commit - ) - .into(), - extra_headers: Vec::new(), - }; - repo.write_object(&squash)?.detach() - } else { - merge.upstream_commit - }; - - let mut tbuf_a2 = gix::date::parse::TimeBuf::default(); - let mut tbuf_c2 = gix::date::parse::TimeBuf::default(); - let commit = gix::objs::Commit { - tree, - parents: [parent, second_parent].into_iter().collect(), - author: author.to_ref(&mut tbuf_a2).into(), - committer: committer.to_ref(&mut tbuf_c2).into(), - encoding: None, - message: message.into(), - extra_headers: Vec::new(), - }; - let new_commit = repo.write_object(&commit)?.detach(); - advance_head(repo, new_commit, parent, message) -} - fn name_from_url(url: &str) -> Option { let stem = url .trim_end_matches('/') diff --git a/crates/git-vendor/src/exe_tests.rs b/crates/git-vendor/src/exe_tests.rs index 4919396..c0e64f2 100644 --- a/crates/git-vendor/src/exe_tests.rs +++ b/crates/git-vendor/src/exe_tests.rs @@ -16,61 +16,6 @@ fn git(args: &[&str], dir: &Path) { ); } -/// `advance_head` must reject a stale `parent` instead of silently -/// overwriting a commit another process made after the caller -/// snapshotted `current_head`. -#[test] -fn advance_head_rejects_stale_parent() { - let dir = tempfile::tempdir().unwrap(); - git(&["init", "-q", "-b", "main"], dir.path()); - git(&["config", "user.email", "t@example.com"], dir.path()); - git(&["config", "user.name", "T"], dir.path()); - std::fs::write(dir.path().join("f"), "one").unwrap(); - git(&["add", "f"], dir.path()); - git(&["commit", "-q", "-m", "one"], dir.path()); - - let repo = gix::open(dir.path()).expect("gix open"); - let stale_parent = repo.head_commit().expect("head").id().detach(); - let tree = repo - .head_commit() - .expect("head") - .tree_id() - .expect("tree") - .detach(); - - // Simulate a concurrent writer advancing HEAD after we snapshotted it. - git( - &["commit", "-q", "--allow-empty", "-m", "concurrent"], - dir.path(), - ); - let concurrent = repo.head_commit().expect("head").id().detach(); - assert_ne!(concurrent, stale_parent); - - let author = author_sig(&repo).expect("author"); - let committer = committer_sig(&repo).expect("committer"); - let mut tbuf_a = gix::date::parse::TimeBuf::default(); - let mut tbuf_c = gix::date::parse::TimeBuf::default(); - let commit = gix::objs::Commit { - tree, - parents: [stale_parent].into_iter().collect(), - author: author.to_ref(&mut tbuf_a).into(), - committer: committer.to_ref(&mut tbuf_c).into(), - encoding: None, - message: "stale update".into(), - extra_headers: Vec::new(), - }; - let new_commit = repo.write_object(&commit).expect("write").detach(); - - let result = advance_head(&repo, new_commit, stale_parent, "stale update"); - assert!(result.is_err(), "advance_head must reject a stale parent"); - - let head_after = repo.head_commit().expect("head").id().detach(); - assert_eq!( - head_after, concurrent, - "the concurrent commit must remain HEAD after the stale write is rejected" - ); -} - fn test_entry() -> VendorEntry { VendorEntry { name: VendorName::new("mylib").unwrap(), From 75f086cbfbf816499eedcf1af553e3b0b9be4b3e Mon Sep 17 00:00:00 2001 From: "Joseph D. Carpinelli" Date: Fri, 3 Jul 2026 13:21:54 -0400 Subject: [PATCH 28/31] refactor: extract shared merge/entry-resolution helpers in exe.rs `add` and `update` each inlined an identical checkout/reconcile/stage sequence for applying a merge result; factored into `apply_merge`. The name-or-all entry lookup repeated in `update`, `update_no_fetch`, and `status` is now `resolve_entries`. The local-modification diff in `update_no_fetch` is now `locally_modified_paths`. Assisted-by: Claude:claude-sonnet-5 --- crates/git-vendor/src/exe.rs | 244 ++++++++++++++++++++--------------- 1 file changed, 141 insertions(+), 103 deletions(-) diff --git a/crates/git-vendor/src/exe.rs b/crates/git-vendor/src/exe.rs index c6b0e00..abe7a92 100644 --- a/crates/git-vendor/src/exe.rs +++ b/crates/git-vendor/src/exe.rs @@ -142,34 +142,19 @@ impl Executor { match head_oid { Some(ours) => { let merge = repo.merge_vendor(&entry, ours, upstream)?; - let new_paths = tree_paths(repo, merge.result_tree)?; - let path_refs: Vec<&gix::bstr::BStr> = - new_paths.iter().map(|b| b.as_ref()).collect(); - git_vendor::validate_trackable_paths(&path_refs)?; - - if merge.has_conflicts() { - repo.checkout_vendor_conflicted(&entry, &merge)?; - reconcile_tracked_paths(repo, &entry, &[], &new_paths, io)?; - entry.base = Some(merge.upstream_commit); - config.insert(&entry)?; - let config_str = save_config(&config, &cfg_path)?; - stage_gitvendors(repo, config_str.as_bytes())?; - repo.prepare_merge(&entry, &merge, &msg)?; - let paths: Vec<_> = merge.conflicts.iter().map(|c| c.path.as_str()).collect(); - writeln!(io.err, "Conflict in: {}", paths.join(", "))?; - writeln!(io.err, "Resolve conflicts, then run `git commit`.")?; + let conflicted = self.apply_merge( + &mut config, + &cfg_path, + &mut entry, + &merge, + &[], + &msg, + false, + io, + )?; + if conflicted { return Err(ConflictExit.into()); } - - let _full_tree = repo.checkout_vendor(&entry, merge.result_tree)?; - reconcile_tracked_paths(repo, &entry, &[], &new_paths, io)?; - - entry.base = Some(merge.upstream_commit); - config.insert(&entry)?; - let config_str = save_config(&config, &cfg_path)?; - - stage_gitvendors(repo, config_str.as_bytes())?; - repo.prepare_merge(&entry, &merge, &msg)?; writeln!(io.err, "Staged; run `git commit` to complete.")?; } None => { @@ -211,11 +196,7 @@ impl Executor { let cfg_path = config_path(repo)?; let mut config = load_config(&cfg_path)?; - let entries: Vec = match name { - Some(ref n) => vec![require_entry(&config, n)?], - None => config.entries()?, - }; - + let entries = resolve_entries(&config, name.as_deref())?; if entries.is_empty() { writeln!(io.err, "No vendors configured.")?; return Ok(()); @@ -226,8 +207,6 @@ impl Executor { .map(|c| c.id().detach()) .map_err(|e| format!("HEAD: {e}"))?; - let current_head = head_oid; - for mut entry in entries { let n = entry.name.as_str().to_owned(); writeln!(io.err, "Fetching {n}…")?; @@ -258,37 +237,21 @@ impl Executor { .clone() .unwrap_or_else(|| format!("vendor: update {n}")); - let old_paths: Vec = repo.vendor_paths(&entry, current_head)?; - - let merge = repo.merge_vendor(&entry, current_head, upstream)?; - let new_paths = tree_paths(repo, merge.result_tree)?; - let path_refs: Vec<&gix::bstr::BStr> = new_paths.iter().map(|b| b.as_ref()).collect(); - git_vendor::validate_trackable_paths(&path_refs)?; - - if merge.has_conflicts() { - repo.checkout_vendor_conflicted(&entry, &merge)?; - reconcile_tracked_paths(repo, &entry, &old_paths, &new_paths, io)?; - entry.base = Some(merge.upstream_commit); - config.insert(&entry)?; - let config_str = save_config(&config, &cfg_path)?; - stage_gitvendors(repo, config_str.as_bytes())?; - repo.prepare_merge(&entry, &merge, &msg)?; - let paths: Vec<_> = merge.conflicts.iter().map(|c| c.path.as_str()).collect(); - writeln!(io.err, "{n}: conflict in {}", paths.join(", "))?; - writeln!(io.err, "Resolve conflicts, then run `git commit`.")?; + let old_paths: Vec = repo.vendor_paths(&entry, head_oid)?; + let merge = repo.merge_vendor(&entry, head_oid, upstream)?; + let conflicted = self.apply_merge( + &mut config, + &cfg_path, + &mut entry, + &merge, + &old_paths, + &msg, + true, + io, + )?; + if conflicted { return Err(ConflictExit.into()); } - - let _full_tree = repo.checkout_vendor(&entry, merge.result_tree)?; - reconcile_tracked_paths(repo, &entry, &old_paths, &new_paths, io)? - .ok_or("`.gitattributes` has an unresolved conflict; resolve it before updating")?; - - entry.base = Some(merge.upstream_commit); - config.insert(&entry)?; - let config_str = save_config(&config, &cfg_path)?; - - stage_gitvendors(repo, config_str.as_bytes())?; - repo.prepare_merge(&entry, &merge, &msg)?; writeln!(io.err, "Updated {n}. Run `git commit` to record the merge.")?; // `prepare_merge` overwrites MERGE_HEAD rather than accumulating an @@ -301,6 +264,62 @@ impl Executor { Ok(()) } + /// Validate and stage a merge result shared by `add` and `update`: checks + /// that upstream paths are trackable, checks out the merged tree + /// (conflicted or clean), reconciles `.gitattributes`, records the new + /// base, and stages `.gitvendors`. Returns `Ok(true)` if the merge left + /// conflicts, having already printed the conflict message — the caller + /// should return `Err(ConflictExit)`. `require_reconcile` controls + /// whether an unresolved `.gitattributes` conflict on a clean merge is + /// itself treated as an error (true for `update`, which has old paths to + /// reconcile against; false for `add`'s first-merge case, which has none). + #[allow(clippy::too_many_arguments)] + fn apply_merge( + &self, + config: &mut VendorConfig, + cfg_path: &Path, + entry: &mut VendorEntry, + merge: &git_vendor::VendorMerge, + old_paths: &[gix::bstr::BString], + msg: &str, + require_reconcile: bool, + io: &mut Io, + ) -> Result { + let repo = &self.0; + let new_paths = tree_paths(repo, merge.result_tree)?; + let path_refs: Vec<&gix::bstr::BStr> = new_paths.iter().map(|b| b.as_ref()).collect(); + git_vendor::validate_trackable_paths(&path_refs)?; + + if merge.has_conflicts() { + repo.checkout_vendor_conflicted(entry, merge)?; + reconcile_tracked_paths(repo, entry, old_paths, &new_paths, io)?; + entry.base = Some(merge.upstream_commit); + config.insert(entry)?; + let config_str = save_config(config, cfg_path)?; + stage_gitvendors(repo, config_str.as_bytes())?; + repo.prepare_merge(entry, merge, msg)?; + let paths: Vec<_> = merge.conflicts.iter().map(|c| c.path.as_str()).collect(); + writeln!(io.err, "{}: conflict in {}", entry.name, paths.join(", "))?; + writeln!(io.err, "Resolve conflicts, then run `git commit`.")?; + return Ok(true); + } + + let _full_tree = repo.checkout_vendor(entry, merge.result_tree)?; + let reconciled = reconcile_tracked_paths(repo, entry, old_paths, &new_paths, io)?; + if require_reconcile { + reconciled + .ok_or("`.gitattributes` has an unresolved conflict; resolve it before updating")?; + } + + entry.base = Some(merge.upstream_commit); + config.insert(entry)?; + let config_str = save_config(config, cfg_path)?; + + stage_gitvendors(repo, config_str.as_bytes())?; + repo.prepare_merge(entry, merge, msg)?; + Ok(false) + } + /// Rebuild vendored files from `.gitvendors` without fetching (`update /// --no-fetch`). Use after editing a vendor's `pattern` entries to move /// or refilter its files. Refuses a modified vendor unless `force`. @@ -309,11 +328,7 @@ impl Executor { let cfg_path = config_path(repo)?; let config = load_config(&cfg_path)?; - let entries: Vec = match name { - Some(ref n) => vec![require_entry(&config, n)?], - None => config.entries()?, - }; - + let entries = resolve_entries(&config, name.as_deref())?; if entries.is_empty() { writeln!(io.err, "No vendors configured.")?; return Ok(()); @@ -341,37 +356,15 @@ impl Executor { continue; }; - let pristine = old_config - .as_ref() - .and_then(|c| c.get(&n).ok().flatten()) - .and_then(|old| old.base.map(|b| (old, b))) - .map(|(old, b)| repo.upstream_tree(&old, b)) - .transpose()?; - if let Some(pristine) = pristine { - let ours = repo.ours_tree(&entry, head_oid)?; - if ours != pristine && !force { - let pristine_blobs = tree_blobs(repo, pristine)?; - let our_blobs = tree_blobs(repo, ours)?; - let mut modified: Vec = our_blobs - .iter() - .filter(|(p, oid)| pristine_blobs.get(*p) != Some(oid)) - .map(|(p, _)| p.to_string()) - .collect(); - modified.extend( - pristine_blobs - .keys() - .filter(|p| !our_blobs.contains_key(*p)) - .map(|p| p.to_string()), - ); - modified.sort(); - writeln!( - io.err, - "{n}: vendored files have local modifications ({}); \ - re-run with --force to discard them", - modified.join(", ") - )?; - continue; - } + let modified = locally_modified_paths(repo, old_config.as_ref(), &entry, head_oid)?; + if !modified.is_empty() && !force { + writeln!( + io.err, + "{n}: vendored files have local modifications ({}); \ + re-run with --force to discard them", + modified.join(", ") + )?; + continue; } let new_tree = repo.upstream_tree(&entry, base)?; @@ -399,11 +392,7 @@ impl Executor { let cfg_path = config_path(repo)?; let config = load_config(&cfg_path)?; - let entries: Vec = match name { - Some(ref n) => vec![require_entry(&config, n)?], - None => config.entries()?, - }; - + let entries = resolve_entries(&config, name.as_deref())?; if entries.is_empty() { writeln!(io.err, "No vendors configured.")?; return Ok(()); @@ -534,6 +523,55 @@ fn require_entry(config: &VendorConfig, name: &str) -> Result { .ok_or_else(|| format!("no vendor named {name:?}").into()) } +/// Resolve `name` to a single-entry list, or all configured vendors if omitted. +fn resolve_entries(config: &VendorConfig, name: Option<&str>) -> Result> { + match name { + Some(n) => Ok(vec![require_entry(config, n)?]), + None => Ok(config.entries()?), + } +} + +/// Local-modification guard for `update --no-fetch`: compares `entry`'s +/// current working tree against the pristine upstream tree of its last +/// recorded base, returning the sorted list of differing paths (empty if +/// unmodified or if there's no prior recorded base to compare against). +fn locally_modified_paths( + repo: &gix::Repository, + old_config: Option<&VendorConfig>, + entry: &VendorEntry, + head_oid: gix::ObjectId, +) -> Result> { + let pristine = old_config + .and_then(|c| c.get(entry.name.as_str()).ok().flatten()) + .and_then(|old| old.base.map(|b| (old, b))) + .map(|(old, b)| repo.upstream_tree(&old, b)) + .transpose()?; + let Some(pristine) = pristine else { + return Ok(Vec::new()); + }; + + let ours = repo.ours_tree(entry, head_oid)?; + if ours == pristine { + return Ok(Vec::new()); + } + + let pristine_blobs = tree_blobs(repo, pristine)?; + let our_blobs = tree_blobs(repo, ours)?; + let mut modified: Vec = our_blobs + .iter() + .filter(|(p, oid)| pristine_blobs.get(*p) != Some(oid)) + .map(|(p, _)| p.to_string()) + .collect(); + modified.extend( + pristine_blobs + .keys() + .filter(|p| !our_blobs.contains_key(*p)) + .map(|p| p.to_string()), + ); + modified.sort(); + Ok(modified) +} + fn tree_paths(repo: &gix::Repository, tree_id: gix::ObjectId) -> Result> { let index = repo.index_from_tree(&tree_id)?; Ok(index From aa965715a8991c033e31f1b638ffa0c1aefa9460 Mon Sep 17 00:00:00 2001 From: "Joseph D. Carpinelli" Date: Fri, 3 Jul 2026 13:36:35 -0400 Subject: [PATCH 29/31] fix: prune empty ancestor directories after remove `remove` deleted individual vendored files but left now-empty parent directories behind on disk. Assisted-by: Claude:claude-sonnet-5 --- crates/git-vendor/src/exe.rs | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/crates/git-vendor/src/exe.rs b/crates/git-vendor/src/exe.rs index abe7a92..0e40e8b 100644 --- a/crates/git-vendor/src/exe.rs +++ b/crates/git-vendor/src/exe.rs @@ -445,6 +445,7 @@ impl Executor { if abs.symlink_metadata().is_ok() { std::fs::remove_file(&abs)?; } + remove_empty_ancestors(&abs, workdir); } } @@ -523,6 +524,22 @@ fn require_entry(config: &VendorConfig, name: &str) -> Result { .ok_or_else(|| format!("no vendor named {name:?}").into()) } +/// Remove `path`'s parent directory and each ancestor above it, as long as +/// they're empty and still inside `workdir`. Stops at the first non-empty or +/// out-of-bounds directory. +fn remove_empty_ancestors(path: &Path, workdir: &Path) { + let mut dir = path.parent(); + while let Some(d) = dir { + if d == workdir || !d.starts_with(workdir) { + break; + } + if std::fs::remove_dir(d).is_err() { + break; + } + dir = d.parent(); + } +} + /// Resolve `name` to a single-entry list, or all configured vendors if omitted. fn resolve_entries(config: &VendorConfig, name: Option<&str>) -> Result> { match name { From 216c2afff38a6fd02b7b526ebab62b77f96bc8eb Mon Sep 17 00:00:00 2001 From: "Joseph D. Carpinelli" Date: Fri, 3 Jul 2026 13:38:41 -0400 Subject: [PATCH 30/31] test: record proptest regression seed for vendor_tip Seed from a run that hit ENOSPC mid-test, not a genuine logic failure; case now passes and is checked in per convention. Assisted-by: Claude:claude-sonnet-5 --- .../tests/vendor_tip/property.proptest-regressions | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 crates/git-vendor/tests/vendor_tip/property.proptest-regressions diff --git a/crates/git-vendor/tests/vendor_tip/property.proptest-regressions b/crates/git-vendor/tests/vendor_tip/property.proptest-regressions new file mode 100644 index 0000000..a8b8d03 --- /dev/null +++ b/crates/git-vendor/tests/vendor_tip/property.proptest-regressions @@ -0,0 +1,7 @@ +# Seeds for failure cases proptest has generated in the past. It is +# automatically read and these particular cases re-run before any +# novel cases are generated. +# +# It is recommended to check this file in to source control so that +# everyone who runs the test benefits from these saved cases. +cc dbcfa7967e8aa39e022c3556d410bf666654260f8e998f181e6ff64a117f73b3 # shrinks to name = VendorName("m5l5add-j84") From bc20425b14e2e2ebf4fa29d49a0062fb496f7c98 Mon Sep 17 00:00:00 2001 From: "Joseph D. Carpinelli" Date: Fri, 3 Jul 2026 13:45:09 -0400 Subject: [PATCH 31/31] test: drop spurious proptest regression seed The seed came from a run that hit ENOSPC mid-test, not a genuine logic failure in the code under test. Assisted-by: Claude:claude-sonnet-5 --- .../tests/vendor_tip/property.proptest-regressions | 7 ------- 1 file changed, 7 deletions(-) delete mode 100644 crates/git-vendor/tests/vendor_tip/property.proptest-regressions diff --git a/crates/git-vendor/tests/vendor_tip/property.proptest-regressions b/crates/git-vendor/tests/vendor_tip/property.proptest-regressions deleted file mode 100644 index a8b8d03..0000000 --- a/crates/git-vendor/tests/vendor_tip/property.proptest-regressions +++ /dev/null @@ -1,7 +0,0 @@ -# Seeds for failure cases proptest has generated in the past. It is -# automatically read and these particular cases re-run before any -# novel cases are generated. -# -# It is recommended to check this file in to source control so that -# everyone who runs the test benefits from these saved cases. -cc dbcfa7967e8aa39e022c3556d410bf666654260f8e998f181e6ff64a117f73b3 # shrinks to name = VendorName("m5l5add-j84")