git-utils: stage patch paths without recursive add

This commit is contained in:
Chris Bookholt
2026-07-01 18:57:30 -07:00
parent d98e980a31
commit 17f491ab67
6 changed files with 1830 additions and 17 deletions
@@ -0,0 +1,65 @@
//! Filesystem-spelling policy for exact staging.
use std::collections::BTreeMap;
use std::collections::BTreeSet;
use std::ffi::OsStr;
use std::ffi::OsString;
use std::io;
use std::path::Path;
use std::path::PathBuf;
/// Find requested paths that are neither exact index names nor composed of
/// physically exact directory-entry spellings.
///
/// Git's byte-oriented ignore-case matching does not cover every alias that a
/// case-insensitive filesystem recognizes. Exact index names remain valid for
/// tracked updates and deletions. Other paths must exist under their requested
/// spelling instead of merely resolving through a case or normalization alias;
/// unresolved paths are refused before a mixed update can partially stage.
pub(crate) fn filesystem_spelling_conflicts(
git_root: &Path,
paths: &[String],
exact_index_paths: &BTreeSet<&[u8]>,
) -> io::Result<BTreeSet<String>> {
let mut cache = BTreeMap::<PathBuf, Option<BTreeSet<OsString>>>::new();
let mut conflicts = BTreeSet::new();
for path in paths {
if exact_index_paths.contains(path.as_bytes()) {
continue;
}
if !has_exact_directory_entry_spelling(git_root, path, &mut cache)? {
conflicts.insert(path.clone());
}
}
Ok(conflicts)
}
fn has_exact_directory_entry_spelling(
git_root: &Path,
path: &str,
cache: &mut BTreeMap<PathBuf, Option<BTreeSet<OsString>>>,
) -> io::Result<bool> {
let mut parent = git_root.to_path_buf();
for component in path.split('/') {
if !cache.contains_key(&parent) {
let entries = match std::fs::read_dir(&parent) {
Ok(entries) => Some(
entries
.map(|entry| entry.map(|entry| entry.file_name()))
.collect::<io::Result<BTreeSet<_>>>()?,
),
Err(error) if error.kind() == io::ErrorKind::NotFound => None,
Err(error) => return Err(error),
};
cache.insert(parent.clone(), entries);
}
let Some(Some(entries)) = cache.get(&parent) else {
return Ok(false);
};
if !entries.contains(OsStr::new(component)) {
return Ok(false);
}
parent.push(component);
}
Ok(true)
}
@@ -0,0 +1,481 @@
//! Index-name and flag policy for exact staging.
use std::collections::BTreeMap;
use std::collections::BTreeSet;
use std::io;
use std::path::Path;
use crate::exact_filesystem_policy::filesystem_spelling_conflicts;
use crate::git_command::GitRunner;
#[derive(Clone, Debug, Eq, PartialEq)]
pub(crate) enum ExactIndexPolicy {
Proceed {
paths: Vec<String>,
content_filter_paths: Vec<String>,
},
Refuse {
stderr: String,
},
}
#[derive(Clone, Copy, Debug)]
struct IndexEntry<'a> {
tag: u8,
path: &'a [u8],
}
#[derive(Debug)]
struct CaseConflict {
reason: &'static str,
paths: BTreeSet<String>,
}
impl CaseConflict {
fn new(reason: &'static str, paths: impl IntoIterator<Item = String>) -> Self {
Self {
reason,
paths: paths.into_iter().collect(),
}
}
fn add_index_paths<'a>(&mut self, paths: impl IntoIterator<Item = &'a [u8]>) {
self.paths.extend(
paths
.into_iter()
.map(|path| String::from_utf8_lossy(path).into_owned()),
);
}
fn message(&self) -> String {
format!(
"refusing {} under core.ignoreCase: {}",
self.reason,
quote_paths(&self.paths)
)
}
}
struct IndexEvidence<'a> {
full_paths: BTreeMap<Vec<u8>, BTreeSet<&'a [u8]>>,
directory_prefixes: BTreeMap<Vec<u8>, BTreeSet<&'a [u8]>>,
}
impl<'a> IndexEvidence<'a> {
fn collect(entries: &[IndexEntry<'a>], requested: &[String]) -> Self {
let relevant = requested
.iter()
.flat_map(|path| path_prefixes(path.as_bytes()))
.map(ascii_fold)
.collect::<BTreeSet<_>>();
let mut full_paths = BTreeMap::<Vec<u8>, BTreeSet<&[u8]>>::new();
let mut directory_prefixes = BTreeMap::<Vec<u8>, BTreeSet<&[u8]>>::new();
for entry in entries {
let folded = ascii_fold(entry.path);
if relevant.contains(&folded) {
full_paths.entry(folded).or_default().insert(entry.path);
}
for prefix in non_leaf_prefixes(entry.path) {
let folded = ascii_fold(prefix);
if relevant.contains(&folded) {
directory_prefixes.entry(folded).or_default().insert(prefix);
}
}
}
Self {
full_paths,
directory_prefixes,
}
}
}
pub(crate) fn resolve_exact_index_policy(
git: &GitRunner,
git_root: &Path,
paths: &[String],
content_filter_paths: &[String],
git_config_args: &[String],
) -> io::Result<ExactIndexPolicy> {
let output = read_index(git, git_root, git_config_args)?;
let entries = parse_index_entries(&output)?;
let ignore_case =
effective_git_bool(git, git_root, git_config_args, "core.ignoreCase")?.unwrap_or(false);
let (paths, content_filter_paths) = if ignore_case {
let mapped = match map_ignore_case_paths(git_root, &entries, paths, content_filter_paths) {
Ok(mapped) => mapped,
Err(conflict) => {
return Ok(ExactIndexPolicy::Refuse {
stderr: conflict.message(),
});
}
};
let exact_index_paths = entries
.iter()
.map(|entry| entry.path)
.collect::<BTreeSet<_>>();
let conflicts = filesystem_spelling_conflicts(git_root, &mapped.0, &exact_index_paths)?;
if !conflicts.is_empty() {
return Ok(ExactIndexPolicy::Refuse {
stderr: CaseConflict::new(
"filesystem spelling aliases or unresolved paths",
conflicts,
)
.message(),
});
}
mapped
} else {
(paths.to_vec(), content_filter_paths.to_vec())
};
let exact = paths.iter().map(String::as_bytes).collect::<BTreeSet<_>>();
let mut skipped = BTreeSet::new();
let mut assumed_unchanged = BTreeSet::new();
for entry in &entries {
if !exact.contains(entry.path) {
continue;
}
let path = std::str::from_utf8(entry.path).map_err(|_| {
io::Error::new(
io::ErrorKind::InvalidData,
"non-UTF-8 exact path in Git index output",
)
})?;
if entry.tag == b'S' {
skipped.insert(path.to_string());
}
if entry.tag.is_ascii_lowercase() {
assumed_unchanged.insert(path.to_string());
}
}
if skipped.is_empty() && assumed_unchanged.is_empty() {
return Ok(ExactIndexPolicy::Proceed {
paths,
content_filter_paths,
});
}
let mut reasons = Vec::new();
if !skipped.is_empty() {
reasons.push(format!("skip-worktree path(s): {}", quote_paths(&skipped)));
}
if !assumed_unchanged.is_empty() {
reasons.push(format!(
"assume-unchanged path(s): {}",
quote_paths(&assumed_unchanged)
));
}
Ok(ExactIndexPolicy::Refuse {
stderr: format!("refusing to stage {}", reasons.join("; ")),
})
}
fn read_index(git: &GitRunner, git_root: &Path, git_config_args: &[String]) -> io::Result<Vec<u8>> {
// One full, byte-oriented scan avoids invoking Git once per patch path and
// lets one parse enforce aliases, directory prefixes, and flags together.
let mut command = git.command();
command
.env("GIT_OPTIONAL_LOCKS", "0")
.args(git_config_args)
.args(["ls-files", "--cached", "-v", "-z"])
.current_dir(git_root);
let output = git.output(command)?;
if !output.status.success() {
return Err(io::Error::other(format!(
"git exact-index probe failed with status {}: {}",
output.status,
String::from_utf8_lossy(&output.stderr).trim()
)));
}
Ok(output.stdout)
}
fn parse_index_entries(output: &[u8]) -> io::Result<Vec<IndexEntry<'_>>> {
if output.is_empty() {
return Ok(Vec::new());
}
let Some(body) = output.strip_suffix(&[0]) else {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
"unterminated Git exact-index output",
));
};
body.split(|byte| *byte == 0)
.map(|record| {
let [tag, b' ', path @ ..] = record else {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
"malformed Git exact-index output",
));
};
if !valid_index_tag(*tag) || path.is_empty() {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
"unexpected Git exact-index output",
));
}
Ok(IndexEntry { tag: *tag, path })
})
.collect()
}
fn valid_index_tag(tag: u8) -> bool {
matches!(
tag,
b'H' | b'S' | b'M' | b'R' | b'C' | b'K' | b'h' | b's' | b'm' | b'r' | b'c' | b'k' | b'?'
)
}
fn map_ignore_case_paths(
git_root: &Path,
entries: &[IndexEntry<'_>],
paths: &[String],
content_filter_paths: &[String],
) -> Result<(Vec<String>, Vec<String>), CaseConflict> {
let evidence = IndexEvidence::collect(entries, paths);
let mut groups = BTreeMap::<Vec<u8>, BTreeSet<String>>::new();
for path in paths {
groups
.entry(ascii_fold(path.as_bytes()))
.or_default()
.insert(path.clone());
}
let mut mapping = BTreeMap::new();
for (folded, spellings) in groups {
let aliases = evidence
.full_paths
.get(&folded)
.cloned()
.unwrap_or_default();
let canonical = match (spellings.len(), aliases.len()) {
(1, 0) => {
let Some(spelling) = spellings.first() else {
return Err(CaseConflict::new("empty request group", []));
};
spelling.clone()
}
(1, 1) => {
let Some(alias) = aliases.first() else {
return Err(CaseConflict::new("missing index alias", spellings));
};
let alias = std::str::from_utf8(alias)
.map_err(|_| CaseConflict::new("non-UTF-8 index alias", spellings.clone()))?;
if !spellings.contains(alias) {
let mut conflict =
CaseConflict::new("index full-path alias", spellings.clone());
conflict.add_index_paths(aliases.iter().copied());
return Err(conflict);
}
alias.to_string()
}
(_, 1) => {
let Some(alias) = aliases.first() else {
return Err(CaseConflict::new("missing index alias", spellings));
};
let alias = std::str::from_utf8(alias)
.map_err(|_| CaseConflict::new("non-UTF-8 index alias", spellings.clone()))?;
if !spellings.contains(alias) {
let mut conflict =
CaseConflict::new("index full-path alias", spellings.clone());
conflict.add_index_paths(aliases.iter().copied());
return Err(conflict);
}
if let Some(missing) = spellings.iter().find(|spelling| {
*spelling != alias
&& std::fs::symlink_metadata(git_root.join(spelling)).is_err()
}) {
return Err(CaseConflict::new(
"missing requested case alias",
[missing.clone(), alias.to_string()],
));
}
alias.to_string()
}
(1, _) => {
let mut conflict = CaseConflict::new("ambiguous index aliases", spellings);
conflict.add_index_paths(aliases.iter().copied());
return Err(conflict);
}
_ if aliases.is_empty() => {
return Err(CaseConflict::new(
"request-set full-path aliases",
spellings,
));
}
_ => {
let mut conflict = CaseConflict::new("ambiguous index aliases", spellings);
conflict.add_index_paths(aliases.iter().copied());
return Err(conflict);
}
};
for spelling in spellings {
mapping.insert(spelling, canonical.clone());
}
}
let paths = map_and_dedupe(paths, &mapping)
.ok_or_else(|| CaseConflict::new("unmapped requested path", paths.to_vec()))?;
let content_filter_paths = map_and_dedupe(content_filter_paths, &mapping)
.ok_or_else(|| CaseConflict::new("unmapped content-filter path", paths.clone()))?;
if let Some(conflict) = requested_paths_conflict(&paths) {
return Err(conflict);
}
if let Some(conflict) = index_paths_conflict(&paths, &evidence) {
return Err(conflict);
}
Ok((paths, content_filter_paths))
}
fn map_and_dedupe(paths: &[String], mapping: &BTreeMap<String, String>) -> Option<Vec<String>> {
let mut seen = BTreeSet::new();
let mut mapped = Vec::new();
for path in paths {
let path = mapping.get(path)?.clone();
if seen.insert(path.clone()) {
mapped.push(path);
}
}
Some(mapped)
}
fn requested_paths_conflict(paths: &[String]) -> Option<CaseConflict> {
let mut full = BTreeMap::<Vec<u8>, BTreeSet<String>>::new();
let mut directories = BTreeMap::<Vec<u8>, BTreeSet<String>>::new();
for path in paths {
full.entry(ascii_fold(path.as_bytes()))
.or_default()
.insert(path.clone());
for prefix in non_leaf_prefixes(path.as_bytes()) {
directories
.entry(ascii_fold(prefix))
.or_default()
.insert(String::from_utf8_lossy(prefix).into_owned());
}
}
if let Some(spellings) = full.values().find(|spellings| spellings.len() > 1) {
return Some(CaseConflict::new(
"request-set full-path aliases",
spellings.clone(),
));
}
if let Some(spellings) = directories.values().find(|spellings| spellings.len() > 1) {
return Some(CaseConflict::new(
"request-set directory-prefix aliases",
spellings.clone(),
));
}
for (folded, leaf) in full {
if let Some(prefixes) = directories.get(&folded) {
let paths = leaf.into_iter().chain(prefixes.iter().cloned());
return Some(CaseConflict::new(
"request-set file/directory aliases",
paths,
));
}
}
None
}
fn index_paths_conflict(paths: &[String], evidence: &IndexEvidence<'_>) -> Option<CaseConflict> {
for path in paths {
for prefix in non_leaf_prefixes(path.as_bytes()) {
let folded = ascii_fold(prefix);
if let Some(ancestors) = evidence.full_paths.get(&folded) {
let mut conflict =
CaseConflict::new("index file/requested-directory alias", [path.clone()]);
conflict.add_index_paths(ancestors.iter().copied());
return Some(conflict);
}
if let Some(index_prefixes) = evidence.directory_prefixes.get(&folded)
&& index_prefixes.iter().any(|indexed| *indexed != prefix)
{
let mut conflict =
CaseConflict::new("index directory-prefix alias", [path.clone()]);
conflict.add_index_paths(index_prefixes.iter().copied());
return Some(conflict);
}
}
if let Some(descendants) = evidence
.directory_prefixes
.get(&ascii_fold(path.as_bytes()))
{
let mut conflict =
CaseConflict::new("requested file/index-directory alias", [path.clone()]);
conflict.add_index_paths(descendants.iter().copied());
return Some(conflict);
}
}
None
}
fn path_prefixes(path: &[u8]) -> impl Iterator<Item = &[u8]> {
non_leaf_prefixes(path).chain(std::iter::once(path))
}
fn non_leaf_prefixes(path: &[u8]) -> impl Iterator<Item = &[u8]> {
path.iter()
.enumerate()
.filter_map(move |(index, byte)| (*byte == b'/').then_some(&path[..index]))
}
fn ascii_fold(value: &[u8]) -> Vec<u8> {
value.iter().map(u8::to_ascii_lowercase).collect()
}
pub(crate) fn effective_git_bool(
git: &GitRunner,
git_root: &Path,
git_config_args: &[String],
key: &str,
) -> io::Result<Option<bool>> {
let mut command = git.command();
command
.env("GIT_OPTIONAL_LOCKS", "0")
.args(git_config_args)
.args(["config", "--type=bool", "--get", key])
.current_dir(git_root);
let output = git.output(command)?;
if output.status.code() == Some(1) && output.stdout.is_empty() && output.stderr.is_empty() {
return Ok(None);
}
if !output.status.success() {
return Err(io::Error::other(format!(
"git boolean config probe for {key} failed with status {}: {}",
output.status,
String::from_utf8_lossy(&output.stderr).trim()
)));
}
match String::from_utf8_lossy(&output.stdout).trim() {
"true" => Ok(Some(true)),
"false" => Ok(Some(false)),
value => Err(io::Error::new(
io::ErrorKind::InvalidData,
format!("unexpected normalized Git boolean value for {key}: {value:?}"),
)),
}
}
fn quote_paths(paths: &BTreeSet<String>) -> String {
let shown = paths
.iter()
.take(8)
.map(|path| {
let mut characters = path.chars();
let prefix = characters.by_ref().take(160).collect::<String>();
if characters.next().is_some() {
format!("{prefix}...")
} else {
path.clone()
}
})
.map(|path| format!("{path:?}"))
.collect::<Vec<_>>()
.join(", ");
if paths.len() > 8 {
format!("{shown}, ...")
} else {
shown
}
}
+294
View File
@@ -0,0 +1,294 @@
//! Non-recursive staging for already-confined repository paths.
use std::collections::BTreeSet;
use std::io;
use std::io::Seek;
use std::io::Write;
use std::path::Path;
use std::process::Stdio;
use crate::apply::run_git;
use crate::exact_index_policy::ExactIndexPolicy;
use crate::exact_index_policy::effective_git_bool;
use crate::exact_index_policy::resolve_exact_index_policy;
use crate::git_command::GitRunner;
use crate::patch_paths::validate_patch_path;
use crate::safe_git::ensure_no_selected_git_add_filters;
#[derive(Clone, Debug, Eq, PartialEq)]
pub(crate) struct StagePathsResult {
pub(crate) exit_code: i32,
pub(crate) stderr: String,
}
/// Stage only the literal paths supplied by a caller that has already applied
/// the operation-specific containment and filesystem policy.
///
/// Unlike `git add`, `update-index` never treats a path that races from a leaf
/// to a directory as a recursive request. Callers that require staging to
/// succeed can inspect the returned status; the public forward helper retains
/// its historical best-effort handling of a non-zero Git command.
///
/// This is not a transactional filesystem-confinement primitive. A concurrent
/// strict-ancestor swap to a symlink or Windows junction can still redirect the
/// bytes read for an already-confined index pathname. It cannot make this
/// exact command recurse into, or create index entries for, descendant paths;
/// the filter neutralizer also remains in force for the entire command.
pub(crate) fn update_index_exact_paths(
git: &GitRunner,
git_root: &Path,
paths: &[String],
content_filter_paths: &[String],
git_config_args: &[String],
) -> io::Result<StagePathsResult> {
if paths.is_empty() {
return Ok(StagePathsResult {
exit_code: 0,
stderr: String::new(),
});
}
for path in paths {
if path.as_bytes().contains(&0) {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
"exact staging path contains NUL",
));
}
let _ = validate_patch_path(path.clone())?;
}
let exact_paths = paths.iter().map(String::as_str).collect::<BTreeSet<_>>();
if content_filter_paths
.iter()
.any(|path| !exact_paths.contains(path.as_str()))
{
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
"content-filter staging path is not in the exact staging set",
));
}
let (paths, content_filter_paths) = match resolve_exact_index_policy(
git,
git_root,
paths,
content_filter_paths,
git_config_args,
)? {
ExactIndexPolicy::Proceed {
paths,
content_filter_paths,
} => (paths, content_filter_paths),
ExactIndexPolicy::Refuse { stderr } => {
return Ok(StagePathsResult {
exit_code: 1,
stderr,
});
}
};
let ignored = ignored_untracked_paths(git, git_root, &paths, git_config_args)?;
if !ignored.is_empty() {
return Ok(StagePathsResult {
exit_code: 1,
stderr: format!(
"refusing to stage ignored untracked path(s): {}",
quote_paths(&ignored)
),
});
}
if let Some(result) = sparse_checkout_policy(git, git_root, &paths, git_config_args)? {
return Ok(result);
}
let filter_guard =
ensure_no_selected_git_add_filters(git, git_root, &content_filter_paths, git_config_args)?;
let mut guarded_config = git_config_args.to_vec();
guarded_config.extend_from_slice(filter_guard.git_config_args());
let mut update_args = vec![
"--literal-pathspecs".to_string(),
"update-index".to_string(),
"--add".to_string(),
"--remove".to_string(),
"--".to_string(),
];
update_args.extend_from_slice(&paths);
let (exit_code, _stdout, stderr) = run_git(git, git_root, &guarded_config, &update_args)?;
Ok(StagePathsResult { exit_code, stderr })
}
fn ignored_untracked_paths(
git: &GitRunner,
git_root: &Path,
paths: &[String],
git_config_args: &[String],
) -> io::Result<BTreeSet<String>> {
if paths.is_empty() {
return Ok(BTreeSet::new());
}
// `check-ignore --stdin` still recognizes a leading `:` as pathspec
// magic, while its command mode rejects the global literal-pathspec
// override. A leading `./` makes every normalized repository-relative
// name unambiguously literal without changing which file it denotes.
let probe_paths = paths
.iter()
.map(|path| format!("./{path}"))
.collect::<Vec<_>>();
let input = nul_path_input(&probe_paths)?;
let mut command = git.command();
command
.env("GIT_OPTIONAL_LOCKS", "0")
.args(git_config_args)
// `check-ignore --stdin` consumes pathnames rather than pathspecs and
// rejects the global literal-pathspec mode as unsupported magic.
.args(["check-ignore", "--stdin", "-z"])
.current_dir(git_root)
.stdin(Stdio::from(input));
let output = git.output(command)?;
match output.status.code() {
Some(0) => {
let ignored = parse_reported_paths(&output.stdout, &probe_paths, "ignored-path")?
.into_iter()
.map(|path| path.trim_start_matches("./").to_string())
.collect::<BTreeSet<_>>();
if ignored.is_empty() {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
"Git ignored-path probe reported success without a path",
));
}
Ok(ignored)
}
Some(1) if output.stdout.is_empty() && output.stderr.is_empty() => Ok(BTreeSet::new()),
_ => Err(io::Error::other(format!(
"git ignored-path probe failed with status {}: {}",
output.status,
String::from_utf8_lossy(&output.stderr).trim()
))),
}
}
fn sparse_checkout_policy(
git: &GitRunner,
git_root: &Path,
paths: &[String],
git_config_args: &[String],
) -> io::Result<Option<StagePathsResult>> {
if !effective_git_bool(git, git_root, git_config_args, "core.sparseCheckout")?.unwrap_or(false)
{
return Ok(None);
}
// Do not let an older Git fall back to a PATH-resolved
// `git-sparse-checkout` helper. Repository-controlled PATH entries are
// outside the trusted executable boundary. `--list-cmds=builtins` is a
// main-program capability query and cannot dispatch an external command.
let mut builtins_command = git.command();
builtins_command
.env("GIT_OPTIONAL_LOCKS", "0")
.args(git_config_args)
.arg("--list-cmds=builtins")
.current_dir(git_root);
let builtins = git.output(builtins_command)?;
let sparse_checkout_is_builtin = builtins.status.success()
&& std::str::from_utf8(&builtins.stdout)
.is_ok_and(|output| output.lines().any(|command| command == "sparse-checkout"));
if !sparse_checkout_is_builtin {
return Ok(Some(StagePathsResult {
exit_code: builtins.status.code().unwrap_or(-1).max(1),
stderr: "unable to verify sparse-checkout staging policy with a built-in Git command"
.to_string(),
}));
}
let input = nul_path_input(paths)?;
let mut command = git.command();
command
.env("GIT_OPTIONAL_LOCKS", "0")
.args(git_config_args)
.args(["sparse-checkout", "check-rules", "-z"])
.current_dir(git_root)
.stdin(Stdio::from(input));
let output = git.output(command)?;
if !output.status.success() {
return Ok(Some(StagePathsResult {
exit_code: output.status.code().unwrap_or(-1),
stderr: format!(
"unable to verify sparse-checkout staging policy: {}",
String::from_utf8_lossy(&output.stderr).trim()
),
}));
}
let included = parse_reported_paths(&output.stdout, paths, "sparse-checkout")?;
let excluded = paths
.iter()
.filter(|path| !included.contains(path.as_str()))
.cloned()
.collect::<BTreeSet<_>>();
if excluded.is_empty() {
Ok(None)
} else {
Ok(Some(StagePathsResult {
exit_code: 1,
stderr: format!(
"refusing to stage path(s) outside the sparse-checkout definition: {}",
quote_paths(&excluded)
),
}))
}
}
fn nul_path_input(paths: &[String]) -> io::Result<std::fs::File> {
let mut input = tempfile::tempfile()?;
for path in paths {
input.write_all(path.as_bytes())?;
input.write_all(&[0])?;
}
input.rewind()?;
Ok(input)
}
fn parse_reported_paths(
output: &[u8],
expected_paths: &[String],
probe: &str,
) -> io::Result<BTreeSet<String>> {
if output.is_empty() {
return Ok(BTreeSet::new());
}
let Some(body) = output.strip_suffix(&[0]) else {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
format!("unterminated Git {probe} output"),
));
};
let expected = expected_paths
.iter()
.map(String::as_bytes)
.collect::<BTreeSet<_>>();
let mut reported = BTreeSet::new();
for path in body.split(|byte| *byte == 0) {
if path.is_empty() || !expected.contains(path) {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
format!("unexpected Git {probe} output"),
));
}
let path = std::str::from_utf8(path).map_err(|_| {
io::Error::new(
io::ErrorKind::InvalidData,
format!("non-UTF-8 Git {probe} output"),
)
})?;
reported.insert(path.to_string());
}
Ok(reported)
}
fn quote_paths(paths: &BTreeSet<String>) -> String {
paths
.iter()
.map(|path| format!("{path:?}"))
.collect::<Vec<_>>()
.join(", ")
}
+3
View File
@@ -3,6 +3,9 @@ mod apply_output;
mod baseline;
mod branch;
mod errors;
mod exact_filesystem_policy;
mod exact_index_policy;
mod exact_staging;
mod fsmonitor;
mod git_command;
mod git_config;
+11 -14
View File
@@ -7,9 +7,9 @@ use std::path::PathBuf;
use crate::apply::run_git;
use crate::apply::safe_git_config_parts;
use crate::apply::write_temp_patch;
use crate::exact_staging::update_index_exact_paths;
use crate::git_command::GitRunner;
use crate::git_config::path_is_within;
use crate::safe_git::ensure_no_selected_git_add_filters;
pub(crate) fn extract_effective_paths_from_patch(
git: &GitRunner,
@@ -157,7 +157,7 @@ fn insert_numstat_path(paths: &mut Vec<String>, path: &[u8]) -> io::Result<()> {
Ok(())
}
fn validate_patch_path(path: String) -> io::Result<String> {
pub(crate) fn validate_patch_path(path: String) -> io::Result<String> {
if path.starts_with('/')
|| path.ends_with('/')
|| invalid_platform_patch_path(&path)
@@ -261,18 +261,15 @@ pub(crate) fn stage_effective_paths(
if existing.is_empty() {
return Ok(());
}
let filter_guard =
ensure_no_selected_git_add_filters(git, git_root, &content_filter_paths, git_config_args)?;
let mut args = vec![
"--literal-pathspecs".to_string(),
"add".to_string(),
"--".to_string(),
];
args.extend(existing);
let mut guarded_config = git_config_args.to_vec();
guarded_config.extend_from_slice(filter_guard.git_config_args());
let (_code, _, _) = run_git(git, git_root, &guarded_config, &args)?;
// We do not hard fail staging; best-effort is OK. Return Ok even on non-zero.
let _result = update_index_exact_paths(
git,
git_root,
&existing,
&content_filter_paths,
git_config_args,
)?;
// Preserve the public helper's historical best-effort treatment of a
// non-zero staging command. Security and probe failures still propagate.
Ok(())
}
File diff suppressed because it is too large Load Diff