use std::path::{Path, PathBuf};
use super::git::{GitEnv, spawn_git_bytes, spawn_git_bytes_with_input};
use super::prefix::{from_prefix, paths_from};
use super::quoting::path_from_bytes;
use super::{GitError, StagedView};
#[derive(Debug, Default, PartialEq, Eq)]
pub struct Uncommitted {
pub differing: Vec<PathBuf>,
pub index_only: Vec<PathBuf>,
}
pub async fn uncommitted_paths(root: &Path) -> Result<Uncommitted, GitError> {
let view = StagedView::of(root).await?;
let (tracked, untracked, listing) = tokio::join!(
spawn_git_bytes(
root,
&[
"--no-optional-locks",
"diff",
"--name-only",
"-z",
"--ignore-submodules=none",
"--no-relative",
"--no-ext-diff",
"--no-textconv",
],
view.env(),
),
spawn_git_bytes(
root,
&[
"--no-optional-locks",
"ls-files",
"-z",
"--others",
"--exclude-standard",
"--full-name",
"--",
":/",
],
view.env(),
),
spawn_git_bytes(
root,
&[
"--no-optional-locks",
"ls-files",
"-z",
"--stage",
"-v",
"--full-name",
"--",
":/",
],
view.env(),
)
);
let (tracked, untracked) = (tracked?, untracked?);
let mut paths = paths_from(
listed(&tracked).chain(listed(&untracked)).collect(),
&view.prefix,
);
let listing = listing?;
let (flagged, filtered) = tokio::join!(
flagged_differences(root, &view, &listing),
filtered_differences(root, &view, &listing)
);
let flagged = flagged?;
paths.extend(filtered?);
paths.extend(flagged.differing);
paths.sort();
paths.dedup();
Ok(Uncommitted {
differing: paths,
index_only: flagged.index_only,
})
}
pub async fn diverging_from(root: &Path, commit: &str) -> Result<Uncommitted, GitError> {
super::refuse_flag_like(commit)?;
let view = StagedView::of(root).await?;
let recorded_args = [
"--no-optional-locks",
"diff",
"--cached",
"--name-only",
"-z",
"--ignore-submodules=none",
"--no-relative",
"--no-ext-diff",
"--no-textconv",
commit,
"--",
];
let (worktree, recorded) = tokio::join!(
uncommitted_paths(root),
spawn_git_bytes(root, &recorded_args, view.env())
);
let mut uncommitted = worktree?;
uncommitted
.differing
.extend(paths_from(listed(&recorded?).collect(), &view.prefix));
uncommitted.differing.sort();
uncommitted.differing.dedup();
Ok(uncommitted)
}
async fn flagged_differences(
root: &Path,
view: &StagedView,
listing: &[u8],
) -> Result<Uncommitted, GitError> {
const LINK: &[u8] = b"120000";
const GITLINK: &[u8] = b"160000";
let mut flagged = Uncommitted::default();
let mut files: Vec<(PathBuf, &[u8])> = Vec::new();
for entry in index_entries(listing).filter(IndexEntry::flagged) {
let named = from_prefix(&path_from_bytes(entry.name.to_vec()), &view.prefix);
if entry.mode == GITLINK {
if submodule_differs(&root.join(&named), entry.blob).await? {
flagged.differing.push(named);
}
continue;
}
match std::fs::symlink_metadata(root.join(&named)) {
Ok(meta) if meta.is_file() && entry.mode != LINK => files.push((named, entry.blob)),
Ok(meta) if meta.file_type().is_symlink() && entry.mode == LINK => {
let oid = String::from_utf8_lossy(entry.blob);
let text = spawn_git_bytes(root, &["cat-file", "blob", &oid], view.env()).await?;
if !std::fs::read_link(root.join(&named))
.is_ok_and(|target| target == path_from_bytes(text))
{
flagged.differing.push(named);
}
}
Ok(_) => flagged.differing.push(named),
Err(_) if entry.skip_worktree() => flagged.index_only.push(named),
Err(_) => flagged.differing.push(named),
}
}
for batch in files.chunks(256) {
let mut args: Vec<std::ffi::OsString> = vec!["hash-object".into(), "--".into()];
args.extend(
batch
.iter()
.map(|(named, _)| root.join(named).into_os_string()),
);
let hashes = spawn_git_bytes(root, &args, view.env()).await?;
let mut hashes = hashes.split(|&byte| byte == b'\n');
for (named, blob) in batch {
if hashes.next() != Some(*blob) {
flagged.differing.push(named.clone());
}
}
}
Ok(flagged)
}
async fn submodule_differs(path: &Path, commit: &[u8]) -> Result<bool, GitError> {
match std::fs::symlink_metadata(path) {
Err(_) => return Ok(false),
Ok(meta) if !meta.is_dir() => return Ok(true),
Ok(_) => {}
}
if !path.join(".git").exists() {
return Ok(false);
}
let head = spawn_git_bytes(path, &["rev-parse", "HEAD"], GitEnv::Scrubbed).await?;
if head.trim_ascii() != commit {
return Ok(true);
}
let changes = spawn_git_bytes(
path,
&[
"--no-optional-locks",
"status",
"--porcelain",
"-z",
"--ignore-submodules=none",
],
GitEnv::Scrubbed,
)
.await?;
Ok(!changes.is_empty())
}
async fn filtered_differences(
root: &Path,
view: &StagedView,
listing: &[u8],
) -> Result<Vec<PathBuf>, GitError> {
let regular: Vec<IndexEntry<'_>> = index_entries(listing)
.filter(|entry| entry.mode == b"100644" || entry.mode == b"100755")
.collect();
if regular.is_empty() {
return Ok(Vec::new());
}
let named: Vec<PathBuf> = regular
.iter()
.map(|entry| from_prefix(&path_from_bytes(entry.name.to_vec()), &view.prefix))
.collect();
let mut input = Vec::new();
for path in &named {
input.extend_from_slice(path.as_os_str().as_encoded_bytes());
input.push(0);
}
let attributes = spawn_git_bytes_with_input(
root,
&["check-attr", "--stdin", "-z", "filter"],
view.env(),
&input,
)
.await?;
let values = attributes.split(|&byte| byte == 0).skip(2).step_by(3);
let mut differing = Vec::new();
for ((entry, path), value) in regular.iter().zip(named).zip(values) {
if matches!(value, b"unspecified" | b"unset" | b"set" | b"lfs") {
continue;
}
let Ok(raw) = std::fs::read(root.join(&path)) else {
continue;
};
let mut at = std::ffi::OsString::from("--path=");
at.push(&path);
let oid = std::ffi::OsString::from(String::from_utf8_lossy(entry.blob).as_ref());
let checkout = spawn_git_bytes(
root,
&[
std::ffi::OsString::from("cat-file"),
"--filters".into(),
at,
oid,
],
view.env(),
)
.await;
if checkout.is_ok_and(|content| content == raw) {
continue;
}
differing.push(path);
}
Ok(differing)
}
struct IndexEntry<'a> {
tag: u8,
mode: &'a [u8],
blob: &'a [u8],
name: &'a [u8],
}
impl IndexEntry<'_> {
fn flagged(&self) -> bool {
self.tag.is_ascii_lowercase() || self.tag == b'S'
}
fn skip_worktree(&self) -> bool {
self.tag.eq_ignore_ascii_case(&b'S')
}
}
fn index_entries(listing: &[u8]) -> impl Iterator<Item = IndexEntry<'_>> {
listing.split(|&byte| byte == 0).filter_map(|record| {
let tab = record.iter().position(|&byte| byte == b'\t')?;
let mut fields = record[..tab].split(|&byte| byte == b' ');
Some(IndexEntry {
tag: *fields.next()?.first()?,
mode: fields.next()?,
blob: fields.next()?,
name: &record[tab + 1..],
})
})
}
fn listed(output: &[u8]) -> impl Iterator<Item = PathBuf> + '_ {
output
.split(|&byte| byte == 0)
.filter(|name| !name.is_empty())
.map(|name| path_from_bytes(name.to_vec()))
}