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 { 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 { 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, Vec)> { 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 { 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> { 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, hash: Kind, shallow: &BTreeSet) -> Result> { 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> { 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> { 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::()?; Ok(IndexEntry { mode, id, stage, path: path.into(), }) }) .collect() } #[derive(Default)] struct Tree { entries: BTreeMap, } enum TreeEntry { Tree(Tree), Leaf { mode: u32, id: ObjectId }, } fn index_tree(path: &Path, entries: &[IndexEntry], hash: Kind) -> Result> { 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 { 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> { 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 { let id = ObjectId::from_hex(input)?; if id.kind() != hash { return Err("object ID uses the wrong hash kind".into()); } Ok(id) } fn git(path: &Path, args: [&str; N]) -> Result> { git_os(path, args.iter().map(OsStr::new)) } fn git_os<'a>(path: &Path, args: impl IntoIterator) -> Result> { 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(path: &Path, args: [&str; N], input: &[u8]) -> Result> { 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(path: &Path, args: [&str; N]) -> Result>> { 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)) }