mod branch;
#[cfg(any(target_os = "linux", target_os = "macos"))]
mod ref_namespace;
pub(crate) use branch::create_worktree_branch;
use std::collections::HashMap;
use std::ffi::OsStr;
use std::io;
use std::path::{Path, PathBuf};
use std::process::Command;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex, OnceLock};
#[derive(Debug, Default, Clone, PartialEq, Eq)]
pub struct OwnGitGrant {
pub read: Vec<String>,
pub write: Vec<String>,
}
pub fn own_gitdir_grants(workspace: &Path) -> OwnGitGrant {
let resolved = git_dirs(workspace);
prime_identity_cache(workspace, resolved.clone());
let Some((common_dir, absolute_git_dir)) = resolved else {
return OwnGitGrant::default();
};
let read = vec![
path_to_string(&absolute_git_dir),
path_to_string(&common_dir),
];
let read_only = OwnGitGrant {
read: read.clone(),
write: Vec::new(),
};
let Some(branch) = own_branch(workspace) else {
return read_only; };
if is_default_branch(workspace, &branch) {
return read_only;
}
let write = vec![
path_to_string(&absolute_git_dir),
path_to_string(&common_dir.join("objects")),
];
OwnGitGrant { read, write }
}
type GitDirPair = (PathBuf, PathBuf);
#[derive(Clone)]
struct CachedGitIdentity {
paths: GitDirPair,
#[cfg(any(target_os = "linux", target_os = "macos"))]
held: Arc<BoundGitIdentity>,
}
impl CachedGitIdentity {
fn acquire(paths: GitDirPair) -> Option<Self> {
#[cfg(any(target_os = "linux", target_os = "macos"))]
let held = Arc::new(
BoundGitIdentity::bind(paths.0.canonicalize().ok()?, paths.1.canonicalize().ok()?)
.ok()?,
);
Some(Self {
paths,
#[cfg(any(target_os = "linux", target_os = "macos"))]
held,
})
}
}
fn identity_cache() -> &'static Mutex<HashMap<PathBuf, Option<CachedGitIdentity>>> {
static CACHE: OnceLock<Mutex<HashMap<PathBuf, Option<CachedGitIdentity>>>> = OnceLock::new();
CACHE.get_or_init(|| Mutex::new(HashMap::new()))
}
fn prime_identity_cache(workspace: &Path, resolved: Option<GitDirPair>) {
identity_cache()
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.insert(
workspace.to_path_buf(),
resolved.and_then(CachedGitIdentity::acquire),
);
}
fn cached_identity(workspace: &Path) -> Option<CachedGitIdentity> {
identity_cache()
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.get(workspace)
.cloned()
.flatten()
}
pub(crate) fn ambient_gitdir_write_grant(workspace: &Path) -> Vec<String> {
let resolved = git_dirs(workspace);
identity_cache()
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.entry(workspace.to_path_buf())
.or_insert_with(|| resolved.and_then(CachedGitIdentity::acquire));
own_gitdir_shell_write_grant(workspace)
}
pub fn own_gitdir_shell_write_grant(workspace: &Path) -> Vec<String> {
let Some((common_dir, git_dir)) = verified_identity(workspace) else {
return Vec::new();
};
let Some(branch) = own_branch(workspace) else {
return Vec::new();
};
if is_default_branch(workspace, &branch) {
return Vec::new();
}
vec![
path_to_string(&git_dir),
path_to_string(&common_dir.join("objects")),
]
}
pub(crate) fn build_git_reads(workspace: &Path) -> (Vec<String>, Vec<agent_bridle::HeldReadRoot>) {
#[cfg(any(target_os = "linux", target_os = "macos"))]
{
let checked = || -> Option<_> {
verified_identity(workspace)?;
let identity = cached_identity(workspace)?;
identity.held.verify().ok()?;
let mut paths = Vec::new();
#[allow(unused_mut)]
let mut handles = Vec::new();
for (path, root) in [
(&identity.held.common_dir, &identity.held.common_root),
(&identity.held.git_dir, &identity.held.git_root),
] {
let path = path_to_string(path);
let held = agent_bridle::HeldReadRoot::bind(
path.clone(),
root.as_fd().try_clone_to_owned().ok()?,
)
.ok()?;
paths.push(path);
#[cfg(target_os = "linux")]
handles.push(held);
#[cfg(target_os = "macos")]
let _ = held;
}
Some((paths, handles))
};
checked().unwrap_or_default()
}
#[cfg(not(any(target_os = "linux", target_os = "macos")))]
{
let _ = workspace;
(Vec::new(), Vec::new())
}
}
fn verified_identity(workspace: &Path) -> Option<(PathBuf, PathBuf)> {
let cached = cached_identity(workspace)?;
#[cfg(any(target_os = "linux", target_os = "macos"))]
cached.held.verify().ok()?;
let (cached_common, cached_git_dir) = cached.paths;
let (fresh_common, fresh_git_dir) = git_dirs(workspace)?;
if fresh_common != cached_common || fresh_git_dir != cached_git_dir {
return None; }
Some((cached_common, cached_git_dir))
}
fn path_to_string(path: &Path) -> String {
path.to_string_lossy().into_owned()
}
pub struct BoundGitIdentity {
#[cfg(any(target_os = "linux", target_os = "macos"))]
common_dir: PathBuf,
git_dir: PathBuf,
#[cfg(any(target_os = "linux", target_os = "macos"))]
common_root: agent_bridle_fdguard::GrantedRoot,
#[cfg(any(target_os = "linux", target_os = "macos"))]
git_root: agent_bridle_fdguard::GrantedRoot,
#[cfg(any(target_os = "linux", target_os = "macos"))]
common_workspace: crate::fs_cap::WorkspaceDir,
#[cfg(any(target_os = "linux", target_os = "macos"))]
git_workspace: crate::fs_cap::WorkspaceDir,
}
impl BoundGitIdentity {
#[cfg(any(target_os = "linux", target_os = "macos"))]
fn bind(common_dir: PathBuf, git_dir: PathBuf) -> Result<Self, String> {
Self::bind_seamed(common_dir, git_dir, &|| {})
}
#[cfg(any(target_os = "linux", target_os = "macos"))]
fn bind_seamed(
common_dir: PathBuf,
git_dir: PathBuf,
between_acquire_and_derive: &dyn Fn(),
) -> Result<Self, String> {
let common_root = agent_bridle_fdguard::GrantedRoot::acquire(&common_dir).map_err(|e| {
format!(
"refused: cannot bind the common git dir '{}' ({e})",
common_dir.display()
)
})?;
let git_root = agent_bridle_fdguard::GrantedRoot::acquire(&git_dir).map_err(|e| {
format!(
"refused: cannot bind the worktree admin dir '{}' ({e})",
git_dir.display()
)
})?;
between_acquire_and_derive();
let common_workspace = crate::fs_cap::WorkspaceDir::from_granted_root(&common_root)
.map_err(|e| {
format!(
"refused: cannot bind the common git dir '{}' ({e})",
common_dir.display()
)
})?;
let git_workspace =
crate::fs_cap::WorkspaceDir::from_granted_root(&git_root).map_err(|e| {
format!(
"refused: cannot bind the worktree admin dir '{}' ({e})",
git_dir.display()
)
})?;
Ok(Self {
common_dir,
git_dir,
common_root,
git_root,
common_workspace,
git_workspace,
})
}
pub fn verify(&self) -> Result<(), String> {
#[cfg(any(target_os = "linux", target_os = "macos"))]
{
use std::os::unix::fs::MetadataExt;
for (label, path, root) in [
("common git dir", &self.common_dir, &self.common_root),
("worktree admin dir", &self.git_dir, &self.git_root),
] {
let now = std::fs::metadata(path).map_err(|e| {
format!(
"refused: the {label} '{}' can no longer be inspected ({e})",
path.display()
)
})?;
let held = root.identity();
if (now.dev(), now.ino()) != (held.device, held.inode) {
return Err(format!(
"refused: the {label} '{}' no longer names the directory bound \
before the confined command ran",
path.display()
));
}
}
}
Ok(())
}
fn read_detached_head(&self) -> Result<Option<String>, String> {
let text = {
#[cfg(any(target_os = "linux", target_os = "macos"))]
{
use std::io::Read;
match self.git_root.open_read(Path::new("HEAD")) {
Ok(mut file) => {
let mut text = String::new();
file.read_to_string(&mut text)
.map_err(|e| format!("refused: cannot read HEAD ({e})"))?;
text
}
Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(None),
Err(e) => return Err(format!("refused: cannot read HEAD ({e})")),
}
}
#[cfg(not(any(target_os = "linux", target_os = "macos")))]
{
match std::fs::read_to_string(self.git_dir.join("HEAD")) {
Ok(text) => text,
Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(None),
Err(e) => return Err(format!("refused: cannot read HEAD ({e})")),
}
}
};
let text = text.trim();
if text.is_empty() || text.starts_with("ref:") {
return Ok(None);
}
Ok(Some(text.to_string()))
}
}
#[cfg(any(target_os = "linux", target_os = "macos"))]
fn hardened_git_common(identity: &BoundGitIdentity, args: &[&str]) -> io::Result<Command> {
let mut cmd = hardened_git(&identity.common_dir, args)?;
cmd.env("GIT_DIR", &identity.common_dir);
Ok(cmd)
}
pub struct OwnBranchRefMove {
pub branch: String,
pub old_tip: String,
pub identity: Arc<BoundGitIdentity>,
}
#[cfg(any(target_os = "linux", target_os = "macos"))]
pub fn own_branch_for_commit_ref_move(workspace: &Path) -> Option<OwnBranchRefMove> {
let (common_dir, git_dir) = verified_identity(workspace)?;
let branch = own_branch(workspace)?;
if is_default_branch(workspace, &branch) {
return None;
}
let identity = BoundGitIdentity::bind(common_dir, git_dir).ok()?;
let old_tip = own_branch_tip(&identity, &branch)?;
Some(OwnBranchRefMove {
branch,
old_tip,
identity: Arc::new(identity),
})
}
#[cfg(not(any(target_os = "linux", target_os = "macos")))]
pub fn own_branch_for_commit_ref_move(_workspace: &Path) -> Option<OwnBranchRefMove> {
None
}
#[cfg(any(target_os = "linux", target_os = "macos"))]
fn own_branch_tip(identity: &BoundGitIdentity, branch: &str) -> Option<String> {
rev_parse(identity, &format!("refs/heads/{branch}"))
.ok()
.flatten()
}
pub fn detach_own_head(identity: &BoundGitIdentity, tip: &str) -> Result<(), String> {
detach_own_head_seamed(identity, tip, &|| {})
}
#[cfg(any(target_os = "linux", target_os = "macos"))]
fn detach_own_head_seamed(
identity: &BoundGitIdentity,
tip: &str,
between_verify_and_use: &dyn Fn(),
) -> Result<(), String> {
identity.verify()?;
between_verify_and_use();
write_head_natively(identity, &format!("{tip}\n"))
}
#[cfg(not(any(target_os = "linux", target_os = "macos")))]
fn detach_own_head_seamed(
_identity: &BoundGitIdentity,
_tip: &str,
_between_verify_and_use: &dyn Fn(),
) -> Result<(), String> {
Err(unsupported_native_write())
}
pub fn reattach_own_head(identity: &BoundGitIdentity, branch: &str) -> Result<(), String> {
reattach_own_head_seamed(identity, branch, &|| {})
}
#[cfg(any(target_os = "linux", target_os = "macos"))]
fn reattach_own_head_seamed(
identity: &BoundGitIdentity,
branch: &str,
between_verify_and_use: &dyn Fn(),
) -> Result<(), String> {
identity.verify()?;
between_verify_and_use();
write_head_natively(identity, &format!("ref: refs/heads/{branch}\n"))
}
#[cfg(not(any(target_os = "linux", target_os = "macos")))]
fn reattach_own_head_seamed(
_identity: &BoundGitIdentity,
_branch: &str,
_between_verify_and_use: &dyn Fn(),
) -> Result<(), String> {
Err(unsupported_native_write())
}
#[cfg(not(any(target_os = "linux", target_os = "macos")))]
fn unsupported_native_write() -> String {
"refused: native ref/HEAD writes are Linux/macOS only on this build (no \
crate::fs_cap on this platform); the pathname-based git spawn this replaced is \
not restored (#2686 review round 4, P2)"
.to_string()
}
#[cfg(any(target_os = "linux", target_os = "macos"))]
fn write_head_natively(identity: &BoundGitIdentity, content: &str) -> Result<(), String> {
write_lockfile_natively(&identity.git_workspace, Path::new("HEAD"), content)
}
#[cfg(any(target_os = "linux", target_os = "macos"))]
fn write_lockfile_natively(
workspace: &crate::fs_cap::WorkspaceDir,
rel: &Path,
content: &str,
) -> Result<(), String> {
write_lockfile_checked(workspace, rel, content, None, None)
}
#[cfg(any(target_os = "linux", target_os = "macos"))]
pub(crate) fn publish_detached_head(
admin: &agent_bridle_fdguard::GrantedRoot,
old: &str,
new: &str,
verified_reflog_entry: &str,
) -> Result<(), String> {
let workspace = crate::fs_cap::WorkspaceDir::from_granted_root(admin)
.map_err(|e| format!("refused: cannot bind HEAD publication ({e})"))?;
write_lockfile_checked(
&workspace,
Path::new("HEAD"),
&format!("{new}\n"),
Some(old),
Some(verified_reflog_entry),
)
}
#[cfg(any(target_os = "linux", target_os = "macos"))]
fn write_lockfile_checked(
workspace: &crate::fs_cap::WorkspaceDir,
rel: &Path,
content: &str,
expected: Option<&str>,
head_reflog: Option<&str>,
) -> Result<(), String> {
use std::io::{Read as _, Write as _};
let lock_rel = lock_path(rel);
let mut lock = workspace.create_new(&lock_rel).map_err(|e| {
format!(
"refused: '{}' is locked by another writer ({e})",
rel.display()
)
})?;
if let Some(expected) = expected {
let mut actual = String::new();
let read = workspace
.open_regular(rel, true)
.and_then(|mut file| file.read_to_string(&mut actual));
if read.is_err() || actual.trim() != expected {
drop(lock);
let _ = workspace.unlink(&lock_rel);
return Err("refused: detached HEAD changed during native commit".into());
}
}
let wrote = lock
.write_all(content.as_bytes())
.and_then(|()| lock.sync_all());
drop(lock);
if let Err(e) = wrote {
let _ = workspace.unlink(&lock_rel);
return Err(format!("refused: cannot write '{}' ({e})", rel.display()));
}
if let Some(entry) = head_reflog {
let appended = workspace
.create_dir_all(Path::new("logs"))
.and_then(|()| workspace.append_regular(Path::new("logs/HEAD")))
.and_then(|mut log| {
log.write_all(entry.as_bytes())
.and_then(|()| log.sync_all())
});
if let Err(e) = appended {
let _ = workspace.unlink(&lock_rel);
return Err(format!("refused: cannot append worktree HEAD reflog ({e}); HEAD unchanged; a partial log entry may remain"));
}
}
workspace.rename(&lock_rel, rel).map_err(|e| {
let _ = workspace.unlink(&lock_rel);
let log_note = if head_reflog.is_some() {
"; the verified worktree HEAD reflog entry was already appended"
} else {
""
};
format!("refused: cannot commit '{}' ({e}){log_note}", rel.display())
})
}
#[cfg(any(target_os = "linux", target_os = "macos"))]
fn lock_path(rel: &Path) -> PathBuf {
let mut name = rel.as_os_str().to_owned();
name.push(".lock");
PathBuf::from(name)
}
#[derive(Debug)]
pub struct AdvanceRefusal {
pub candidate_oid: Option<String>,
pub reason: String,
}
impl std::fmt::Display for AdvanceRefusal {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.reason)
}
}
pub fn advance_own_branch_ref(
identity: &BoundGitIdentity,
branch: &str,
old_tip: &str,
) -> Result<String, AdvanceRefusal> {
advance_own_branch_ref_seamed(identity, branch, old_tip, &|| {})
}
#[cfg(any(target_os = "linux", target_os = "macos"))]
fn advance_own_branch_ref_seamed(
identity: &BoundGitIdentity,
branch: &str,
old_tip: &str,
between_verify_and_use: &dyn Fn(),
) -> Result<String, AdvanceRefusal> {
let refuse = |reason: String, candidate_oid: Option<String>| AdvanceRefusal {
candidate_oid,
reason,
};
identity.verify().map_err(|reason| refuse(reason, None))?;
let new_oid = identity
.read_detached_head()
.map_err(|e| refuse(e, None))?
.ok_or_else(|| refuse("no commit was created on detached HEAD".to_string(), None))?;
if new_oid == old_tip {
return Err(refuse(
"HEAD did not move — nothing to publish".to_string(),
None,
));
}
let new_parents =
commit_parents_of(identity, &new_oid).map_err(|e| refuse(e, Some(new_oid.clone())))?;
let is_append = new_parents == [old_tip.to_string()];
let old_parents =
commit_parents_of(identity, old_tip).map_err(|e| refuse(e, Some(new_oid.clone())))?;
let is_amend = !is_append && !old_parents.is_empty() && new_parents == old_parents;
if !is_append && !is_amend {
return Err(refuse(
format!(
"the new commit {new_oid} is not an append or amendment of branch '{branch}''s \
previous tip {old_tip} (parents: {new_parents:?})"
),
Some(new_oid),
));
}
let subject = git_text(identity, &["log", "-1", "--format=%s", &new_oid])
.map_err(|e| refuse(e, Some(new_oid.clone())))?;
let message = format!(
"commit{}: {subject}",
if is_amend { " (amend)" } else { "" }
);
between_verify_and_use();
advance_branch_ref_natively(identity, branch, old_tip, &new_oid, &message)
.map_err(|e| refuse(e, Some(new_oid.clone())))?;
Ok(new_oid)
}
#[cfg(not(any(target_os = "linux", target_os = "macos")))]
fn advance_own_branch_ref_seamed(
_identity: &BoundGitIdentity,
_branch: &str,
_old_tip: &str,
_between_verify_and_use: &dyn Fn(),
) -> Result<String, AdvanceRefusal> {
Err(AdvanceRefusal {
candidate_oid: None,
reason: unsupported_native_write(),
})
}
#[cfg(any(target_os = "linux", target_os = "macos"))]
fn advance_branch_ref_natively(
identity: &BoundGitIdentity,
branch: &str,
old_tip: &str,
new_oid: &str,
message: &str,
) -> Result<(), String> {
advance_branch_ref_natively_seamed(identity, branch, Some(old_tip), new_oid, message, &|| {})
}
#[cfg(any(target_os = "linux", target_os = "macos"))]
fn advance_branch_ref_natively_seamed(
identity: &BoundGitIdentity,
branch: &str,
old_tip: Option<&str>,
new_oid: &str,
message: &str,
between_reflog_and_rename: &dyn Fn(),
) -> Result<(), String> {
use std::io::Write as _;
let _namespace = old_tip
.is_none()
.then(|| ref_namespace::NewRefNamespace::lock(identity, branch))
.transpose()?;
let ref_rel = PathBuf::from(format!("refs/heads/{branch}"));
if let Some(parent) = ref_rel.parent().filter(|p| !p.as_os_str().is_empty()) {
identity
.common_workspace
.create_dir_all(parent)
.map_err(|e| format!("refused: cannot create refs/heads/{branch}'s directory ({e})"))?;
}
let lock_rel = lock_path(&ref_rel);
let mut lock = identity
.common_workspace
.create_new(&lock_rel)
.map_err(|e| format!("refused: refs/heads/{branch} is locked by another writer ({e})"))?;
let abort = |reason: String| {
let _ = identity.common_workspace.unlink(&lock_rel);
reason
};
if old_tip.is_none() {
if let Err(e) = ref_namespace::check(identity, branch) {
return Err(abort(e));
}
}
let current = match read_ref_natively(identity, branch) {
Ok(current) => current,
Err(e) => return Err(abort(e)),
};
if current.as_deref() != old_tip {
return Err(abort(format!(
"refused: refs/heads/{branch} is no longer at {old_tip:?} (now {current:?}) — \
a concurrent mover won the race"
)));
}
let wrote = lock
.write_all(format!("{new_oid}\n").as_bytes())
.and_then(|()| lock.sync_all());
drop(lock);
if let Err(e) = wrote {
return Err(abort(format!(
"refused: cannot write refs/heads/{branch} ({e})"
)));
}
if let Err(e) = append_reflog_natively(
identity,
branch,
old_tip.unwrap_or(&"0".repeat(new_oid.len())),
new_oid,
message,
) {
return Err(abort(e));
}
between_reflog_and_rename();
identity
.common_workspace
.rename(&lock_rel, &ref_rel)
.map_err(|e| {
abort(format!(
"refused: cannot commit refs/heads/{branch} ({e}) — the reflog line for this \
update was already appended to logs/refs/heads/{branch} before this failure, so \
that log now has an entry the ref itself never reflects"
))
})
}
#[cfg(any(target_os = "linux", target_os = "macos"))]
fn read_ref_natively(identity: &BoundGitIdentity, branch: &str) -> Result<Option<String>, String> {
use std::io::Read as _;
let rel = PathBuf::from(format!("refs/heads/{branch}"));
match identity.common_root.open_read(&rel) {
Ok(mut file) => {
let mut text = String::new();
file.read_to_string(&mut text)
.map_err(|e| format!("refused: cannot read refs/heads/{branch} ({e})"))?;
let oid = text.trim();
if !crate::git_staging::is_hex_oid(oid) || oid.bytes().all(|b| b == b'0') {
return Err(format!("refused: invalid loose refs/heads/{branch}"));
}
Ok(Some(oid.to_owned()))
}
Err(e) if e.kind() == io::ErrorKind::NotFound => read_packed_ref_natively(identity, branch),
Err(e) => Err(format!("refused: cannot read refs/heads/{branch} ({e})")),
}
}
#[cfg(any(target_os = "linux", target_os = "macos"))]
fn read_packed_ref_natively(
identity: &BoundGitIdentity,
branch: &str,
) -> Result<Option<String>, String> {
let suffix = format!(" refs/heads/{branch}");
let text = read_packed_refs_natively(identity)?;
let oid = text
.lines()
.filter(|line| !line.starts_with('#') && !line.starts_with('^'))
.find_map(|line| line.strip_suffix(&suffix));
match oid {
Some(oid) if !crate::git_staging::is_hex_oid(oid) || oid.bytes().all(|b| b == b'0') => {
Err(format!("refused: invalid packed refs/heads/{branch}"))
}
_ => Ok(oid.map(str::to_owned)),
}
}
#[cfg(any(target_os = "linux", target_os = "macos"))]
fn read_packed_refs_natively(identity: &BoundGitIdentity) -> Result<String, String> {
use std::io::Read as _;
match identity.common_root.open_read(Path::new("packed-refs")) {
Ok(mut file) => {
let mut text = String::new();
file.read_to_string(&mut text)
.map_err(|e| format!("refused: cannot read packed-refs ({e})"))?;
Ok(text)
}
Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(String::new()),
Err(e) => Err(format!("refused: cannot read packed-refs ({e})")),
}
}
#[cfg(any(target_os = "linux", target_os = "macos"))]
fn append_reflog_natively(
identity: &BoundGitIdentity,
branch: &str,
old_tip: &str,
new_oid: &str,
message: &str,
) -> Result<(), String> {
use std::io::Write as _;
let rel = Path::new("logs/refs/heads").join(branch);
if let Some(parent) = rel.parent() {
identity
.common_workspace
.create_dir_all(parent)
.map_err(|e| {
format!("refused: cannot create the reflog directory for {branch} ({e})")
})?;
}
let line = format!(
"{old_tip} {new_oid} {} <{}> {} +0000\t{message}\n",
crate::agent_identity::DEFAULT_AGENT_NAME,
crate::agent_identity::DEFAULT_AGENT_EMAIL,
chrono::Utc::now().timestamp(),
);
let mut file = identity
.common_root
.open_write(&rel, true)
.map_err(|e| format!("refused: cannot open logs/refs/heads/{branch} ({e})"))?;
file.write_all(line.as_bytes())
.and_then(|()| file.sync_all())
.map_err(|e| format!("refused: cannot append to logs/refs/heads/{branch} ({e})"))
}
pub fn detached_commit_candidate(
identity: &BoundGitIdentity,
old_tip: &str,
) -> Result<Option<String>, String> {
identity.verify()?;
let Some(oid) = identity.read_detached_head()? else {
return Ok(None);
};
let Some(candidate) = (oid != old_tip).then_some(oid) else {
return Ok(None);
};
if !is_real_commit_object(identity, &candidate) {
return Err(format!(
"refused: detached HEAD '{candidate}' is not a complete commit \
object — a confined child may still be writing HEAD when it was \
read; re-check after the child reaps"
));
}
Ok(Some(candidate))
}
#[cfg(any(target_os = "linux", target_os = "macos"))]
fn is_real_commit_object(identity: &BoundGitIdentity, oid: &str) -> bool {
git_text(identity, &["cat-file", "-t", oid])
.ok()
.is_some_and(|kind| kind.trim() == "commit")
}
#[cfg(not(any(target_os = "linux", target_os = "macos")))]
fn is_real_commit_object(_identity: &BoundGitIdentity, _oid: &str) -> bool {
false
}
pub struct DetachedHeadGuard {
identity: Arc<BoundGitIdentity>,
branch: String,
old_tip: String,
resolved: AtomicBool,
}
impl DetachedHeadGuard {
#[must_use]
pub fn new(identity: Arc<BoundGitIdentity>, branch: String, old_tip: String) -> Self {
Self {
identity,
branch,
old_tip,
resolved: AtomicBool::new(false),
}
}
pub fn resolve(&self) {
self.resolved.store(true, Ordering::Release);
}
}
impl Drop for DetachedHeadGuard {
fn drop(&mut self) {
if self.resolved.load(Ordering::Acquire) {
return;
}
match detached_commit_candidate(&self.identity, &self.old_tip) {
Ok(Some(oid)) => {
let event = crate::event_journal::JournalEvent::new(
crate::event_journal::EventKind::OrphanedCommit,
&self.branch,
&oid,
"detached-head-guard-drop",
);
if crate::event_journal::record_event(event).is_none() {
eprintln!(
"newt: a commit ({oid}) landed on detached HEAD for branch '{}' but \
the dispatch holding it was cancelled before publication, and it \
could not be durably recorded either. Recover it with \
`git branch {} {oid}`.",
self.branch, self.branch
);
}
}
Ok(None) => {}
Err(error) => {
eprintln!(
"newt: could not check whether a commit landed on detached HEAD for \
branch '{}' before this dispatch was cancelled: {error}. If one exists, \
it may be unreachable from any ref — recover it by inspecting the \
worktree's reflog by hand.",
self.branch
);
}
}
if let Err(error) = reattach_own_head(&self.identity, &self.branch) {
eprintln!(
"newt: could not restore branch '{}' as HEAD after a cancelled dispatch: \
{error}. The worktree may still be on a detached HEAD.",
self.branch
);
}
}
}
#[cfg(any(target_os = "linux", target_os = "macos"))]
fn rev_parse(identity: &BoundGitIdentity, rev: &str) -> Result<Option<String>, String> {
let output = hardened_git_common(identity, &["rev-parse", "--verify", "--quiet", rev])
.map_err(|e| e.to_string())?
.output()
.map_err(|e| e.to_string())?;
if !output.status.success() {
return Ok(None);
}
let oid = String::from_utf8_lossy(&output.stdout).trim().to_string();
Ok((!oid.is_empty()).then_some(oid))
}
#[cfg(any(target_os = "linux", target_os = "macos"))]
fn commit_parents_of(identity: &BoundGitIdentity, oid: &str) -> Result<Vec<String>, String> {
let output = hardened_git_common(identity, &["cat-file", "commit", oid])
.map_err(|e| e.to_string())?
.output()
.map_err(|e| e.to_string())?;
if !output.status.success() {
return Err(String::from_utf8_lossy(&output.stderr).trim().to_string());
}
crate::native_git_broker::commit_parents(&output.stdout)
}
#[cfg(any(target_os = "linux", target_os = "macos"))]
fn git_text(identity: &BoundGitIdentity, args: &[&str]) -> Result<String, String> {
let output = hardened_git_common(identity, args)
.map_err(|e| e.to_string())?
.output()
.map_err(|e| e.to_string())?;
if !output.status.success() {
return Err(String::from_utf8_lossy(&output.stderr).trim().to_string());
}
Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
}
pub(crate) fn git_dirs(workspace: &Path) -> Option<(PathBuf, PathBuf)> {
let output = hardened_git(
workspace,
&["rev-parse", "--git-common-dir", "--absolute-git-dir"],
)
.ok()?
.output()
.ok()?;
if !output.status.success() {
return None;
}
let stdout = String::from_utf8_lossy(&output.stdout);
let mut lines = stdout.lines();
let common_dir = lines.next()?;
let absolute_git_dir = PathBuf::from(lines.next()?);
let common_dir = if Path::new(common_dir).is_absolute() {
PathBuf::from(common_dir)
} else {
workspace.join(common_dir)
};
Some((common_dir, absolute_git_dir))
}
pub(crate) fn own_branch(workspace: &Path) -> Option<String> {
let output = hardened_git(workspace, &["symbolic-ref", "-q", "HEAD"])
.ok()?
.output()
.ok()?;
if !output.status.success() {
return None;
}
String::from_utf8_lossy(&output.stdout)
.trim()
.strip_prefix("refs/heads/")
.map(str::to_owned)
}
pub(crate) fn is_default_branch(workspace: &Path, branch: &str) -> bool {
if branch == "main" || branch == "master" {
return true;
}
let Some(output) = hardened_git(
workspace,
&["symbolic-ref", "-q", "refs/remotes/origin/HEAD"],
)
.ok()
.and_then(|mut c| c.output().ok()) else {
return false;
};
if !output.status.success() {
return false;
}
String::from_utf8_lossy(&output.stdout)
.trim()
.strip_prefix("refs/remotes/origin/")
.is_some_and(|default| default == branch)
}
#[must_use]
pub fn origin_default_branch_name(workspace: &Path) -> Option<String> {
let output = hardened_git(
workspace,
&["symbolic-ref", "-q", "refs/remotes/origin/HEAD"],
)
.ok()?
.output()
.ok()?;
if !output.status.success() {
return None;
}
String::from_utf8_lossy(&output.stdout)
.trim()
.strip_prefix("refs/remotes/origin/")
.map(str::to_owned)
}
const GIT_HARDENING_OVERRIDES: &[&str] = &[
"core.fsmonitor=", "core.hooksPath=/dev/null", "core.pager=cat", "core.sshCommand=false", "core.askpass=", "core.editor=false", "protocol.ext.allow=never", ];
const GIT_HOST_ONLY_OVERRIDES: &[&str] = &["diff.external="];
const COPYABLE_GIT_CONFIG: &[&str] = &[
"init.defaultbranch",
"core.autocrlf",
"core.eol",
"core.safecrlf",
"core.quotepath",
"core.whitespace",
"pull.rebase",
"pull.ff",
"push.default",
"fetch.prune",
"merge.conflictstyle",
"rebase.autosquash",
"rebase.autostash",
"rerere.enabled",
"diff.algorithm",
"diff.renames",
"diff.colormoved",
"branch.sort",
"tag.sort",
"commit.verbose",
"help.autocorrect",
"color.",
"column.",
"status.",
"log.",
];
fn copyable(key: &str) -> bool {
let key = key.to_ascii_lowercase();
COPYABLE_GIT_CONFIG.iter().any(|allowed| {
if allowed.ends_with('.') {
key.starts_with(allowed) && !key[allowed.len()..].contains('.')
} else {
key == *allowed
}
})
}
#[must_use]
pub fn copyable_git_config(listing: &str) -> std::collections::BTreeMap<String, String> {
listing
.lines()
.filter_map(|line| line.split_once('='))
.filter(|(key, _)| copyable(key))
.map(|(key, value)| (key.to_ascii_lowercase(), value.to_owned()))
.collect()
}
pub fn ambient_git_config_listing() -> io::Result<String> {
let Some(home) = crate::config::home_dir() else {
return Ok(String::new());
};
let xdg = std::env::var_os("XDG_CONFIG_HOME")
.map(PathBuf::from)
.unwrap_or_else(|| home.join(".config"));
let mut listing = String::new();
for file in [home.join(".gitconfig"), xdg.join("git").join("config")] {
if !file.is_file() {
continue;
}
let file = file.to_string_lossy().into_owned();
let output = hardened_git(&home, &["config", "--file", &file, "--list"])?.output()?;
if output.status.success() {
listing.push_str(&String::from_utf8_lossy(&output.stdout));
}
}
Ok(listing)
}
#[must_use]
pub fn sandbox_git_env(
author: (&str, &str),
profile: &std::collections::BTreeMap<String, String>,
) -> Vec<(String, String)> {
let mut config: Vec<(&str, &str)> = profile
.iter()
.filter(|(key, _)| copyable(key))
.map(|(key, value)| (key.as_str(), value.as_str()))
.collect();
config.extend([
("core.excludesFile", "/dev/null"),
("core.attributesFile", "/dev/null"),
("user.name", author.0),
("user.email", author.1),
]);
config.extend(
GIT_HARDENING_OVERRIDES
.iter()
.map(|kv| kv.split_once('=').expect("each override is `key=value`")),
);
let mut env: Vec<(String, String)> = [
("GIT_CONFIG_GLOBAL", "/dev/null"),
("GIT_CONFIG_SYSTEM", "/dev/null"),
("GIT_CONFIG_NOSYSTEM", "1"),
]
.into_iter()
.map(|(k, v)| (k.to_owned(), v.to_owned()))
.collect();
env.push(("GIT_CONFIG_COUNT".to_owned(), config.len().to_string()));
for (i, (key, value)) in config.into_iter().enumerate() {
env.push((format!("GIT_CONFIG_KEY_{i}"), key.to_owned()));
env.push((format!("GIT_CONFIG_VALUE_{i}"), value.to_owned()));
}
env
}
pub fn metadata_git(
cwd: &Path,
args: &[&str],
read_scope: &crate::Scope<String>,
) -> io::Result<Command> {
crate::agentic::check_git_read_scope("metadata", read_scope)
.map_err(|error| io::Error::new(io::ErrorKind::PermissionDenied, error))?;
hardened_git(cwd, args)
}
pub fn hardened_git(cwd: &Path, args: &[&str]) -> io::Result<Command> {
let path = std::env::var_os("PATH");
let mut c = Command::new(git_program(cwd, path.as_deref())?);
c.arg("--no-optional-locks");
for kv in GIT_HARDENING_OVERRIDES
.iter()
.chain(GIT_HOST_ONLY_OVERRIDES)
{
c.arg("-c").arg(kv);
}
c.args(args).current_dir(cwd);
c.env_clear();
if let Some(path) = path {
c.env("PATH", path);
}
if let Some(home) = std::env::var_os("HOME") {
c.env("HOME", home);
}
c.env("LC_ALL", "C")
.env("LANG", "C")
.env("GIT_CONFIG_GLOBAL", "/dev/null")
.env("GIT_CONFIG_SYSTEM", "/dev/null")
.env("GIT_CONFIG_NOSYSTEM", "1")
.env("GIT_TERMINAL_PROMPT", "0")
.env("GIT_PAGER", "cat");
Ok(c)
}
pub fn resolve_remote_url(workspace: &Path, remote: &str) -> Result<String, String> {
let output = hardened_git(workspace, &["remote", "get-url", remote])
.map_err(|e| e.to_string())?
.output()
.map_err(|e| e.to_string())?;
if !output.status.success() {
return Err(format!(
"no such remote '{remote}': {}",
String::from_utf8_lossy(&output.stderr).trim()
));
}
Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
}
pub fn push_url_host(url: &str) -> Result<String, String> {
let unsupported = || {
format!(
"unsupported remote URL scheme for a governed push: {url} (only https:// and ssh:// are pushable)"
)
};
let valid_host = |host: &str| {
!host.is_empty()
&& host
.chars()
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '-'))
};
if let Some(rest) = url.strip_prefix("https://") {
if rest.contains('@') {
return Err(format!(
"refused: an https push URL must not embed credentials or a username: {url}"
));
}
let authority = rest.split('/').next().unwrap_or("");
let host = authority.split(':').next().unwrap_or(authority);
return if valid_host(host) {
Ok(host.to_ascii_lowercase())
} else {
Err(unsupported())
};
}
if let Some(rest) = url.strip_prefix("ssh://") {
let authority = rest.split('/').next().unwrap_or("");
let host_part = authority.rsplit('@').next().unwrap_or(authority);
let host = host_part.split(':').next().unwrap_or(host_part);
return if valid_host(host) {
Ok(host.to_ascii_lowercase())
} else {
Err(unsupported())
};
}
if url.contains("://") || url.contains("::") {
return Err(unsupported());
}
if let Some(colon) = url.find(':') {
if url[..colon].contains('/') {
return Err(unsupported()); }
let authority = &url[..colon];
let Some(host) = authority.rsplit_once('@').map(|(_, host)| host) else {
return Err(unsupported()); };
return if valid_host(host) {
Ok(host.to_ascii_lowercase())
} else {
Err(unsupported())
};
}
Err(unsupported())
}
#[must_use]
pub fn github_owner_repo(url: &str) -> Option<(String, String)> {
let host = push_url_host(url).ok()?;
if host != "github.com" {
return None;
}
let path = if let Some(rest) = url.strip_prefix("https://") {
rest.split_once('/')?.1
} else if let Some(rest) = url.strip_prefix("ssh://") {
rest.split_once('/')?.1
} else {
url.split_once(':')?.1
};
let path = path.trim_end_matches('/').trim_end_matches(".git");
let (owner, name) = path.split_once('/')?;
(!owner.is_empty() && !name.is_empty()).then(|| (owner.to_string(), name.to_string()))
}
fn git_program(cwd: &Path, path: Option<&OsStr>) -> io::Result<PathBuf> {
resolve_trusted_program(cwd, path, "git")
}
pub fn trusted_git_program(path: Option<&OsStr>) -> io::Result<PathBuf> {
resolve_trusted_program(Path::new("."), path, "git")
}
pub fn trusted_gh_program(path: Option<&OsStr>) -> io::Result<PathBuf> {
resolve_trusted_program(Path::new("."), path, "gh")
}
#[cfg(unix)]
pub(crate) fn resolve_trusted_program(
cwd: &Path,
path: Option<&OsStr>,
name: &str,
) -> io::Result<PathBuf> {
use std::os::unix::fs::PermissionsExt;
let _ = cwd;
path.into_iter()
.flat_map(std::env::split_paths)
.filter(|entry| entry.is_absolute())
.map(|entry| entry.join(name))
.find(|program| {
program.metadata().is_ok_and(|metadata| {
metadata.is_file() && metadata.permissions().mode() & 0o111 != 0
})
})
.ok_or_else(|| {
io::Error::new(
io::ErrorKind::NotFound,
format!("{name} executable not found in an absolute PATH entry"),
)
})
}
#[cfg(not(unix))]
pub(crate) fn resolve_trusted_program(
cwd: &Path,
path: Option<&OsStr>,
name: &str,
) -> io::Result<PathBuf> {
let _ = (cwd, path);
Ok(PathBuf::from(name))
}
#[cfg(all(test, unix))]
mod tests {
use super::*;
use std::os::unix::fs::PermissionsExt;
#[test]
fn git_program_is_resolved_before_the_environment_is_scrubbed() {
let command = hardened_git(Path::new("."), &["--version"]).unwrap();
assert!(
Path::new(command.get_program()).is_absolute(),
"a PATH-scrubbed bare program forces Rust's fork/pre-exec path"
);
assert!(command.get_envs().any(|(key, value)| {
key == "GIT_CONFIG_GLOBAL" && value == Some(std::ffi::OsStr::new("/dev/null"))
}));
}
#[test]
fn executable_lookup_preserves_path_order_among_absolute_entries() {
let fixture = tempfile::tempdir().unwrap();
let cwd = std::path::absolute(fixture.path()).unwrap();
for (name, mode) in [
("not-executable", 0o600),
("first", 0o700),
("second", 0o700),
] {
let dir = cwd.join(name);
std::fs::create_dir(&dir).unwrap();
let file = dir.join("git");
std::fs::write(&file, "#!/bin/sh\nexit 0\n").unwrap();
std::fs::set_permissions(file, std::fs::Permissions::from_mode(mode)).unwrap();
}
let path = std::env::join_paths([
cwd.join("missing"),
cwd.join("not-executable"),
cwd.join("first"),
cwd.join("second"),
])
.unwrap();
assert_eq!(
git_program(&cwd, Some(&path)).unwrap(),
cwd.join("first/git")
);
let reversed = std::env::join_paths([cwd.join("second"), cwd.join("first")]).unwrap();
assert_eq!(
git_program(&cwd, Some(&reversed)).unwrap(),
cwd.join("second/git")
);
}
#[test]
fn relative_and_empty_path_entries_are_never_resolved_against_cwd() {
let fixture = tempfile::tempdir().unwrap();
let cwd = std::path::absolute(fixture.path()).unwrap();
let planted = cwd.join("git");
std::fs::write(&planted, "#!/bin/sh\nexit 0\n").unwrap();
std::fs::set_permissions(&planted, std::fs::Permissions::from_mode(0o700)).unwrap();
for path in [OsStr::new(""), OsStr::new("."), OsStr::new("sub")] {
assert_eq!(
git_program(&cwd, Some(path)).unwrap_err().kind(),
io::ErrorKind::NotFound,
"a repo-writable relative/empty PATH entry must never be searched: {path:?}"
);
}
}
#[test]
fn absent_or_unusable_path_fails_in_the_parent_without_fallback() {
let fixture = tempfile::tempdir().unwrap();
for path in [None, Some(OsStr::new("missing"))] {
assert_eq!(
git_program(fixture.path(), path).unwrap_err().kind(),
io::ErrorKind::NotFound
);
}
}
}
#[cfg(test)]
mod sandbox_git_env_tests {
use super::*;
fn config(env: &[(String, String)]) -> Vec<(String, String)> {
let get = |k: &str| env.iter().find(|(key, _)| key == k).map(|(_, v)| v.clone());
let count: usize = get("GIT_CONFIG_COUNT").unwrap().parse().unwrap();
(0..count)
.map(|i| {
(
get(&format!("GIT_CONFIG_KEY_{i}")).unwrap(),
get(&format!("GIT_CONFIG_VALUE_{i}")).unwrap(),
)
})
.collect()
}
#[test]
fn ambient_config_is_ignored() {
let env = sandbox_git_env(("newt-agent", "a@b"), &Default::default());
assert!(env.contains(&("GIT_CONFIG_GLOBAL".into(), "/dev/null".into())));
assert!(env.contains(&("GIT_CONFIG_NOSYSTEM".into(), "1".into())));
}
#[test]
fn every_hardening_override_rides_the_env_config() {
let config = config(&sandbox_git_env(("newt-agent", "a@b"), &Default::default()));
for kv in GIT_HARDENING_OVERRIDES {
let (key, value) = kv.split_once('=').unwrap();
assert!(
config.contains(&(key.into(), value.into())),
"{kv} missing: {config:?}"
);
}
}
#[test]
fn invalid_external_diff_override_is_reserved_for_host_metadata() {
let config = config(&sandbox_git_env(("newt-agent", "a@b"), &Default::default()));
assert!(config.iter().all(|(key, _)| key != "diff.external"));
let host =
hardened_git(Path::new("."), &["diff", "--no-ext-diff", "--no-textconv"]).unwrap();
assert!(host.get_args().any(|arg| arg == "diff.external="));
}
#[test]
fn commits_are_authored_by_the_agent_identity() {
let config = config(&sandbox_git_env(
(
crate::agent_identity::DEFAULT_AGENT_NAME,
crate::agent_identity::DEFAULT_AGENT_EMAIL,
),
&Default::default(),
));
assert!(config.contains(&("user.name".into(), "newt-agent".into())));
assert!(config.contains(&(
"user.email".into(),
"309460085+newt-agent@users.noreply.github.com".into()
)));
}
#[test]
fn a_copy_keeps_plain_settings_and_drops_every_gadget() {
let listing = "user.name=Op\ninit.defaultBranch=main\nalias.x=!curl evil\n\
credential.helper=osxkeychain\ncore.sshCommand=ssh -i k\ninclude.path=~/x\n\
url.git@h:.insteadof=https://h/\ncolor.ui=auto\ncolor.diff.meta=blue\npull.rebase=true\n";
let copied = copyable_git_config(listing);
let keys: Vec<&str> = copied.keys().map(String::as_str).collect();
assert_eq!(keys, ["color.ui", "init.defaultbranch", "pull.rebase"]);
}
#[test]
fn a_profile_cannot_carry_what_the_copy_refuses() {
let profile: std::collections::BTreeMap<String, String> = [
("alias.st".to_owned(), "!sh -c evil".to_owned()),
("init.defaultbranch".to_owned(), "trunk".to_owned()),
]
.into();
let config = config(&sandbox_git_env(("a", "a@b"), &profile));
assert!(
config.iter().all(|(key, _)| key != "alias.st"),
"{config:?}"
);
assert!(config.contains(&("init.defaultbranch".into(), "trunk".into())));
}
#[test]
fn the_hardening_floor_is_applied_after_the_profile() {
let config = config(&sandbox_git_env(("a", "a@b"), &Default::default()));
let last_user = config
.iter()
.rposition(|(k, _)| k.starts_with("user."))
.unwrap();
let first_floor = config
.iter()
.position(|(k, _)| k == "core.fsmonitor")
.unwrap();
assert!(first_floor > last_user, "{config:?}");
}
}
#[cfg(test)]
mod subdir_discovery_tests {
use super::*;
#[test]
fn hardened_git_from_a_repo_subdirectory_still_finds_the_repo() {
let root = tempfile::tempdir().expect("repo root");
let init = |args: &[&str]| {
assert!(std::process::Command::new("git")
.args(args)
.current_dir(root.path())
.output()
.expect("git")
.status
.success());
};
init(&["init", "-q"]);
init(&["config", "user.email", "t@example.com"]);
init(&["config", "user.name", "t"]);
std::fs::write(root.path().join("f.txt"), "one\n").unwrap();
init(&["add", "f.txt"]);
init(&["commit", "-q", "-m", "init"]);
let subdir = root.path().join("sub");
std::fs::create_dir(&subdir).unwrap();
let out = hardened_git(&subdir, &["rev-parse", "HEAD"])
.unwrap()
.output()
.unwrap();
assert!(
out.status.success(),
"rev-parse HEAD from a repo subdirectory must still succeed: {}",
String::from_utf8_lossy(&out.stderr)
);
}
}
#[cfg(all(test, unix))]
mod own_gitdir_grant_tests {
include!("git_hardening/branch_tests.rs");
use super::*;
use std::process::Command;
fn hermetic_git_env(home: &Path) -> Vec<(&'static str, String)> {
vec![
("HOME", home.to_string_lossy().into_owned()),
("GIT_AUTHOR_NAME", "t".to_string()),
("GIT_AUTHOR_EMAIL", "t@example.invalid".to_string()),
("GIT_COMMITTER_NAME", "t".to_string()),
("GIT_COMMITTER_EMAIL", "t@example.invalid".to_string()),
("GIT_CONFIG_NOSYSTEM", "1".to_string()),
("GIT_CONFIG_GLOBAL", "/dev/null".to_string()),
("GIT_TEMPLATE_DIR", "/dev/null".to_string()),
]
}
fn hermetic_git(dir: &Path, home: &Path) -> Command {
let mut cmd = Command::new("git");
cmd.current_dir(dir)
.env_clear()
.envs(hermetic_git_env(home));
cmd
}
fn git(dir: &Path, args: &[&str]) {
let home = tempfile::tempdir().unwrap();
let status = hermetic_git(dir, home.path())
.args(args)
.status()
.expect("git invocation");
assert!(status.success(), "git {args:?} failed");
}
fn git_output(dir: &Path, args: &[&str]) -> String {
let home = tempfile::tempdir().unwrap();
let output = hermetic_git(dir, home.path())
.args(args)
.output()
.expect("git invocation");
assert!(output.status.success(), "git {args:?} failed");
String::from_utf8_lossy(&output.stdout).trim().to_owned()
}
fn init_repo(dir: &Path) {
git(dir, &["init", "-q"]);
std::fs::write(dir.join("seed"), "x").unwrap();
git(dir, &["add", "seed"]);
git(dir, &["commit", "-q", "-m", "init"]);
}
#[test]
fn full_access_commit_does_not_rebind_an_existing_identity() {
let root = tempfile::tempdir().unwrap();
let workspace = root.path().canonicalize().unwrap();
init_repo(&workspace);
git(&workspace, &["checkout", "-q", "-b", "task"]);
prime_identity_cache(&workspace, None);
let _ = crate::native_git_broker::NativeGitBroker::invocation_caveats(
&crate::Caveats::top(),
&workspace,
)
.unwrap();
assert!(own_gitdir_shell_write_grant(&workspace).is_empty());
assert!(cached_identity(&workspace).is_none());
}
#[test]
fn linked_worktree_on_own_branch_grants_the_two_write_directories() {
let root = tempfile::tempdir().unwrap();
let root_path = root.path().canonicalize().unwrap();
let main = root_path.join("main");
std::fs::create_dir(&main).unwrap();
init_repo(&main);
let wt = root_path.join("wt");
git(
&main,
&["worktree", "add", "-q", wt.to_str().unwrap(), "-b", "task"],
);
let grant = own_gitdir_grants(&wt);
assert!(!grant.write.is_empty(), "own branch must get write roots");
std::fs::write(wt.join("f.txt"), "hi").unwrap();
let scope = crate::caveats::Scope::only(grant.write.clone());
let path = |rel: &str| main.join(".git").join(rel).to_string_lossy().into_owned();
for touched in [
path("worktrees/wt/index"),
path("worktrees/wt/index.lock"),
path("worktrees/wt/HEAD"),
path("worktrees/wt/logs/HEAD"),
path("worktrees/wt/COMMIT_EDITMSG"),
path("objects/pack/multi-pack-index"),
] {
assert!(
crate::caveats::permits_path(&scope, &touched),
"{touched} must be permitted"
);
}
for denied in [
path("refs/heads/task"),
path("refs/heads/task.lock"),
path("logs/refs/heads/task"),
path("refs/tags/v1"),
path("logs/HEAD"),
path("config"),
path("hooks/pre-commit"),
] {
assert!(
!crate::caveats::permits_path(&scope, &denied),
"{denied} must stay denied"
);
}
git(&wt, &["add", "f.txt"]);
}
#[test]
fn normal_checkout_on_own_branch_grants_add_and_commit_paths() {
let repo = tempfile::tempdir().unwrap();
init_repo(repo.path());
git(repo.path(), &["checkout", "-q", "-b", "task"]);
let grant = own_gitdir_grants(repo.path());
assert!(!grant.write.is_empty());
std::fs::write(repo.path().join("f.txt"), "hi").unwrap();
git(repo.path(), &["add", "f.txt"]);
git(repo.path(), &["commit", "-q", "-m", "task work"]);
}
#[test]
fn default_branch_checkout_grants_no_write_roots() {
let repo = tempfile::tempdir().unwrap();
init_repo(repo.path());
git(repo.path(), &["branch", "-m", "main"]);
let grant = own_gitdir_grants(repo.path());
assert!(
grant.write.is_empty(),
"checkout on the default branch must not get commit authority: {:?}",
grant.write
);
}
#[test]
fn detached_head_grants_read_only() {
let repo = tempfile::tempdir().unwrap();
init_repo(repo.path());
let out = Command::new("git")
.args(["rev-parse", "HEAD"])
.current_dir(repo.path())
.output()
.unwrap();
let head = String::from_utf8_lossy(&out.stdout).trim().to_owned();
git(repo.path(), &["checkout", "-q", &head]);
let grant = own_gitdir_grants(repo.path());
assert!(
grant.write.is_empty(),
"detached HEAD must not get write roots"
);
assert!(!grant.read.is_empty(), "detached HEAD still gets read");
}
#[test]
fn detach_advance_reattach_publishes_a_commit_made_on_detached_head() {
let root = tempfile::tempdir().unwrap();
let root_path = root.path().canonicalize().unwrap();
let main = root_path.join("main");
std::fs::create_dir(&main).unwrap();
init_repo(&main);
let wt = root_path.join("wt");
git(
&main,
&["worktree", "add", "-q", wt.to_str().unwrap(), "-b", "task"],
);
own_gitdir_grants(&wt);
let OwnBranchRefMove {
branch,
old_tip,
identity,
} = own_branch_for_commit_ref_move(&wt).expect("task is a non-default, born branch");
assert_eq!(branch, "task");
detach_own_head(&identity, &old_tip).unwrap();
let symref = Command::new("git")
.args(["symbolic-ref", "-q", "HEAD"])
.current_dir(&wt)
.output()
.unwrap();
assert!(
!symref.status.success(),
"HEAD must be a detached oid, not a symbolic ref, while the confined child runs"
);
std::fs::write(wt.join("f.txt"), "hi").unwrap();
git(&wt, &["add", "f.txt"]);
git(&wt, &["commit", "-q", "-m", "task work"]);
let new_oid = advance_own_branch_ref(&identity, &branch, &old_tip).unwrap();
reattach_own_head(&identity, &branch).unwrap();
let tip = git_output(&main, &["rev-parse", "refs/heads/task"]);
assert_eq!(
new_oid, tip,
"advance_own_branch_ref returns the published oid"
);
let head = git_output(&wt, &["rev-parse", "HEAD"]);
assert_eq!(tip, head, "refs/heads/task must advance to the new commit");
assert_ne!(tip, old_tip);
assert_eq!(
git_output(&wt, &["symbolic-ref", "HEAD"]),
"refs/heads/task",
"HEAD must be reattached as a symbolic ref to the branch"
);
assert_eq!(
git_output(&main, &["log", "--format=%s", "-1", "refs/heads/task"]),
"task work"
);
}
#[test]
fn advance_own_branch_ref_refuses_a_commit_that_is_not_old_tips_descendant() {
let root = tempfile::tempdir().unwrap();
let root_path = root.path().canonicalize().unwrap();
let main = root_path.join("main");
std::fs::create_dir(&main).unwrap();
init_repo(&main);
let wt = root_path.join("wt");
git(
&main,
&["worktree", "add", "-q", wt.to_str().unwrap(), "-b", "task"],
);
own_gitdir_grants(&wt);
let OwnBranchRefMove {
branch,
old_tip,
identity,
} = own_branch_for_commit_ref_move(&wt).unwrap();
detach_own_head(&identity, &old_tip).unwrap();
git(&wt, &["checkout", "--orphan", "forged"]);
std::fs::write(wt.join("evil"), "x").unwrap();
git(&wt, &["add", "evil"]);
git(&wt, &["commit", "-q", "-m", "forged"]);
git(&wt, &["checkout", "-q", "--detach", "HEAD"]);
let result = advance_own_branch_ref(&identity, &branch, &old_tip);
let refusal = result.expect_err("an unrelated commit must be refused");
assert!(
refusal.candidate_oid.is_some(),
"a real (if unrelated) commit exists, so its oid must be reported, not lost: {refusal}"
);
assert_eq!(
git_output(&main, &["rev-parse", "refs/heads/task"]),
old_tip,
"a refused advance must not move the branch ref at all"
);
}
#[test]
fn advance_own_branch_ref_accepts_an_amended_commit() {
let root = tempfile::tempdir().unwrap();
let root_path = root.path().canonicalize().unwrap();
let main = root_path.join("main");
std::fs::create_dir(&main).unwrap();
init_repo(&main);
std::fs::write(main.join("second"), "y").unwrap();
git(&main, &["add", "second"]);
git(&main, &["commit", "-q", "-m", "second"]);
let wt = root_path.join("wt");
git(
&main,
&["worktree", "add", "-q", wt.to_str().unwrap(), "-b", "task"],
);
own_gitdir_grants(&wt);
let OwnBranchRefMove {
branch,
old_tip,
identity,
} = own_branch_for_commit_ref_move(&wt).unwrap();
detach_own_head(&identity, &old_tip).unwrap();
std::fs::write(wt.join("f.txt"), "hi").unwrap();
git(&wt, &["add", "f.txt"]);
git(&wt, &["commit", "-q", "--amend", "-m", "amended second"]);
advance_own_branch_ref(&identity, &branch, &old_tip).unwrap();
reattach_own_head(&identity, &branch).unwrap();
assert_eq!(
git_output(&main, &["log", "--format=%s", "-1", "refs/heads/task"]),
"amended second"
);
}
#[test]
fn advance_own_branch_ref_refuses_when_the_branch_moved_concurrently() {
let root = tempfile::tempdir().unwrap();
let root_path = root.path().canonicalize().unwrap();
let main = root_path.join("main");
std::fs::create_dir(&main).unwrap();
init_repo(&main);
let wt = root_path.join("wt");
git(
&main,
&["worktree", "add", "-q", wt.to_str().unwrap(), "-b", "task"],
);
own_gitdir_grants(&wt);
let OwnBranchRefMove {
branch,
old_tip,
identity,
} = own_branch_for_commit_ref_move(&wt).unwrap();
detach_own_head(&identity, &old_tip).unwrap();
std::fs::write(wt.join("f.txt"), "hi").unwrap();
git(&wt, &["add", "f.txt"]);
git(&wt, &["commit", "-q", "-m", "task work"]);
std::fs::write(main.join("elsewhere"), "y").unwrap();
git(&main, &["add", "elsewhere"]);
git(&main, &["commit", "-q", "-m", "elsewhere"]);
git(&main, &["branch", "-f", "task", "HEAD"]);
let moved_to = git_output(&main, &["rev-parse", "refs/heads/task"]);
assert_ne!(moved_to, old_tip, "the race must actually move the ref");
let refusal = advance_own_branch_ref(&identity, &branch, &old_tip)
.expect_err("a moved-tip CAS must refuse, not clobber the concurrent move");
assert!(
refusal.candidate_oid.is_some(),
"the detached commit genuinely exists and must be reported: {refusal}"
);
assert_eq!(
git_output(&main, &["rev-parse", "refs/heads/task"]),
moved_to,
"refs/heads/task must stay exactly where the concurrent mover left it"
);
}
#[test]
fn advance_own_branch_ref_uses_the_held_common_dir_not_a_swapped_pathname() {
let root = tempfile::tempdir().unwrap();
let root_path = root.path().canonicalize().unwrap();
let main = root_path.join("main");
std::fs::create_dir(&main).unwrap();
init_repo(&main);
let wt = root_path.join("wt");
git(
&main,
&["worktree", "add", "-q", wt.to_str().unwrap(), "-b", "task"],
);
own_gitdir_grants(&wt);
let OwnBranchRefMove {
branch,
old_tip,
identity,
} = own_branch_for_commit_ref_move(&wt).unwrap();
detach_own_head(&identity, &old_tip).unwrap();
std::fs::write(wt.join("f.txt"), "hi").unwrap();
git(&wt, &["add", "f.txt"]);
git(&wt, &["commit", "-q", "-m", "task work"]);
let new_oid = git_output(&wt, &["rev-parse", "HEAD"]);
let common_dir = identity.common_dir.clone();
let moved_aside = root_path.join("common-moved-aside");
let swap = || {
std::fs::rename(&common_dir, &moved_aside).unwrap();
std::fs::create_dir(&common_dir).unwrap();
let pathname_resolve = Command::new("git")
.args(["rev-parse", "--verify", "--quiet", "refs/heads/task"])
.env_clear()
.env("GIT_DIR", &common_dir)
.env("HOME", &common_dir)
.output()
.unwrap();
assert!(
!pathname_resolve.status.success(),
"measured red for the class: a pathname git spawn against the \
swapped-in decoy must fail to resolve anything — proving round \
3's implementation could only ever refuse here, never reach \
the real object"
);
};
let published = advance_own_branch_ref_seamed(&identity, &branch, &old_tip, &swap)
.expect("the native write must land in the HELD object after the swap");
assert_eq!(published, new_oid);
assert_eq!(
git_output(&moved_aside, &["rev-parse", "refs/heads/task"]),
new_oid,
"the REAL (now-moved-aside) common dir must show the published commit"
);
assert_eq!(
std::fs::read_dir(&common_dir).unwrap().count(),
0,
"the decoy sitting at the swapped-in pathname must stay untouched — \
nothing in the native write path ever opens it"
);
}
#[test]
fn reattach_own_head_uses_the_held_admin_dir_not_a_swapped_pathname() {
let root = tempfile::tempdir().unwrap();
let root_path = root.path().canonicalize().unwrap();
let main = root_path.join("main");
std::fs::create_dir(&main).unwrap();
init_repo(&main);
let wt = root_path.join("wt");
git(
&main,
&["worktree", "add", "-q", wt.to_str().unwrap(), "-b", "task"],
);
own_gitdir_grants(&wt);
let OwnBranchRefMove {
branch,
old_tip,
identity,
} = own_branch_for_commit_ref_move(&wt).unwrap();
detach_own_head(&identity, &old_tip).unwrap();
let git_dir = identity.git_dir.clone();
let moved_aside = root_path.join("admin-moved-aside");
let swap = || {
std::fs::rename(&git_dir, &moved_aside).unwrap();
std::fs::create_dir(&git_dir).unwrap();
let pathname_resolve = Command::new("git")
.args(["symbolic-ref", "-q", "HEAD"])
.env_clear()
.env("GIT_DIR", &git_dir)
.env("HOME", &git_dir)
.output()
.unwrap();
assert!(
!pathname_resolve.status.success(),
"measured red for the class: a pathname git spawn against the \
swapped-in decoy must fail — round 3's implementation could \
only ever refuse here, never reattach the real worktree"
);
};
reattach_own_head_seamed(&identity, &branch, &swap)
.expect("the native write must land in the HELD admin dir after the swap");
assert_eq!(
std::fs::read_to_string(moved_aside.join("HEAD"))
.unwrap()
.trim(),
"ref: refs/heads/task",
"the REAL (now-moved-aside) admin dir must show HEAD reattached"
);
assert_eq!(
std::fs::read_dir(&git_dir).unwrap().count(),
0,
"the decoy sitting at the swapped-in pathname must stay untouched"
);
}
#[test]
fn detached_commit_candidate_reports_an_unpublished_commit_without_moving_the_ref() {
let root = tempfile::tempdir().unwrap();
let root_path = root.path().canonicalize().unwrap();
let main = root_path.join("main");
std::fs::create_dir(&main).unwrap();
init_repo(&main);
let wt = root_path.join("wt");
git(
&main,
&["worktree", "add", "-q", wt.to_str().unwrap(), "-b", "task"],
);
own_gitdir_grants(&wt);
let OwnBranchRefMove {
old_tip, identity, ..
} = own_branch_for_commit_ref_move(&wt).unwrap();
detach_own_head(&identity, &old_tip).unwrap();
std::fs::write(wt.join("f.txt"), "hi").unwrap();
git(&wt, &["add", "f.txt"]);
git(&wt, &["commit", "-q", "-m", "task work"]);
let candidate = detached_commit_candidate(&identity, &old_tip)
.expect("verify/read must succeed")
.expect("a real commit was made and must be reported");
assert_eq!(candidate, git_output(&wt, &["rev-parse", "HEAD"]));
assert_eq!(
git_output(&main, &["rev-parse", "refs/heads/task"]),
old_tip,
"reporting the candidate must not itself move the branch ref"
);
}
#[test]
fn detached_head_guard_never_publishes_on_drop_but_reattaches_and_records_the_candidate() {
let _settings = crate::test_guard::GlobalSettingsGuard::acquire();
let journal_dir = tempfile::tempdir().unwrap();
let journal_path = journal_dir.path().join("events.jsonl");
crate::process_env::set_var(
crate::event_journal::JOURNAL_PATH_ENV,
journal_path.to_str().unwrap(),
);
let root = tempfile::tempdir().unwrap();
let root_path = root.path().canonicalize().unwrap();
let main = root_path.join("main");
std::fs::create_dir(&main).unwrap();
init_repo(&main);
let wt = root_path.join("wt");
git(
&main,
&["worktree", "add", "-q", wt.to_str().unwrap(), "-b", "task"],
);
own_gitdir_grants(&wt);
let OwnBranchRefMove {
branch,
old_tip,
identity,
} = own_branch_for_commit_ref_move(&wt).unwrap();
detach_own_head(&identity, &old_tip).unwrap();
let candidate_oid;
{
let _guard = DetachedHeadGuard::new(identity.clone(), branch.clone(), old_tip.clone());
std::fs::write(wt.join("f.txt"), "hi").unwrap();
git(&wt, &["add", "f.txt"]);
git(&wt, &["commit", "-q", "-m", "task work"]);
candidate_oid = git_output(&wt, &["rev-parse", "HEAD"]);
}
assert_eq!(
git_output(&wt, &["symbolic-ref", "HEAD"]),
"refs/heads/task",
"a cancelled dispatch must not leave the worktree stuck detached"
);
assert_eq!(
git_output(&main, &["rev-parse", "refs/heads/task"]),
old_tip,
"a cancelled dispatch must NEVER publish — there is no confirmed outcome to publish"
);
let journal = std::fs::read_to_string(&journal_path)
.expect("Drop must durably record the stranded commit, not merely swallow it");
assert!(
journal.contains(&candidate_oid) && journal.contains("orphaned-commit"),
"the stranded commit's oid must be recoverable from the event journal: {journal}"
);
}
#[tokio::test]
async fn detached_head_guard_recovers_when_an_unleased_future_is_cancelled() {
let _settings = crate::test_guard::GlobalSettingsGuard::acquire();
let journal_dir = tempfile::tempdir().unwrap();
let journal_path = journal_dir.path().join("events.jsonl");
crate::process_env::set_var(
crate::event_journal::JOURNAL_PATH_ENV,
journal_path.to_str().unwrap(),
);
let root = tempfile::tempdir().unwrap();
let root_path = root.path().canonicalize().unwrap();
let main = root_path.join("main");
std::fs::create_dir(&main).unwrap();
init_repo(&main);
let wt = root_path.join("wt");
git(
&main,
&["worktree", "add", "-q", wt.to_str().unwrap(), "-b", "task"],
);
own_gitdir_grants(&wt);
let OwnBranchRefMove {
branch,
old_tip,
identity,
} = own_branch_for_commit_ref_move(&wt).unwrap();
detach_own_head(&identity, &old_tip).unwrap();
std::fs::write(wt.join("f.txt"), "hi").unwrap();
git(&wt, &["add", "f.txt"]);
git(&wt, &["commit", "-q", "-m", "task work"]);
let candidate_oid = git_output(&wt, &["rev-parse", "HEAD"]);
let dispatch = async {
let _guard = DetachedHeadGuard::new(identity.clone(), branch.clone(), old_tip.clone());
std::future::pending::<()>().await;
};
tokio::select! {
biased;
() = dispatch => panic!("the pending dispatch must never complete"),
() = std::future::ready(()) => {}, }
assert_eq!(
git_output(&wt, &["symbolic-ref", "HEAD"]),
"refs/heads/task",
"a real tokio cancellation must still reattach HEAD"
);
assert_eq!(
git_output(&main, &["rev-parse", "refs/heads/task"]),
old_tip,
"a real tokio cancellation must NEVER publish"
);
let journal = std::fs::read_to_string(&journal_path)
.expect("a real tokio cancellation must still durably record the candidate");
assert!(journal.contains(&candidate_oid) && journal.contains("orphaned-commit"));
}
#[test]
fn detached_commit_candidate_fails_closed_on_a_torn_orphaned_head() {
let _settings = crate::test_guard::GlobalSettingsGuard::acquire();
let root = tempfile::tempdir().unwrap();
let root_path = root.path().canonicalize().unwrap();
let main = root_path.join("main");
std::fs::create_dir(&main).unwrap();
init_repo(&main);
let wt = root_path.join("wt");
git(
&main,
&["worktree", "add", "-q", wt.to_str().unwrap(), "-b", "task"],
);
own_gitdir_grants(&wt);
let OwnBranchRefMove {
old_tip, identity, ..
} = own_branch_for_commit_ref_move(&wt).unwrap();
detach_own_head(&identity, &old_tip).unwrap();
let real_commit_oid = {
git(&wt, &["commit", "-q", "--allow-empty", "-m", "x"]);
git_output(&wt, &["rev-parse", "HEAD"])
};
let wt_name = wt.file_name().unwrap();
let head_path = main
.join(".git")
.join("worktrees")
.join(wt_name)
.join("HEAD");
std::fs::write(head_path, format!("{real_commit_oid}deadbeef")).unwrap();
let result = detached_commit_candidate(&identity, &old_tip);
assert!(
result.is_err(),
"a torn HEAD pointing at a nonexistent object must fail closed, not be reported: {result:?}"
);
assert_eq!(
git_output(&main, &["rev-parse", "refs/heads/task"]),
old_tip,
"failing closed must not itself move the branch ref"
);
}
#[test]
fn detached_commit_candidate_reports_a_real_completed_commit_object() {
let root = tempfile::tempdir().unwrap();
let root_path = root.path().canonicalize().unwrap();
let main = root_path.join("main");
std::fs::create_dir(&main).unwrap();
init_repo(&main);
let wt = root_path.join("wt");
git(
&main,
&["worktree", "add", "-q", wt.to_str().unwrap(), "-b", "task"],
);
own_gitdir_grants(&wt);
let OwnBranchRefMove {
old_tip, identity, ..
} = own_branch_for_commit_ref_move(&wt).unwrap();
detach_own_head(&identity, &old_tip).unwrap();
git(&wt, &["commit", "-q", "--allow-empty", "-m", "x"]);
let real = git_output(&wt, &["rev-parse", "HEAD"]);
let candidate = detached_commit_candidate(&identity, &old_tip)
.expect("a real, completed commit must not fail closed");
assert_eq!(candidate, Some(real));
}
#[test]
fn bound_identity_resists_a_gitlink_rewrite_after_the_detached_commit() {
let root = tempfile::tempdir().unwrap();
let root_path = root.path().canonicalize().unwrap();
let main = root_path.join("main");
std::fs::create_dir(&main).unwrap();
init_repo(&main);
let wt = root_path.join("wt");
git(
&main,
&["worktree", "add", "-q", wt.to_str().unwrap(), "-b", "task"],
);
own_gitdir_grants(&wt);
let OwnBranchRefMove {
branch,
old_tip,
identity,
} = own_branch_for_commit_ref_move(&wt).unwrap();
detach_own_head(&identity, &old_tip).unwrap();
std::fs::write(wt.join("f.txt"), "hi").unwrap();
git(&wt, &["add", "f.txt"]);
git(&wt, &["commit", "-q", "-m", "task work"]);
let main_head_before = std::fs::read(main.join(".git").join("HEAD")).unwrap();
let main_task_ref_before =
std::fs::read(main.join(".git").join("refs/heads/task")).unwrap();
std::fs::write(
wt.join(".git"),
format!("gitdir: {}\n", main.join(".git").display()),
)
.unwrap();
let redirected = Command::new("git")
.args(["rev-parse", "--absolute-git-dir"])
.current_dir(&wt)
.output()
.unwrap();
assert_eq!(
PathBuf::from(String::from_utf8_lossy(&redirected.stdout).trim())
.canonicalize()
.unwrap(),
main.join(".git").canonicalize().unwrap(),
"the rewrite must actually redirect a fresh rev-parse — proving the attack is real"
);
let new_oid = advance_own_branch_ref(&identity, &branch, &old_tip)
.expect("the bound identity must still publish correctly on wt");
reattach_own_head(&identity, &branch).unwrap();
assert_eq!(
std::fs::read(main.join(".git").join("HEAD")).unwrap(),
main_head_before,
"the primary checkout's own HEAD must be untouched by the rewrite"
);
assert_ne!(
std::fs::read(main.join(".git").join("refs/heads/task")).unwrap(),
main_task_ref_before,
"wt's task branch DOES still live in the (unrewritten) common dir and must advance"
);
assert_eq!(
git_output(&main, &["rev-parse", "refs/heads/task"]),
new_oid,
"the commit must land on task's real ref, resolved via the bound identity"
);
}
#[test]
fn bound_identity_resists_a_commondir_rewrite_after_the_detached_commit() {
let root = tempfile::tempdir().unwrap();
let root_path = root.path().canonicalize().unwrap();
let main = root_path.join("main");
std::fs::create_dir(&main).unwrap();
init_repo(&main);
let decoy = root_path.join("decoy");
std::fs::create_dir(&decoy).unwrap();
init_repo(&decoy);
let wt = root_path.join("wt");
git(
&main,
&["worktree", "add", "-q", wt.to_str().unwrap(), "-b", "task"],
);
own_gitdir_grants(&wt);
let OwnBranchRefMove {
branch,
old_tip,
identity,
} = own_branch_for_commit_ref_move(&wt).unwrap();
detach_own_head(&identity, &old_tip).unwrap();
std::fs::write(wt.join("f.txt"), "hi").unwrap();
git(&wt, &["add", "f.txt"]);
git(&wt, &["commit", "-q", "-m", "task work"]);
let decoy_head_before = std::fs::read(decoy.join(".git").join("HEAD")).unwrap();
let admin_dir = &identity.git_dir;
std::fs::write(
admin_dir.join("commondir"),
format!("{}\n", decoy.join(".git").display()),
)
.unwrap();
let new_oid = advance_own_branch_ref(&identity, &branch, &old_tip)
.expect("ref resolution must bypass the rewritten commondir file entirely");
reattach_own_head(&identity, &branch).unwrap();
assert_eq!(
std::fs::read(decoy.join(".git").join("HEAD")).unwrap(),
decoy_head_before,
"the decoy repository named by the rewritten commondir must be untouched"
);
assert_eq!(
git_output(&main, &["rev-parse", "refs/heads/task"]),
new_oid,
"the commit must land on task's real ref in the ORIGINAL common dir"
);
}
#[test]
fn bound_identity_refuses_once_the_admin_dir_itself_is_replaced() {
let root = tempfile::tempdir().unwrap();
let root_path = root.path().canonicalize().unwrap();
let main = root_path.join("main");
std::fs::create_dir(&main).unwrap();
init_repo(&main);
let wt = root_path.join("wt");
git(
&main,
&["worktree", "add", "-q", wt.to_str().unwrap(), "-b", "task"],
);
own_gitdir_grants(&wt);
let OwnBranchRefMove {
branch,
old_tip,
identity,
} = own_branch_for_commit_ref_move(&wt).unwrap();
detach_own_head(&identity, &old_tip).unwrap();
std::fs::write(wt.join("f.txt"), "hi").unwrap();
git(&wt, &["add", "f.txt"]);
git(&wt, &["commit", "-q", "-m", "task work"]);
let admin_dir = identity.git_dir.clone();
std::fs::rename(&admin_dir, root_path.join("admin-dir-moved-aside")).unwrap();
std::fs::create_dir(&admin_dir).unwrap();
let refusal = advance_own_branch_ref(&identity, &branch, &old_tip)
.expect_err("a replaced admin dir must refuse, not publish through the new object");
assert!(refusal.reason.contains("no longer names the directory"));
assert_eq!(
git_output(&main, &["rev-parse", "refs/heads/task"]),
old_tip,
"a refused advance must not move the branch ref"
);
let reattach_refusal = reattach_own_head(&identity, &branch)
.expect_err("reattach must also refuse against the replaced admin dir");
assert!(reattach_refusal.contains("no longer names the directory"));
}
#[test]
fn bind_derives_the_write_handle_from_the_held_fd_not_a_second_pathname_open() {
let root = tempfile::tempdir().unwrap();
let root_path = root.path().canonicalize().unwrap();
let main = root_path.join("main");
std::fs::create_dir(&main).unwrap();
init_repo(&main);
let wt = root_path.join("wt");
git(
&main,
&["worktree", "add", "-q", wt.to_str().unwrap(), "-b", "task"],
);
own_gitdir_grants(&wt);
let old_tip = git_output(&main, &["rev-parse", "refs/heads/task"]);
let (common_dir, git_dir) = verified_identity(&wt).unwrap();
let git_dir_rel_to_common = git_dir.strip_prefix(&common_dir).unwrap().to_path_buf();
let moved_aside = root_path.join("common-moved-aside-at-bind-time");
let swap = || {
std::fs::rename(&common_dir, &moved_aside).unwrap();
std::fs::create_dir(&common_dir).unwrap(); std::fs::create_dir_all(common_dir.join(&git_dir_rel_to_common)).unwrap();
};
let identity = BoundGitIdentity::bind_seamed(common_dir.clone(), git_dir, &swap).expect(
"acquire already completed before the swap; a decoy directory still opens fine",
);
let decoy_relocated = root_path.join("common-decoy-after-restore");
std::fs::rename(&common_dir, &decoy_relocated).unwrap();
std::fs::rename(&moved_aside, &common_dir).unwrap();
identity
.verify()
.expect("the pathname is restored now — verify only ever checked common_root");
let branch = "task".to_string();
detach_own_head(&identity, &old_tip).unwrap();
std::fs::write(wt.join("f.txt"), "hi").unwrap();
git(&wt, &["add", "f.txt"]);
git(&wt, &["commit", "-q", "-m", "task work"]);
let new_oid = git_output(&wt, &["rev-parse", "HEAD"]);
let published = advance_own_branch_ref(&identity, &branch, &old_tip).expect(
"the write must land in the REAL object, derived by dup — never a decoy that could \
only ever have been bound by an independent second open",
);
assert_eq!(published, new_oid);
assert_eq!(
git_output(&main, &["rev-parse", "refs/heads/task"]),
new_oid,
"the real, restored common dir must show the published commit"
);
}
#[test]
fn advance_own_branch_ref_creates_missing_parents_for_a_packed_only_nested_branch() {
let root = tempfile::tempdir().unwrap();
let root_path = root.path().canonicalize().unwrap();
let main = root_path.join("main");
std::fs::create_dir(&main).unwrap();
init_repo(&main);
let wt = root_path.join("wt");
git(
&main,
&[
"worktree",
"add",
"-q",
wt.to_str().unwrap(),
"-b",
"feature/foo",
],
);
git(&main, &["pack-refs", "--all"]);
let common_dir = git_dirs(&wt).unwrap().0;
assert!(
!common_dir.join("refs/heads/feature").exists(),
"packing must leave no loose directory behind — otherwise this test exercises \
nothing new"
);
own_gitdir_grants(&wt);
let OwnBranchRefMove {
branch,
old_tip,
identity,
} = own_branch_for_commit_ref_move(&wt).unwrap();
assert_eq!(branch, "feature/foo");
detach_own_head(&identity, &old_tip).unwrap();
std::fs::write(wt.join("f.txt"), "hi").unwrap();
git(&wt, &["add", "f.txt"]);
git(&wt, &["commit", "-q", "-m", "task work"]);
let new_oid = git_output(&wt, &["rev-parse", "HEAD"]);
let published = advance_own_branch_ref(&identity, &branch, &old_tip).expect(
"a packed-only nested branch must still publish — the lock's parent is created on \
demand",
);
assert_eq!(published, new_oid);
assert_eq!(
git_output(&main, &["rev-parse", "refs/heads/feature/foo"]),
new_oid,
"the new loose ref write must take effect over the stale packed-refs entry"
);
assert!(
std::fs::read_to_string(common_dir.join("logs/refs/heads/feature/foo"))
.unwrap()
.contains(&new_oid),
"the branch's own reflog must gain a first entry despite no pre-existing \
logs/refs/heads/feature directory either"
);
}
#[test]
fn advance_branch_ref_natively_cleans_up_and_reports_honestly_on_a_rename_failure() {
let root = tempfile::tempdir().unwrap();
let root_path = root.path().canonicalize().unwrap();
let main = root_path.join("main");
std::fs::create_dir(&main).unwrap();
init_repo(&main);
let wt = root_path.join("wt");
git(
&main,
&["worktree", "add", "-q", wt.to_str().unwrap(), "-b", "task"],
);
own_gitdir_grants(&wt);
let OwnBranchRefMove {
branch,
old_tip,
identity,
} = own_branch_for_commit_ref_move(&wt).unwrap();
detach_own_head(&identity, &old_tip).unwrap();
std::fs::write(wt.join("f.txt"), "hi").unwrap();
git(&wt, &["add", "f.txt"]);
git(&wt, &["commit", "-q", "-m", "task work"]);
let new_oid = git_output(&wt, &["rev-parse", "HEAD"]);
let common_dir = identity.common_dir.clone();
let ref_path = common_dir.join(format!("refs/heads/{branch}"));
let lock_path_abs = common_dir.join(format!("refs/heads/{branch}.lock"));
let reflog_path = common_dir.join(format!("logs/refs/heads/{branch}"));
let force_rename_failure = || {
std::fs::remove_file(&ref_path).unwrap();
std::fs::create_dir(&ref_path).unwrap();
};
let refusal = advance_branch_ref_natively_seamed(
&identity,
&branch,
Some(&old_tip),
&new_oid,
"commit: task work",
&force_rename_failure,
)
.expect_err("a rename failure must refuse, not silently succeed");
assert!(
refusal.contains("already appended"),
"the refusal must say the reflog line landed even though the ref never moved: \
{refusal}"
);
assert!(
!lock_path_abs.exists(),
"the owned .lock file must be cleaned up even when the failure happens AFTER the \
reflog append, not just on an earlier failure"
);
assert!(
std::fs::read_to_string(&reflog_path)
.unwrap()
.contains(&new_oid),
"the reflog append genuinely landed before the rename failed — the test's own \
setup, not a belief"
);
std::fs::remove_dir(&ref_path).unwrap();
}
#[test]
fn detached_commit_candidate_propagates_a_verify_failure_instead_of_reporting_none() {
let root = tempfile::tempdir().unwrap();
let root_path = root.path().canonicalize().unwrap();
let main = root_path.join("main");
std::fs::create_dir(&main).unwrap();
init_repo(&main);
let wt = root_path.join("wt");
git(
&main,
&["worktree", "add", "-q", wt.to_str().unwrap(), "-b", "task"],
);
own_gitdir_grants(&wt);
let OwnBranchRefMove {
old_tip, identity, ..
} = own_branch_for_commit_ref_move(&wt).unwrap();
detach_own_head(&identity, &old_tip).unwrap();
std::fs::write(wt.join("f.txt"), "hi").unwrap();
git(&wt, &["add", "f.txt"]);
git(&wt, &["commit", "-q", "-m", "task work"]);
let admin_dir = identity.git_dir.clone();
std::fs::rename(&admin_dir, root_path.join("admin-dir-moved-aside")).unwrap();
std::fs::create_dir(&admin_dir).unwrap();
let result = detached_commit_candidate(&identity, &old_tip);
assert!(
result.is_err(),
"a verify() failure must be a reported Err, never the same Ok(None) as 'nothing to \
report': {result:?}"
);
}
}
#[cfg(test)]
mod governed_push_config_tests {
use super::*;
#[test]
fn https_ssh_and_scp_like_urls_resolve_the_right_host() {
assert_eq!(
push_url_host("https://github.com/o/r.git").as_deref(),
Ok("github.com")
);
assert_eq!(
push_url_host("ssh://git@github.com:22/o/r.git").as_deref(),
Ok("github.com")
);
assert_eq!(
push_url_host("git@github.com:o/r.git").as_deref(),
Ok("github.com")
);
}
#[test]
fn file_and_ext_transports_are_refused() {
assert!(push_url_host("file:///home/op/other-repo").is_err());
assert!(push_url_host("ext::sh -c evil").is_err());
assert!(push_url_host("git://github.com/o/r.git").is_err());
}
#[test]
fn https_userinfo_host_confusion_is_refused() {
assert!(push_url_host("https://github.com@evil.example/o/r.git").is_err());
assert!(push_url_host("https://user:token@github.com/o/r.git").is_err());
}
#[test]
fn local_path_containing_at_and_colon_is_not_scp_syntax() {
assert!(push_url_host("/tmp/repo@github.com:target").is_err());
assert!(push_url_host("./relative/repo@host:target").is_err());
}
#[test]
fn double_colon_helper_syntax_is_refused() {
assert!(push_url_host("ext::sh -c 'evil'@host:path").is_err());
}
#[test]
fn github_owner_repo_parses_https_and_scp_like() {
assert_eq!(
github_owner_repo("https://github.com/Org/Repo.git"),
Some(("Org".to_string(), "Repo".to_string()))
);
assert_eq!(
github_owner_repo("git@github.com:Org/Repo.git"),
Some(("Org".to_string(), "Repo".to_string()))
);
assert_eq!(
github_owner_repo("https://gitlab.example.com/Org/Repo.git"),
None,
"non-github hosts refuse the gh broker rather than guessing --repo"
);
}
}
#[cfg(all(test, unix))]
mod governed_push_process_tests {
use super::*;
fn git(dir: &Path, args: &[&str]) {
let status = std::process::Command::new("git")
.args(args)
.current_dir(dir)
.env("GIT_AUTHOR_NAME", "t")
.env("GIT_AUTHOR_EMAIL", "t@example.invalid")
.env("GIT_COMMITTER_NAME", "t")
.env("GIT_COMMITTER_EMAIL", "t@example.invalid")
.status()
.expect("git invocation");
assert!(status.success(), "git {args:?} failed");
}
#[test]
fn resolve_remote_url_matches_what_git_would_actually_push_to() {
let repo = tempfile::tempdir().unwrap();
git(repo.path(), &["init", "-q"]);
git(
repo.path(),
&["remote", "add", "origin", "https://github.com/o/r.git"],
);
assert_eq!(
resolve_remote_url(repo.path(), "origin").unwrap(),
"https://github.com/o/r.git"
);
}
#[test]
fn resolve_remote_url_errors_on_missing_remote() {
let repo = tempfile::tempdir().unwrap();
git(repo.path(), &["init", "-q"]);
assert!(resolve_remote_url(repo.path(), "origin").is_err());
}
}
#[cfg(all(test, any(target_os = "linux", target_os = "macos")))]
#[path = "git_hardening/commit_view_tests.rs"]
mod commit_view_tests;