mirror of
https://github.com/openai/codex.git
synced 2026-11-14 21:40:39 +00:00
git-utils: stage patch paths without recursive add
This commit is contained in:
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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,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;
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user