use std::{ cmp::Ordering, collections::{BTreeMap, BTreeSet}, ffi::OsStr, io::Write, path::{Path, PathBuf}, process::{Command, Stdio},}; use bstr::{BString, ByteSlice};use gix_hash::{Kind, ObjectId}; use super::{Commit, Head, IndexEntry, Reference, ReferenceTarget, State};use crate::Result; pub(super) fn snapshot(path: &Path) -> Result<State> { let hash = object_hash(path)?; let common_dir = common_dir(path)?; let config = std::fs::read(common_dir.join("config"))?.into(); let symbolic_head = git_optional(path, ["symbolic-ref", "-q", "HEAD"])?.map(|out| out.trim().to_owned()); let head_id = git_optional(path, ["rev-parse", "--verify", "HEAD"])? .map(|out| parse_id(out.trim(), hash)) .transpose()?; let head = match (symbolic_head, head_id) { (Some(name), Some(id)) => Head::Symbolic { name: name.into(), id }, (Some(name), None) => Head::Unborn(name.into()), (None, Some(id)) => Head::Detached(id), (None, None) => return Err("HEAD is neither symbolic, detached, nor unborn".into()), }; let (references, mut roots) = references(path, hash)?; roots.extend(head_id); let shallow = shallow_commits(path, hash)?; let commits = commits(path, roots, hash, &shallow)?; let index = index(path, hash)?; let index_tree = index_tree(path, &index, hash)?; let worktree_root = worktree_root(path)?; let worktree = super::worktree(worktree_root.as_deref())?; Ok(State { head, config, references, commits, index, index_tree, worktree, normalization_root: common_dir, show_object_ids: false, })} fn object_hash(path: &Path) -> Result<Kind> { match git(path, ["rev-parse", "--show-object-format"])?.trim() { #[cfg(feature = "sha1")] b"sha1" => Ok(Kind::Sha1), #[cfg(feature = "sha256")] b"sha256" => Ok(Kind::Sha256), value => Err(format!("unsupported object format: {}", value.as_bstr()).into()), }} fn references(path: &Path, hash: Kind) -> Result<(Vec<Reference>, Vec<ObjectId>)> { let output = git( path, [ "for-each-ref", "--format=%(refname)%00%(symref)%00%(objectname)", "refs/", ], )?; let mut out = Vec::new(); let mut roots = Vec::new(); for record in output.lines().filter(|line| !line.is_empty()) { let mut fields = record.split(|byte| *byte == 0); let name = fields.next().ok_or("reference output lacks a name")?; let symbolic = fields.next().ok_or("reference output lacks a symbolic target")?; let id = fields.next().ok_or("reference output lacks an object target")?; let resolved = (!id.is_empty()).then(|| parse_id(id, hash)).transpose()?; roots.extend(resolved); let target = if symbolic.is_empty() { ReferenceTarget::Object(resolved.ok_or("a direct reference lacks an object target")?) } else { ReferenceTarget::Symbolic(symbolic.into()) }; out.push(Reference { name: name.into(), target, }); } out.sort_by(|a, b| a.name.cmp(&b.name)); Ok((out, roots))} fn common_dir(path: &Path) -> Result<PathBuf> { let common_dir = git(path, ["rev-parse", "--git-common-dir"])?; let common_dir = PathBuf::from(String::from_utf8(common_dir.trim().to_owned())?); Ok(if common_dir.is_absolute() { common_dir } else { path.join(common_dir) })} fn shallow_commits(path: &Path, hash: Kind) -> Result<BTreeSet<ObjectId>> { let data = match std::fs::read(common_dir(path)?.join("shallow")) { Ok(data) => data, Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(BTreeSet::new()), Err(err) => return Err(err.into()), }; data.lines().map(|line| parse_id(line, hash)).collect()} fn commits(path: &Path, roots: Vec<ObjectId>, hash: Kind, shallow: &BTreeSet<ObjectId>) -> Result<Vec<Commit>> { let mut pending = Vec::new(); for id in roots { if let Some(id) = peel_to_commit(path, id, hash)? { pending.push(id); } } let mut seen = BTreeSet::new(); let mut commits = Vec::new(); while let Some(id) = pending.pop() { if !seen.insert(id) { continue; } let hex = id.to_string(); let data = git(path, ["cat-file", "commit", &hex])?; if !shallow.contains(&id) { for parent in data .lines() .take_while(|line| !line.is_empty()) .filter_map(|line| line.strip_prefix(b"parent ")) { pending.push(parse_id(parent, hash)?); } } commits.push(Commit { id, data }); } commits.sort_by_key(|commit| commit.id); Ok(commits)} fn peel_to_commit(path: &Path, mut id: ObjectId, hash: Kind) -> Result<Option<ObjectId>> { let mut seen = BTreeSet::new(); loop { if !seen.insert(id) { return Err(format!("tag cycle at {id}").into()); } let hex = id.to_string(); match git(path, ["cat-file", "-t", &hex])?.trim() { b"commit" => return Ok(Some(id)), b"tag" => { let data = git(path, ["cat-file", "tag", &hex])?; let target = data .lines() .next() .and_then(|line| line.strip_prefix(b"object ")) .ok_or("tag object lacks its target")?; id = parse_id(target, hash)?; } _ => return Ok(None), } }} fn index(path: &Path, hash: Kind) -> Result<Vec<IndexEntry>> { let output = git(path, ["ls-files", "--stage", "-z"])?; output .split(|byte| *byte == 0) .filter(|record| !record.is_empty()) .map(|record| { let (metadata, path) = record.split_once_str(b"\t").ok_or("index entry lacks a path")?; let mut fields = metadata.split(|byte| *byte == b' '); let mode = u32::from_str_radix( std::str::from_utf8(fields.next().ok_or("index entry lacks a mode")?)?, 8, )?; let id = parse_id(fields.next().ok_or("index entry lacks an object ID")?, hash)?; let stage = std::str::from_utf8(fields.next().ok_or("index entry lacks a stage")?)?.parse::<u8>()?; Ok(IndexEntry { mode, id, stage, path: path.into(), }) }) .collect()} #[derive(Default)]struct Tree { entries: BTreeMap<BString, TreeEntry>,} enum TreeEntry { Tree(Tree), Leaf { mode: u32, id: ObjectId },} fn index_tree(path: &Path, entries: &[IndexEntry], hash: Kind) -> Result<Option<ObjectId>> { if entries.iter().any(|entry| entry.stage != 0) { return Ok(None); } let mut root = Tree::default(); for entry in entries { insert(&mut root, entry.path.as_ref(), entry.mode, entry.id)?; } Ok(Some(hash_tree(path, &root, hash)?))} fn insert(tree: &mut Tree, path: &[u8], mode: u32, id: ObjectId) -> Result<()> { let mut components = path.split(|byte| *byte == b'/').peekable(); let mut tree = tree; while let Some(component) = components.next() { if components.peek().is_none() { tree.entries.insert(component.into(), TreeEntry::Leaf { mode, id }); return Ok(()); } tree = match tree .entries .entry(component.into()) .or_insert_with(|| TreeEntry::Tree(Tree::default())) { TreeEntry::Tree(tree) => tree, TreeEntry::Leaf { .. } => return Err("an index path traverses a non-tree entry".into()), }; } Err("an index entry has an empty path".into())} fn hash_tree(path: &Path, tree: &Tree, hash: Kind) -> Result<ObjectId> { let mut entries: Vec<_> = tree.entries.iter().collect(); entries.sort_by(|(a, a_entry), (b, b_entry)| tree_name_cmp(a, a_entry, b, b_entry)); let mut data = Vec::new(); for (name, entry) in entries { let (mode, id) = match entry { TreeEntry::Tree(tree) => (0o40000, hash_tree(path, tree, hash)?), TreeEntry::Leaf { mode, id } => (*mode, *id), }; data.extend_from_slice(format!("{mode:o}").as_bytes()); data.push(b' '); data.extend_from_slice(name); data.push(0); data.extend_from_slice(id.as_bytes()); } let id = git_with_input(path, ["hash-object", "-t", "tree", "--stdin"], &data)?; parse_id(id.trim(), hash)} fn tree_name_cmp(a: &[u8], a_entry: &TreeEntry, b: &[u8], b_entry: &TreeEntry) -> Ordering { let mut a = a .iter() .copied() .chain(std::iter::once(if matches!(a_entry, TreeEntry::Tree(_)) { b'/' } else { 0 })); let mut b = b .iter() .copied() .chain(std::iter::once(if matches!(b_entry, TreeEntry::Tree(_)) { b'/' } else { 0 })); loop { match (a.next(), b.next()) { (Some(a), Some(b)) if a == b => {} (Some(a), Some(b)) => return a.cmp(&b), (None, None) => return Ordering::Equal, (None, Some(_)) => return Ordering::Less, (Some(_), None) => return Ordering::Greater, } }} fn worktree_root(path: &Path) -> Result<Option<PathBuf>> { git_optional(path, ["rev-parse", "--show-toplevel"])? .map(|root| String::from_utf8(root.trim().to_owned()).map(PathBuf::from)) .transpose() .map_err(Into::into)} fn parse_id(input: &[u8], hash: Kind) -> Result<ObjectId> { let id = ObjectId::from_hex(input)?; if id.kind() != hash { return Err("object ID uses the wrong hash kind".into()); } Ok(id)} fn git<const N: usize>(path: &Path, args: [&str; N]) -> Result<Vec<u8>> { git_os(path, args.iter().map(OsStr::new))} fn git_os<'a>(path: &Path, args: impl IntoIterator<Item = &'a OsStr>) -> Result<Vec<u8>> { let output = Command::new(gix_path::env::exe_invocation()) .arg("-C") .arg(path) .args(args) .env("GIT_OPTIONAL_LOCKS", "0") .env("GIT_NO_REPLACE_OBJECTS", "1") .output()?; if output.status.success() { return Ok(output.stdout); } Err(format!( "git command failed with {}: {}", output.status, output.stderr.as_bstr().trim().as_bstr() ) .into())} fn git_with_input<const N: usize>(path: &Path, args: [&str; N], input: &[u8]) -> Result<Vec<u8>> { let mut child = Command::new(gix_path::env::exe_invocation()) .arg("-C") .arg(path) .args(args) .env("GIT_OPTIONAL_LOCKS", "0") .env("GIT_NO_REPLACE_OBJECTS", "1") .stdin(Stdio::piped()) .stdout(Stdio::piped()) .stderr(Stdio::piped()) .spawn()?; child.stdin.take().expect("configured as piped").write_all(input)?; let output = child.wait_with_output()?; if output.status.success() { return Ok(output.stdout); } Err(format!( "git command failed with {}: {}", output.status, output.stderr.as_bstr().trim().as_bstr() ) .into())} fn git_optional<const N: usize>(path: &Path, args: [&str; N]) -> Result<Option<Vec<u8>>> { let output = Command::new(gix_path::env::exe_invocation()) .arg("-C") .arg(path) .args(args) .env("GIT_OPTIONAL_LOCKS", "0") .env("GIT_NO_REPLACE_OBJECTS", "1") .output()?; Ok(output.status.success().then_some(output.stdout))}
use std::{
cmp::Ordering,
collections::{BTreeMap, BTreeSet},
ffi::OsStr,
io::Write,
path::{Path, PathBuf},
process::{Command, Stdio},
};
use bstr::{BString, ByteSlice};
use gix_hash::{Kind, ObjectId};
use super::{Commit, Head, IndexEntry, Reference, ReferenceTarget, State};
use crate::Result;
pub(super) fn snapshot(path: &Path) -> Result<State> {
let hash = object_hash(path)?;
let common_dir = common_dir(path)?;
let config = std::fs::read(common_dir.join("config"))?.into();
let symbolic_head = git_optional(path, ["symbolic-ref", "-q", "HEAD"])?.map(|out| out.trim().to_owned());
let head_id = git_optional(path, ["rev-parse", "--verify", "HEAD"])?
.map(|out| parse_id(out.trim(), hash))
.transpose()?;
let head = match (symbolic_head, head_id) {
(Some(name), Some(id)) => Head::Symbolic { name: name.into(), id },
(Some(name), None) => Head::Unborn(name.into()),
(None, Some(id)) => Head::Detached(id),
(None, None) => return Err("HEAD is neither symbolic, detached, nor unborn".into()),
};
let (references, mut roots) = references(path, hash)?;
roots.extend(head_id);
let shallow = shallow_commits(path, hash)?;
let commits = commits(path, roots, hash, &shallow)?;
let index = index(path, hash)?;
let index_tree = index_tree(path, &index, hash)?;
let worktree_root = worktree_root(path)?;
let worktree = super::worktree(worktree_root.as_deref())?;
Ok(State {
head,
config,
references,
commits,
index,
index_tree,
worktree,
normalization_root: common_dir,
show_object_ids: false,
})
}
fn object_hash(path: &Path) -> Result<Kind> {
match git(path, ["rev-parse", "--show-object-format"])?.trim() {
#[cfg(feature = "sha1")]
b"sha1" => Ok(Kind::Sha1),
#[cfg(feature = "sha256")]
b"sha256" => Ok(Kind::Sha256),
value => Err(format!("unsupported object format: {}", value.as_bstr()).into()),
}
}
fn references(path: &Path, hash: Kind) -> Result<(Vec<Reference>, Vec<ObjectId>)> {
let output = git(
path,
[
"for-each-ref",
"--format=%(refname)%00%(symref)%00%(objectname)",
"refs/",
],
)?;
let mut out = Vec::new();
let mut roots = Vec::new();
for record in output.lines().filter(|line| !line.is_empty()) {
let mut fields = record.split(|byte| *byte == 0);
let name = fields.next().ok_or("reference output lacks a name")?;
let symbolic = fields.next().ok_or("reference output lacks a symbolic target")?;
let id = fields.next().ok_or("reference output lacks an object target")?;
let resolved = (!id.is_empty()).then(|| parse_id(id, hash)).transpose()?;
roots.extend(resolved);
let target = if symbolic.is_empty() {
ReferenceTarget::Object(resolved.ok_or("a direct reference lacks an object target")?)
} else {
ReferenceTarget::Symbolic(symbolic.into())
};
out.push(Reference {
name: name.into(),
target,
});
}
out.sort_by(|a, b| a.name.cmp(&b.name));
Ok((out, roots))
}
fn common_dir(path: &Path) -> Result<PathBuf> {
let common_dir = git(path, ["rev-parse", "--git-common-dir"])?;
let common_dir = PathBuf::from(String::from_utf8(common_dir.trim().to_owned())?);
Ok(if common_dir.is_absolute() {
common_dir
} else {
path.join(common_dir)
})
}
fn shallow_commits(path: &Path, hash: Kind) -> Result<BTreeSet<ObjectId>> {
let data = match std::fs::read(common_dir(path)?.join("shallow")) {
Ok(data) => data,
Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(BTreeSet::new()),
Err(err) => return Err(err.into()),
};
data.lines().map(|line| parse_id(line, hash)).collect()
}
fn commits(path: &Path, roots: Vec<ObjectId>, hash: Kind, shallow: &BTreeSet<ObjectId>) -> Result<Vec<Commit>> {
let mut pending = Vec::new();
for id in roots {
if let Some(id) = peel_to_commit(path, id, hash)? {
pending.push(id);
}
}
let mut seen = BTreeSet::new();
let mut commits = Vec::new();
while let Some(id) = pending.pop() {
if !seen.insert(id) {
continue;
}
let hex = id.to_string();
let data = git(path, ["cat-file", "commit", &hex])?;
if !shallow.contains(&id) {
for parent in data
.lines()
.take_while(|line| !line.is_empty())
.filter_map(|line| line.strip_prefix(b"parent "))
{
pending.push(parse_id(parent, hash)?);
}
}
commits.push(Commit { id, data });
}
commits.sort_by_key(|commit| commit.id);
Ok(commits)
}
fn peel_to_commit(path: &Path, mut id: ObjectId, hash: Kind) -> Result<Option<ObjectId>> {
let mut seen = BTreeSet::new();
loop {
if !seen.insert(id) {
return Err(format!("tag cycle at {id}").into());
}
let hex = id.to_string();
match git(path, ["cat-file", "-t", &hex])?.trim() {
b"commit" => return Ok(Some(id)),
b"tag" => {
let data = git(path, ["cat-file", "tag", &hex])?;
let target = data
.lines()
.next()
.and_then(|line| line.strip_prefix(b"object "))
.ok_or("tag object lacks its target")?;
id = parse_id(target, hash)?;
}
_ => return Ok(None),
}
}
}
fn index(path: &Path, hash: Kind) -> Result<Vec<IndexEntry>> {
let output = git(path, ["ls-files", "--stage", "-z"])?;
output
.split(|byte| *byte == 0)
.filter(|record| !record.is_empty())
.map(|record| {
let (metadata, path) = record.split_once_str(b"\t").ok_or("index entry lacks a path")?;
let mut fields = metadata.split(|byte| *byte == b' ');
let mode = u32::from_str_radix(
std::str::from_utf8(fields.next().ok_or("index entry lacks a mode")?)?,
8,
)?;
let id = parse_id(fields.next().ok_or("index entry lacks an object ID")?, hash)?;
let stage = std::str::from_utf8(fields.next().ok_or("index entry lacks a stage")?)?.parse::<u8>()?;
Ok(IndexEntry {
mode,
id,
stage,
path: path.into(),
})
})
.collect()
}
#[derive(Default)]
struct Tree {
entries: BTreeMap<BString, TreeEntry>,
}
enum TreeEntry {
Tree(Tree),
Leaf { mode: u32, id: ObjectId },
}
fn index_tree(path: &Path, entries: &[IndexEntry], hash: Kind) -> Result<Option<ObjectId>> {
if entries.iter().any(|entry| entry.stage != 0) {
return Ok(None);
}
let mut root = Tree::default();
for entry in entries {
insert(&mut root, entry.path.as_ref(), entry.mode, entry.id)?;
}
Ok(Some(hash_tree(path, &root, hash)?))
}
fn insert(tree: &mut Tree, path: &[u8], mode: u32, id: ObjectId) -> Result<()> {
let mut components = path.split(|byte| *byte == b'/').peekable();
let mut tree = tree;
while let Some(component) = components.next() {
if components.peek().is_none() {
tree.entries.insert(component.into(), TreeEntry::Leaf { mode, id });
return Ok(());
}
tree = match tree
.entries
.entry(component.into())
.or_insert_with(|| TreeEntry::Tree(Tree::default()))
{
TreeEntry::Tree(tree) => tree,
TreeEntry::Leaf { .. } => return Err("an index path traverses a non-tree entry".into()),
};
}
Err("an index entry has an empty path".into())
}
fn hash_tree(path: &Path, tree: &Tree, hash: Kind) -> Result<ObjectId> {
let mut entries: Vec<_> = tree.entries.iter().collect();
entries.sort_by(|(a, a_entry), (b, b_entry)| tree_name_cmp(a, a_entry, b, b_entry));
let mut data = Vec::new();
for (name, entry) in entries {
let (mode, id) = match entry {
TreeEntry::Tree(tree) => (0o40000, hash_tree(path, tree, hash)?),
TreeEntry::Leaf { mode, id } => (*mode, *id),
};
data.extend_from_slice(format!("{mode:o}").as_bytes());
data.push(b' ');
data.extend_from_slice(name);
data.push(0);
data.extend_from_slice(id.as_bytes());
}
let id = git_with_input(path, ["hash-object", "-t", "tree", "--stdin"], &data)?;
parse_id(id.trim(), hash)
}
fn tree_name_cmp(a: &[u8], a_entry: &TreeEntry, b: &[u8], b_entry: &TreeEntry) -> Ordering {
let mut a = a
.iter()
.copied()
.chain(std::iter::once(if matches!(a_entry, TreeEntry::Tree(_)) {
b'/'
} else {
0
}));
let mut b = b
.iter()
.copied()
.chain(std::iter::once(if matches!(b_entry, TreeEntry::Tree(_)) {
b'/'
} else {
0
}));
loop {
match (a.next(), b.next()) {
(Some(a), Some(b)) if a == b => {}
(Some(a), Some(b)) => return a.cmp(&b),
(None, None) => return Ordering::Equal,
(None, Some(_)) => return Ordering::Less,
(Some(_), None) => return Ordering::Greater,
}
}
}
fn worktree_root(path: &Path) -> Result<Option<PathBuf>> {
git_optional(path, ["rev-parse", "--show-toplevel"])?
.map(|root| String::from_utf8(root.trim().to_owned()).map(PathBuf::from))
.transpose()
.map_err(Into::into)
}
fn parse_id(input: &[u8], hash: Kind) -> Result<ObjectId> {
let id = ObjectId::from_hex(input)?;
if id.kind() != hash {
return Err("object ID uses the wrong hash kind".into());
}
Ok(id)
}
fn git<const N: usize>(path: &Path, args: [&str; N]) -> Result<Vec<u8>> {
git_os(path, args.iter().map(OsStr::new))
}
fn git_os<'a>(path: &Path, args: impl IntoIterator<Item = &'a OsStr>) -> Result<Vec<u8>> {
let output = Command::new(gix_path::env::exe_invocation())
.arg("-C")
.arg(path)
.args(args)
.env("GIT_OPTIONAL_LOCKS", "0")
.env("GIT_NO_REPLACE_OBJECTS", "1")
.output()?;
if output.status.success() {
return Ok(output.stdout);
}
Err(format!(
"git command failed with {}: {}",
output.status,
output.stderr.as_bstr().trim().as_bstr()
)
.into())
}
fn git_with_input<const N: usize>(path: &Path, args: [&str; N], input: &[u8]) -> Result<Vec<u8>> {
let mut child = Command::new(gix_path::env::exe_invocation())
.arg("-C")
.arg(path)
.args(args)
.env("GIT_OPTIONAL_LOCKS", "0")
.env("GIT_NO_REPLACE_OBJECTS", "1")
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()?;
child.stdin.take().expect("configured as piped").write_all(input)?;
let output = child.wait_with_output()?;
if output.status.success() {
return Ok(output.stdout);
}
Err(format!(
"git command failed with {}: {}",
output.status,
output.stderr.as_bstr().trim().as_bstr()
)
.into())
}
fn git_optional<const N: usize>(path: &Path, args: [&str; N]) -> Result<Option<Vec<u8>>> {
let output = Command::new(gix_path::env::exe_invocation())
.arg("-C")
.arg(path)
.args(args)
.env("GIT_OPTIONAL_LOCKS", "0")
.env("GIT_NO_REPLACE_OBJECTS", "1")
.output()?;
Ok(output.status.success().then_some(output.stdout))
}