use std::collections::HashSet;
use std::path::{Path, PathBuf};
use tracing::debug;
use crate::error::Result;
use crate::git::cli::GitCli;
use crate::git::submodule;
use crate::util::reflink;
#[derive(Debug, Clone)]
pub(crate) struct ReflinkPlan {
pub(crate) source: PathBuf,
}
pub(crate) fn plan(source: Option<&Path>, target_parent: &Path) -> Option<ReflinkPlan> {
let source = source?;
if !source.is_dir() {
return None;
}
if !reflink::is_supported(target_parent) {
debug!("no reflink support at the target; using a normal checkout");
return None;
}
Some(ReflinkPlan {
source: source.to_path_buf(),
})
}
fn tree_of(git: &dyn GitCli, dir: &Path, rev: &str) -> Option<String> {
let spec = format!("{rev}^{{tree}}");
git.run(dir, &["rev-parse", &spec])
.ok()
.map(|s| s.trim().to_string())
}
pub(crate) fn apply(git: &dyn GitCli, target: &Path, plan: &ReflinkPlan) -> Result<bool> {
git.run(target, &["read-tree", "HEAD"])?;
if !trees_match(git, target, &plan.source) {
debug!(
source = %plan.source.display(),
"source worktree is at a different tree; checking out normally"
);
checkout_everything(git, target)?;
return Ok(false);
}
if let Err(e) = clone_content(git, target, plan) {
debug!(error = %e, "reflink materialization failed; checking out normally");
checkout_everything(git, target)?;
return Ok(false);
}
Ok(true)
}
fn trees_match(git: &dyn GitCli, target: &Path, source: &Path) -> bool {
match (tree_of(git, target, "HEAD"), tree_of(git, source, "HEAD")) {
(Some(a), Some(b)) => a == b,
_ => false,
}
}
fn clone_content(git: &dyn GitCli, target: &Path, plan: &ReflinkPlan) -> Result<()> {
let mut skip = HashSet::from([PathBuf::from(".git")]);
skip.extend(untracked(git, &plan.source)?);
reflink::clone_tree(&plan.source, target, &skip)?;
if let Err(e) = git.run(target, &["update-index", "--refresh"]) {
debug!(error = %e, "index refresh found differences to correct");
}
git.run(target, &["checkout", "--", "."])?;
Ok(())
}
fn untracked(git: &dyn GitCli, source: &Path) -> Result<Vec<PathBuf>> {
let out = git.run(
source,
&["status", "--porcelain=v1", "-z", "--untracked-files=normal"],
)?;
Ok(out
.split('\0')
.filter_map(|entry| entry.strip_prefix("?? "))
.map(|path| PathBuf::from(path.strip_suffix('/').unwrap_or(path)))
.collect())
}
fn checkout_everything(git: &dyn GitCli, target: &Path) -> Result<()> {
git.run(target, &["checkout-index", "-a", "-f"])?;
Ok(())
}
pub(crate) fn attach_submodules(git: &dyn GitCli, target: &Path, common_dir: &Path) -> Vec<String> {
let Some(private_modules) = private_modules_dir(git, target) else {
return Vec::new();
};
let mut attached = Vec::new();
let mirror_root = common_dir.join("modules");
walk(
git,
target,
&mirror_root,
&mirror_root,
&private_modules,
"",
&mut attached,
);
attached
}
fn walk(
git: &dyn GitCli,
dir: &Path,
mirror_prefix: &Path,
mirror_root: &Path,
private_modules: &Path,
rel_prefix: &str,
attached: &mut Vec<String>,
) {
let Ok(declared) = submodule::declared(git, dir) else {
return;
};
for sub in declared {
let rel = if rel_prefix.is_empty() {
sub.path.clone()
} else {
format!("{rel_prefix}/{}", sub.path)
};
let work = dir.join(&sub.path);
if !work.is_dir() || is_empty_dir(&work) {
continue;
}
match gitlink_state(&work) {
GitlinkState::Valid => continue,
GitlinkState::Stale => {
if let Err(e) = std::fs::remove_file(work.join(".git")) {
debug!(submodule = %rel, error = %e, "could not clear the stale gitlink");
continue;
}
}
GitlinkState::Absent => {}
}
let Some(mirror) = submodule::seed::mirror_within(mirror_prefix, &sub.name, mirror_root)
else {
debug!(submodule = %rel, "no usable local mirror; leaving it to the reconcile pass");
continue;
};
let Ok(sha) = git.run(dir, &["rev-parse", &format!(":{}", sub.path)]) else {
continue;
};
match submodule::reattach::attach(
git,
dir,
&sub.path,
&sub.name,
&mirror,
private_modules,
sha.trim(),
) {
Ok(()) => {
debug!(submodule = %rel, "attached copied submodule");
attached.push(rel.clone());
walk(
git,
&work,
&mirror.join("modules"),
mirror_root,
&private_modules.join(&sub.name).join("modules"),
&rel,
attached,
);
}
Err(e) => debug!(submodule = %rel, error = %e, "could not attach; leaving it"),
}
}
}
#[derive(Debug, PartialEq, Eq)]
enum GitlinkState {
Absent,
Valid,
Stale,
}
fn gitlink_state(work: &Path) -> GitlinkState {
let dot_git = work.join(".git");
if dot_git.is_dir() {
return GitlinkState::Valid;
}
let Ok(text) = std::fs::read_to_string(&dot_git) else {
return GitlinkState::Absent;
};
let Some(target) = text.trim().strip_prefix("gitdir:") else {
return GitlinkState::Valid;
};
let target = Path::new(target.trim());
let resolved = if target.is_absolute() {
target.to_path_buf()
} else {
work.join(target)
};
if resolved.is_dir() {
GitlinkState::Valid
} else {
GitlinkState::Stale
}
}
fn is_empty_dir(dir: &Path) -> bool {
std::fs::read_dir(dir)
.map(|mut d| d.next().is_none())
.unwrap_or(true)
}
fn private_modules_dir(git: &dyn GitCli, worktree_dir: &Path) -> Option<PathBuf> {
let out = git
.run(
worktree_dir,
&["rev-parse", "--path-format=absolute", "--git-dir"],
)
.ok()?;
Some(PathBuf::from(out.trim()).join("modules"))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::git::cli::RealGit;
use crate::testutil::TestRepo;
fn cow(repo: &TestRepo) -> bool {
reflink::is_supported(repo.root())
}
#[test]
fn plan_declines_without_a_source() {
let repo = TestRepo::init();
assert!(plan(None, repo.root()).is_none());
}
#[test]
fn plan_declines_for_a_source_that_is_not_a_directory() {
let repo = TestRepo::init();
assert!(plan(Some(&repo.root().join("README.md")), repo.root()).is_none());
}
#[test]
fn plan_accepts_a_real_source_on_a_cow_filesystem() {
let repo = TestRepo::init();
if !cow(&repo) {
return;
}
assert!(plan(Some(repo.root()), repo.root()).is_some());
}
#[test]
fn trees_match_compares_the_two_worktrees_not_a_resolved_base() {
let repo = TestRepo::init();
repo.add_worktree("topic", "../wt-same");
let same = repo.root().parent().unwrap().join("wt-same");
assert!(trees_match(&RealGit, &same, repo.root()));
repo.write("only-here.txt", "content\n");
repo.commit_all("move the source ahead");
assert!(!trees_match(&RealGit, &same, repo.root()));
}
#[test]
fn trees_match_is_false_when_either_side_is_unreadable() {
let repo = TestRepo::init();
let missing = repo.root().parent().unwrap().join("not-a-repo");
assert!(!trees_match(&RealGit, &missing, repo.root()));
assert!(!trees_match(&RealGit, repo.root(), &missing));
}
#[test]
fn gitlink_state_distinguishes_stale_from_valid() {
let dir = tempfile::tempdir().unwrap();
let work = dir.path().join("sub");
std::fs::create_dir_all(&work).unwrap();
assert_eq!(gitlink_state(&work), GitlinkState::Absent);
std::fs::write(work.join(".git"), "gitdir: ../../.git/modules/sub\n").unwrap();
assert_eq!(gitlink_state(&work), GitlinkState::Stale);
std::fs::create_dir_all(dir.path().join(".git/modules/sub")).unwrap();
std::fs::write(
work.join(".git"),
format!(
"gitdir: {}\n",
dir.path().join(".git/modules/sub").display()
),
)
.unwrap();
assert_eq!(gitlink_state(&work), GitlinkState::Valid);
std::fs::remove_file(work.join(".git")).unwrap();
std::fs::create_dir_all(work.join(".git")).unwrap();
assert_eq!(gitlink_state(&work), GitlinkState::Valid);
}
#[test]
fn attach_refuses_a_mirror_name_that_escapes_the_modules_directory() {
let repo = TestRepo::init();
repo.add_submodule("libs/sub");
let outside = repo.root().parent().unwrap().join("outside");
std::fs::create_dir_all(&outside).unwrap();
RealGit
.run(&outside, &["init", "-q", "-b", "main"])
.expect("init the repository the name points at");
repo.write(
".gitmodules",
"[submodule \"../../../outside\"]\n\tpath = libs/sub\n\turl = ../x\n",
);
repo.commit_all("point the submodule name out of the repository");
repo.add_worktree("topic", "../wt-escape");
let wt = repo.root().parent().unwrap().join("wt-escape");
let work = wt.join("libs/sub");
std::fs::create_dir_all(&work).unwrap();
std::fs::copy(repo.root().join("libs/sub/sub.txt"), work.join("sub.txt")).unwrap();
let attached = attach_submodules(&RealGit, &wt, &repo.root().join(".git"));
assert!(
attached.is_empty(),
"attached a submodule through an escaping mirror name: {attached:?}"
);
assert!(
!repo.root().join(".git/outside").exists(),
"created a git directory outside the worktree's private modules tree"
);
}
#[test]
fn is_empty_dir_reports_missing_and_empty_alike() {
let dir = tempfile::tempdir().unwrap();
assert!(is_empty_dir(dir.path()));
assert!(is_empty_dir(&dir.path().join("absent")));
std::fs::write(dir.path().join("f"), "x").unwrap();
assert!(!is_empty_dir(dir.path()));
}
}