#![allow(clippy::io_other_error)]
use std::collections::HashSet;
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use std::time::Instant;
use super::{delta_apply, helpers, Index};
use crate::git_util::{is_hex_commit, is_safe_git_path};
use crate::index::freshness::FreshnessError;
use crate::index::manifest::Manifest;
use crate::path_util::{normalize_to_forward_slashes, path_from_bytes};
use crate::IndexError;
const DELTA_MAX_FILES: usize = 5000;
#[derive(Debug, PartialEq, Eq)]
pub(super) enum DeltaOutcome {
Applied,
Fallback,
}
#[derive(Debug, Default)]
pub(super) struct CommittedChanges {
pub added: Vec<PathBuf>,
pub modified: Vec<PathBuf>,
pub deleted: Vec<PathBuf>,
}
impl CommittedChanges {
pub fn len(&self) -> usize {
self.added.len() + self.modified.len() + self.deleted.len()
}
pub fn is_empty(&self) -> bool {
self.len() == 0
}
fn all_paths(&self) -> HashSet<PathBuf> {
self.added
.iter()
.chain(self.modified.iter())
.chain(self.deleted.iter())
.cloned()
.collect()
}
}
pub(super) fn detect_committed_changes(
repo_root: &Path,
git: &Path,
base_commit: &str,
deadline: Option<Instant>,
) -> Result<CommittedChanges, FreshnessError> {
if !is_hex_commit(base_commit) {
return Err(FreshnessError::Io(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
"invalid base_commit",
)));
}
let args = [
"diff",
"--no-renames",
"--name-status",
"-z",
"--end-of-options",
base_commit,
"HEAD",
];
let bytes = match super::freshness::run_git_bounded(git, repo_root, &args, deadline)? {
super::freshness::GitOutput::Complete(bytes) => bytes,
super::freshness::GitOutput::Partial(_) | super::freshness::GitOutput::NoData => {
return Err(FreshnessError::Io(std::io::Error::other(
"git diff --name-status produced no complete output",
)));
}
};
parse_name_status_z(&bytes)
}
fn parse_name_status_z(bytes: &[u8]) -> Result<CommittedChanges, FreshnessError> {
let mut changes = CommittedChanges::default();
let mut tokens: Vec<&[u8]> = bytes.split(|&b| b == 0).collect();
if tokens.last().is_some_and(|t| t.is_empty()) {
tokens.pop();
}
let clean = |raw: &[u8]| -> Result<PathBuf, FreshnessError> {
let p = path_from_bytes(raw);
if !is_safe_git_path(&p) {
return Err(FreshnessError::Io(std::io::Error::new(
std::io::ErrorKind::InvalidData,
"unsafe path in git diff",
)));
}
Ok(normalize_to_forward_slashes(p))
};
let mut i = 0;
while i < tokens.len() {
let kind = tokens[i].first().copied().unwrap_or(b'?');
i += 1;
match kind {
b'A' => {
let path_token = tokens.get(i).ok_or_else(|| {
FreshnessError::Io(std::io::Error::new(
std::io::ErrorKind::InvalidData,
"malformed name-status output (missing path for added)",
))
})?;
let p = clean(path_token)?;
changes.added.push(p);
i += 1;
}
b'M' | b'T' => {
let path_token = tokens.get(i).ok_or_else(|| {
FreshnessError::Io(std::io::Error::new(
std::io::ErrorKind::InvalidData,
"malformed name-status output (missing path for modified)",
))
})?;
let p = clean(path_token)?;
changes.modified.push(p);
i += 1;
}
b'D' => {
let path_token = tokens.get(i).ok_or_else(|| {
FreshnessError::Io(std::io::Error::new(
std::io::ErrorKind::InvalidData,
"malformed name-status output (missing path for deleted)",
))
})?;
let p = clean(path_token)?;
changes.deleted.push(p);
i += 1;
}
_ => {
return Err(FreshnessError::Io(std::io::Error::new(
std::io::ErrorKind::InvalidData,
format!("unsupported or malformed status kind: {}", kind as char),
)));
}
}
}
Ok(changes)
}
pub(super) fn is_ancestor(git: &Path, repo_root: &Path, base: &str, head: &str) -> bool {
if !is_hex_commit(base) || !is_hex_commit(head) {
return false;
}
Command::new(git)
.arg("-C")
.arg(repo_root)
.args([
"merge-base",
"--is-ancestor",
"--end-of-options",
base,
head,
])
.stdout(Stdio::null())
.stderr(Stdio::null())
.status()
.map(|s| s.success())
.unwrap_or(false)
}
pub(super) fn should_delta(
manifest: &Manifest,
changes: &CommittedChanges,
config: &crate::Config,
) -> bool {
!changes.is_empty()
&& changes.len() <= DELTA_MAX_FILES
&& (changes.added.is_empty() && changes.modified.is_empty()
|| manifest.segments.len() < config.max_segments.max(1))
}
impl Index {
pub(super) fn try_committed_delta(
&self,
manifest: &Manifest,
current_head: Option<&str>,
) -> Result<Option<crate::IndexStats>, IndexError> {
let (Some(base), Some(head)) = (manifest.base_commit.as_deref(), current_head) else {
return Ok(None);
};
let git = crate::git_util::resolve_git_binary();
if !git.is_file() || !is_ancestor(&git, &self.canonical_root, base, head) {
return Ok(None);
}
let Ok(changes) = detect_committed_changes(&self.canonical_root, &git, base, None) else {
return Ok(None);
};
if !should_delta(manifest, &changes, &self.config) {
return Ok(None);
}
match self.apply_committed_delta_update(changes) {
Ok(DeltaOutcome::Applied) => {
self.maybe_compact()?;
Ok(Some(self.stats()))
}
Ok(DeltaOutcome::Fallback) => Ok(None),
Err(e) => {
log::debug!("delta apply failed ({e}); full rebuild instead");
Ok(None)
}
}
}
pub(super) fn apply_committed_delta_update(
&self,
changes: CommittedChanges,
) -> Result<DeltaOutcome, IndexError> {
let (_applied, _skipped) = self.apply_changed_paths(&changes.all_paths());
match self.commit_batch() {
Ok(()) => {}
Err(IndexError::OverlayFull { .. }) => return Ok(DeltaOutcome::Fallback),
Err(e) => return Err(e),
}
let head = helpers::current_repo_head(&self.config.repo_root)?;
let write_lock = helpers::acquire_writer_lock(&self.config.index_dir)?;
let snapshot = self.snapshot();
self._dir_lock.unlock()?;
let rebuilt = match delta_apply::flush_overlay_as_delta(
self.config.clone(),
snapshot,
head,
write_lock,
) {
Ok(rebuilt) => rebuilt,
Err(err) => {
if let Err(e) = self._dir_lock.try_lock_shared() {
log::debug!(
"failed to re-acquire shared directory lock after delta error: {e}"
);
}
return Err(err);
}
};
self._dir_lock
.try_lock_shared()
.map_err(|_| IndexError::LockConflict(self.config.index_dir.clone()))?;
self.install_rebuilt_index(&rebuilt)?;
Ok(DeltaOutcome::Applied)
}
}