Files
sausage/userland/git/src/checkout.rs
T
surma 4b27e9afd5 userland/git: git-compatible CLI on v2's gix code (T10)
Local workflow, smart-HTTP clone/fetch/push and public HTTPS clone/fetch match
native git in stdout and exit codes. Only WASI imports (wasi:http, exit-with-code).
2026-11-06 16:07:57 +00:00

425 lines
15 KiB
Rust

//! Worktree writer shared by checkout and future clone/merge operations.
//! Caller must hold the repository .git lock. No refs are changed here.
use crate::Result;
use crate::repo::error;
use gix::bstr::ByteSlice;
use gix::index::{
State,
entry::{Mode, Stat},
};
use std::{
collections::{BTreeMap, BTreeSet},
fs,
path::Path,
};
type Files = BTreeMap<String, (gix::ObjectId, Mode)>;
fn tree_state(repo: &gix::Repository, tree: Option<gix::ObjectId>) -> Result<State> {
match tree {
Some(id) => State::from_tree(&id, &repo.objects, Default::default())
.map_err(|e| error("checkout tree", e)),
None => Ok(State::new(repo.object_hash())),
}
}
fn files(state: &State) -> Result<Files> {
state
.entries()
.iter()
.map(|e| {
let path = e
.path(state)
.to_str()
.map_err(|e| error("checkout path UTF-8", e))?
.to_owned();
crate::repo::validate_path(&path)?;
if path
.split('/')
.any(|s| s.eq_ignore_ascii_case(".git") || s.contains('\\'))
{
return Err(error("checkout path", path));
}
Ok((path, (e.id, e.mode)))
})
.collect()
}
fn metadata(path: &Path) -> Result<Option<fs::Metadata>> {
match fs::symlink_metadata(path) {
Ok(m) => Ok(Some(m)),
Err(e)
if matches!(
e.kind(),
std::io::ErrorKind::NotFound | std::io::ErrorKind::NotADirectory
) =>
{
Ok(None)
}
Err(e) => Err(error(
"checkout metadata",
format!("{}: {e}", path.display()),
)),
}
}
fn regular(mode: Mode) -> bool {
mode == Mode::FILE || mode == Mode::FILE_EXECUTABLE
}
fn clean(repo: &gix::Repository, path: &Path, expected: (gix::ObjectId, Mode)) -> Result<bool> {
let Some(meta) = metadata(path)? else {
// These entries are intentionally not materialized by this writer.
return Ok(!regular(expected.1));
};
if expected.1 == Mode::SYMLINK && meta.file_type().is_symlink() {
let target = fs::read_link(path).map_err(|e| error("checkout symlink", e))?;
let target = target
.to_str()
.ok_or_else(|| error("checkout symlink", "non-UTF-8 link target"))?;
return Ok(gix::objs::compute_hash(
repo.object_hash(),
gix::objs::Kind::Blob,
target.as_bytes(),
)
.map_err(|e| error("checkout symlink hash", e))?
== expected.0);
}
if !meta.is_file() || !regular(expected.1) {
return Ok(false);
}
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
if (meta.permissions().mode() & 0o111 != 0) != (expected.1 == Mode::FILE_EXECUTABLE) {
return Ok(false);
}
}
let bytes = fs::read(path).map_err(|e| error("checkout read", e))?;
let id = gix::objs::compute_hash(repo.object_hash(), gix::objs::Kind::Blob, &bytes)
.map_err(|e| error("checkout hash", e))?;
Ok(id == expected.0)
}
/// Verify every leaf in a directory is tracked and scheduled for removal. Never remove untracked leaves.
fn removable_dir(root: &Path, dir: &Path, removed: &BTreeSet<String>) -> Result<bool> {
for child in fs::read_dir(dir).map_err(|e| error("checkout directory", e))? {
let child = child.map_err(|e| error("checkout directory", e))?;
let path = child.path();
let meta = fs::symlink_metadata(&path).map_err(|e| error("checkout metadata", e))?;
if meta.is_dir() {
if !removable_dir(root, &path, removed)? {
return Ok(false);
}
} else if !path
.strip_prefix(root)
.ok()
.and_then(Path::to_str)
.is_some_and(|p| removed.contains(p))
{
return Ok(false);
}
}
Ok(true)
}
/// Diff trees, preflight all overwrite risks, write raw blobs, remove tracked paths and empty
/// directories, then atomically publish the index last. On I/O failure HEAD/refs remain unchanged;
/// worktree edits already completed are not rolled back. Filters/attributes are not applied.
/// Unrelated staged and unstaged edits survive a non-force switch. Force resets tracked paths,
/// but never overwrites/deletes an untracked file or follows a symlink directory.
pub(crate) fn write_tree_to_worktree(
repo: &gix::Repository,
old_tree: Option<gix::ObjectId>,
new_tree: gix::ObjectId,
force: bool,
) -> Result<Vec<String>> {
write_tree(repo, old_tree, new_tree, force, false)
}
/// Abort discards tracked changes without refusing dirty files. Paths no longer in the
/// index are untracked, even if HEAD once contained them. Preserve any such obstruction,
/// restoring the HEAD index but leaving a warning/local difference rather than losing bytes.
pub(crate) fn abort_to_tree(repo: &gix::Repository, tree: gix::ObjectId) -> Result<Vec<String>> {
write_tree(repo, None, tree, true, true)
}
fn write_tree(
repo: &gix::Repository,
old_tree: Option<gix::ObjectId>,
new_tree: gix::ObjectId,
force: bool,
preserve_obstructions: bool,
) -> Result<Vec<String>> {
let root = repo
.workdir()
.ok_or_else(|| error("checkout", "bare repository"))?;
let old = files(&tree_state(repo, old_tree)?)?;
let mut state = tree_state(repo, Some(new_tree))?;
let new = files(&state)?;
let index_path = repo.git_dir().join("index");
let existing = if index_path
.try_exists()
.map_err(|e| error("checkout index", e))?
{
Some(
gix::index::File::at(&index_path, repo.object_hash(), false, Default::default())
.map_err(|e| error("checkout index", e))?,
)
} else {
None
};
if existing.as_ref().is_some_and(|i| i.is_sparse()) {
return Err(error(
"checkout index",
"sparse indexes are unsupported; expand the index before checkout",
));
}
let indexed = existing
.as_ref()
.map(|i| files(i))
.transpose()?
.unwrap_or_default();
let mut affected: BTreeSet<String> = old
.keys()
.chain(new.keys())
.filter(|p| force || old.get(*p) != new.get(*p))
.cloned()
.collect();
if force {
affected.extend(indexed.keys().cloned());
} else {
// A staged addition can conflict structurally even when missing from the worktree.
// Include index ancestors/descendants of changed paths, so it cannot silently become a D/F conflict.
let changed = affected.clone();
affected.extend(
indexed
.keys()
.filter(|p| {
let prefix = format!("{p}/");
changed
.range(prefix.clone()..)
.next()
.is_some_and(|q| q.starts_with(&prefix))
|| Path::new(p)
.ancestors()
.skip(1)
.filter_map(Path::to_str)
.any(|q| changed.contains(q))
})
.cloned(),
);
}
let conflicts: BTreeSet<_> = existing
.as_ref()
.map(|i| {
i.entries()
.iter()
.filter(|e| e.stage_raw() != 0)
.map(|e| e.path(i).to_str_lossy().into_owned())
.collect()
})
.unwrap_or_default();
let tracked: BTreeSet<_> = old.keys().chain(indexed.keys()).cloned().collect();
let removed: BTreeSet<_> = affected
.iter()
.filter(|p| !new.get(*p).is_some_and(|e| regular(e.1)))
.cloned()
.collect();
let mut dirty = BTreeSet::new();
let mut blocked = BTreeSet::new();
let mut warnings = Vec::new();
// Validate paths in every source before touching anything, including index entries.
for path in &affected {
let dest = root.join(path);
// A symlink/file parent may be replaced only if tracked and scheduled for deletion.
let mut parent = Path::new(path).parent();
while let Some(p) = parent.filter(|p| !p.as_os_str().is_empty()) {
if let Some(m) = metadata(&root.join(p))? {
if m.file_type().is_symlink()
|| (!m.is_dir()
&& !removed.contains(p.to_str().unwrap_or(""))
&& new.get(path).is_some_and(|e| regular(e.1)))
{
blocked.insert(path.clone());
}
}
parent = p.parent();
}
// Inspect the actual worktree: a removed tracked ancestor may have been replaced
// by a directory containing untracked files. A real file parent yields None via
// metadata's NotADirectory handling, without hiding existing descendants.
let meta = metadata(&dest)?;
if let Some(m) = &meta {
if m.is_dir() && new.get(path).is_some_and(|e| regular(e.1)) {
if !removable_dir(root, &dest, &removed)? {
blocked.insert(path.clone());
}
} else if !m.is_dir() && !tracked.contains(path) {
blocked.insert(path.clone());
}
}
if !force {
if indexed.get(path) != old.get(path) && existing.is_some() {
dirty.insert(path.clone());
}
if conflicts.contains(path) {
dirty.insert(path.clone());
}
if let Some(expected) = old.get(path) {
if !clean(repo, &dest, *expected)? {
dirty.insert(path.clone());
}
}
}
}
if !blocked.is_empty() {
let paths = blocked.iter().cloned().collect::<Vec<_>>().join(", ");
if preserve_obstructions {
warnings.push(format!("abort preserved untracked/obstructing paths; HEAD index restored but worktree differs: {paths}"));
} else {
return Err(error(
"checkout untracked/obstructing paths (also protected with force)",
paths,
));
}
}
if !dirty.is_empty() {
return Err(error(
"checkout would overwrite uncommitted changes",
dirty.into_iter().collect::<Vec<_>>().join(", "),
));
}
// Read every required blob before beginning mutations (missing/corrupt objects must not partially checkout).
let mut blobs = BTreeMap::new();
for path in &affected {
if blocked.contains(path) {
continue;
}
if let Some((id, mode)) = new.get(path) {
if regular(*mode) {
blobs.insert(
path.clone(),
repo.find_blob(*id)
.map_err(|e| error("checkout blob", e))?
.data
.to_vec(),
);
} else {
warnings.push(format!(
"{path}: symlink/submodule unsupported; skipped worktree materialization"
));
}
}
}
// Remove old tracked leaves first, deepest first; never recursively delete a directory.
let mut deletions: Vec<_> = affected
.iter()
.filter(|p| tracked.contains(*p) && !blocked.contains(*p))
.collect();
deletions.sort_by_key(|p| std::cmp::Reverse(p.len()));
for path in deletions {
let dest = root.join(path);
// Ancestors may have been removed already (only affects structurally conflicting staged entries).
match fs::symlink_metadata(&dest) {
Ok(m) if !m.is_dir() => {
fs::remove_file(&dest).map_err(|e| error("checkout remove", e))?
}
Ok(_) => {}
Err(e)
if matches!(
e.kind(),
std::io::ErrorKind::NotFound | std::io::ErrorKind::NotADirectory
) => {}
Err(e) => return Err(error("checkout remove", e)),
}
prune_empty(root, dest.parent());
}
for (path, bytes) in blobs {
let dest = root.join(&path);
if dest.is_dir() {
remove_empty_dirs(&dest)?;
}
if let Some(parent) = dest.parent() {
fs::create_dir_all(parent).map_err(|e| error("checkout mkdir", e))?;
}
fs::write(&dest, bytes).map_err(|e| error("checkout write", e))?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mode = if new[&path].1 == Mode::FILE_EXECUTABLE {
0o755
} else {
0o644
};
fs::set_permissions(&dest, fs::Permissions::from_mode(mode))
.map_err(|e| error("checkout chmod", e))?;
}
}
#[cfg(not(unix))]
if affected
.iter()
.any(|p| new.get(p).is_some_and(|e| e.1 == Mode::FILE_EXECUTABLE))
{
warnings.push(
"executable bits preserved in index; filesystem chmod unavailable on this target"
.into(),
);
}
// Preserve unrelated staging, including staged additions, deletions, and conflict stages.
if let Some(index) = &existing {
state.remove_entries(|_, path, _| !affected.contains(path.to_str().unwrap_or("")));
for entry in index.entries() {
if !affected.contains(entry.path(index).to_str().unwrap_or("")) {
state.dangerously_push_entry(
entry.stat,
entry.id,
entry.flags,
entry.mode,
entry.path(index),
);
}
}
state.sort_entries();
state.remove_tree(); // Unrelated staging may differ from the target tree.
}
for (entry, path) in state.entries_mut_with_paths() {
if affected.contains(path.to_str().unwrap_or(""))
&& !blocked.contains(path.to_str().unwrap_or(""))
&& regular(entry.mode)
{
let stat = gix::index::fs::Metadata::from_path_no_follow(
&root.join(path.to_str().map_err(|e| error("checkout path", e))?),
)
.map_err(|e| error("checkout stat", e))?;
entry.stat = Stat::from_fs(&stat).map_err(|e| error("checkout stat", e))?;
}
}
gix::index::File::from_state(state, index_path)
.write(Default::default())
.map_err(|e| error("checkout index write", e))?;
Ok(warnings)
}
fn prune_empty(root: &Path, mut parent: Option<&Path>) {
while let Some(p) = parent.filter(|p| *p != root && p.starts_with(root)) {
if fs::remove_dir(p).is_err() {
break;
}
parent = p.parent();
}
}
fn remove_empty_dirs(path: &Path) -> Result<()> {
for child in fs::read_dir(path).map_err(|e| error("checkout directory", e))? {
let child = child.map_err(|e| error("checkout directory", e))?;
if child
.file_type()
.map_err(|e| error("checkout directory", e))?
.is_dir()
{
remove_empty_dirs(&child.path())?;
}
}
fs::remove_dir(path).map_err(|e| error("checkout directory not empty", e))
}