use std::collections::HashSet;
use std::fs;
use std::path::Path;
use crate::error::{Error, Result};
use crate::objects::ObjectId;
use crate::odb::Odb;
use crate::pack;
fn ensure_linkable_regular_file(src: &Path) -> Result<()> {
let meta = fs::symlink_metadata(src).map_err(Error::Io)?;
if meta.file_type().is_symlink() {
return Err(Error::PathError(format!(
"refusing to link symlink object store entry {}",
src.display()
)));
}
if !meta.is_file() {
return Err(Error::PathError(format!(
"refusing to link non-regular object store entry {}",
src.display()
)));
}
Ok(())
}
fn hard_link_or_copy(src: &Path, dst: &Path) -> Result<()> {
if dst.exists() {
return Ok(());
}
ensure_linkable_regular_file(src)?;
if let Some(parent) = dst.parent() {
fs::create_dir_all(parent).map_err(Error::Io)?;
}
let _ = fs::remove_file(dst);
if fs::hard_link(src, dst).is_ok() {
return Ok(());
}
fs::copy(src, dst).map_err(Error::Io)?;
Ok(())
}
fn pack_entry_name_ok(name: &str) -> bool {
if name.starts_with("tmp_") || name.starts_with(".") {
return false;
}
name.ends_with(".pack")
|| name.ends_with(".idx")
|| name.ends_with(".rev")
|| name.ends_with(".bitmap")
|| name.ends_with(".promisor")
|| name.ends_with(".keep")
|| name.ends_with(".mtimes")
}
pub(crate) fn link_remote_pack_files(remote_objects: &Path, local_objects: &Path) -> Result<usize> {
let remote_pack = remote_objects.join("pack");
if !remote_pack.is_dir() {
return Ok(0);
}
let local_pack = local_objects.join("pack");
fs::create_dir_all(&local_pack).map_err(Error::Io)?;
let mut linked = 0usize;
for entry in fs::read_dir(&remote_pack).map_err(Error::Io)? {
let entry = entry.map_err(Error::Io)?;
let name = entry.file_name();
let name_str = name.to_string_lossy();
if !pack_entry_name_ok(&name_str) {
continue;
}
let dst = local_pack.join(&name);
if dst.exists() {
continue;
}
hard_link_or_copy(&entry.path(), &dst)?;
linked += 1;
}
Ok(linked)
}
fn link_loose_objects(remote_objects: &Path, local_objects: &Path) -> Result<usize> {
let mut copied = 0usize;
for entry in fs::read_dir(remote_objects).map_err(Error::Io)? {
let entry = entry.map_err(Error::Io)?;
let name = entry.file_name();
let name_str = name.to_string_lossy();
if name_str.len() != 2 || !name_str.chars().all(|c| c.is_ascii_hexdigit()) {
continue;
}
let local_shard = local_objects.join(&name);
fs::create_dir_all(&local_shard).map_err(Error::Io)?;
let Ok(shard_entries) = fs::read_dir(entry.path()) else {
continue;
};
for obj in shard_entries.flatten() {
let dst = local_shard.join(obj.file_name());
if dst.exists() {
continue;
}
hard_link_or_copy(&obj.path(), &dst)?;
copied += 1;
}
}
Ok(copied)
}
pub(crate) fn refresh_local_odb_after_link(local_odb: &Odb) {
local_odb.invalidate_packs();
pack::clear_pack_cache();
}
pub(crate) fn source_objects_fully_packed(objects_dir: &Path) -> Result<bool> {
if !pack::read_alternates_recursive(objects_dir)?.is_empty() {
return Ok(false);
}
for entry in fs::read_dir(objects_dir).map_err(Error::Io)? {
let entry = entry.map_err(Error::Io)?;
let name = entry.file_name();
let name_str = name.to_string_lossy();
if name_str == "info" || name_str == "pack" {
continue;
}
if name_str.len() != 2 || !name_str.chars().all(|c| c.is_ascii_hexdigit()) {
continue;
}
let Ok(mut shard) = fs::read_dir(entry.path()) else {
continue;
};
if shard.next().is_some() {
return Ok(false);
}
}
Ok(true)
}
pub(crate) fn try_satisfy_via_object_link(
remote_objects: &Path,
local_objects: &Path,
local_odb: &Odb,
needed: &[ObjectId],
link_loose: bool,
) -> Result<bool> {
if needed.is_empty() {
return Ok(true);
}
let linked_packs = link_remote_pack_files(remote_objects, local_objects)?;
let linked_loose = if link_loose {
link_loose_objects(remote_objects, local_objects)?
} else {
0
};
if linked_packs == 0 && linked_loose == 0 {
return Ok(false);
}
refresh_local_odb_after_link(local_odb);
if linked_packs > 0 && needed.iter().all(|oid| local_odb.exists(oid)) {
return Ok(true);
}
if linked_loose > 0 && needed.iter().all(|oid| local_odb.exists(oid)) {
return Ok(true);
}
Ok(false)
}
pub(crate) fn link_loose_objects_for_oids(
source_objects: &Path,
dest_objects: &Path,
oids: &[ObjectId],
) -> Result<usize> {
let mut linked = 0usize;
for oid in oids {
let hex = oid.to_hex();
if hex.len() < 2 {
continue;
}
let (shard, rest) = hex.split_at(2);
let src = source_objects.join(shard).join(rest);
if !src.is_file() {
continue;
}
let dst_shard = dest_objects.join(shard);
fs::create_dir_all(&dst_shard).map_err(Error::Io)?;
let dst = dst_shard.join(rest);
if dst.exists() {
continue;
}
hard_link_or_copy(&src, &dst)?;
linked += 1;
}
Ok(linked)
}
pub(crate) fn linked_object_set(local_odb: &Odb, needed: &[ObjectId]) -> HashSet<ObjectId> {
needed
.iter()
.copied()
.filter(|oid| local_odb.exists(oid))
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
use std::process::Command;
fn git(dir: &Path, args: &[&str]) {
let out = Command::new("git")
.env("GIT_AUTHOR_NAME", "Test")
.env("GIT_AUTHOR_EMAIL", "test@example.com")
.env("GIT_COMMITTER_NAME", "Test")
.env("GIT_COMMITTER_EMAIL", "test@example.com")
.current_dir(dir)
.args(args)
.env("GIT_CONFIG_GLOBAL", "/dev/null")
.env("GIT_CONFIG_SYSTEM", "/dev/null")
.output()
.expect("git");
assert!(
out.status.success(),
"git: {}",
String::from_utf8_lossy(&out.stderr)
);
}
#[test]
fn source_objects_fully_packed_repacked_repo() {
let tmp = tempfile::tempdir().expect("tempdir");
let src = tmp.path().join("src");
fs::create_dir_all(&src).unwrap();
git(&src, &["init", "-q", "-b", "main"]);
fs::write(src.join("f"), b"payload\n").unwrap();
git(&src, &["add", "f"]);
git(&src, &["commit", "-qm", "c"]);
git(&src, &["repack", "-a", "-d", "-f"]);
let objects = src.join(".git/objects");
assert!(source_objects_fully_packed(&objects).expect("scan"));
}
#[test]
fn source_objects_fully_packed_false_when_loose_blob_remains() {
let tmp = tempfile::tempdir().expect("tempdir");
let src = tmp.path().join("src");
fs::create_dir_all(&src).unwrap();
git(&src, &["init", "-q", "-b", "main"]);
fs::write(src.join("a"), b"one\n").unwrap();
git(&src, &["add", "a"]);
git(&src, &["commit", "-qm", "one"]);
let head = Command::new("git")
.current_dir(&src)
.args(["rev-parse", "HEAD"])
.output()
.unwrap();
let tree = Command::new("git")
.current_dir(&src)
.args(["rev-parse", "HEAD^{tree}"])
.output()
.unwrap();
let head_hex = String::from_utf8_lossy(&head.stdout);
let tree_hex = String::from_utf8_lossy(&tree.stdout);
let input = format!("{}\n{}\n", head_hex.trim(), tree_hex.trim());
let mut pack = Command::new("git")
.current_dir(&src)
.args(["pack-objects", "-q", ".git/objects/pack/pack"])
.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::piped())
.env("GIT_CONFIG_GLOBAL", "/dev/null")
.env("GIT_CONFIG_SYSTEM", "/dev/null")
.spawn()
.expect("pack-objects");
if let Some(mut stdin) = pack.stdin.take() {
use std::io::Write;
stdin.write_all(input.as_bytes()).unwrap();
}
assert!(pack.wait_with_output().unwrap().status.success());
git(&src, &["prune-packed"]);
let objects = src.join(".git/objects");
assert!(
!source_objects_fully_packed(&objects).expect("scan"),
"mixed pack+loose must not qualify for tip-only fast path"
);
}
#[test]
fn link_remote_pack_files_hardlinks_repacked_repo() {
let tmp = tempfile::tempdir().expect("tempdir");
let src = tmp.path().join("src");
fs::create_dir_all(&src).unwrap();
git(&src, &["init", "-q", "-b", "main"]);
fs::write(src.join("f"), b"payload\n").unwrap();
git(&src, &["add", "f"]);
git(&src, &["commit", "-qm", "c"]);
git(&src, &["repack", "-a", "-d", "-f"]);
let dst_objects = tmp.path().join("dst/objects");
fs::create_dir_all(&dst_objects).unwrap();
let linked = link_remote_pack_files(&src.join(".git/objects"), &dst_objects).expect("link");
assert!(
linked >= 2,
"expected at least .pack and .idx, got {linked}"
);
let local_odb = Odb::new(&dst_objects);
refresh_local_odb_after_link(&local_odb);
let rev = Command::new("git")
.current_dir(&src)
.args(["rev-parse", "HEAD"])
.output()
.unwrap();
let tip = ObjectId::from_hex(String::from_utf8_lossy(&rev.stdout).trim()).unwrap();
assert!(local_odb.exists(&tip));
}
#[test]
fn hard_link_rejects_symlink_in_pack_dir() {
let tmp = tempfile::tempdir().expect("tempdir");
let src_pack = tmp.path().join("src/pack");
fs::create_dir_all(&src_pack).unwrap();
let evil = src_pack.join("evil.keep");
#[cfg(unix)]
{
use std::os::unix::fs::symlink;
symlink("elsewhere", &evil).unwrap();
}
#[cfg(not(unix))]
{
return;
}
let dst_pack = tmp.path().join("dst/pack");
fs::create_dir_all(&dst_pack).unwrap();
let dst = dst_pack.join("evil.keep");
let err = hard_link_or_copy(&evil, &dst).unwrap_err();
assert!(
matches!(err, Error::PathError(_)),
"expected PathError, got {err:?}"
);
}
}