#[cfg(unix)]
use std::fs::File;
use std::{
cell::Cell,
collections::BTreeSet,
path::{Path, PathBuf},
rc::Rc,
};
use objects::{
error::{HeddleError, Result as HeddleResult},
object::{ChangeId, ThreadName},
};
use oplog::{IsolationKey, OpRecord};
use refs::RefExpectation;
use repo::{
CheckoutMaterialization, Thread, ThreadManager, ThreadMode,
atomic::{AtomicMutation, StagedCommit, Tx},
};
use super::{
mount_lifecycle::{self, MountOwnership},
worktree_cmd::{helpers::write_isolated_checkout, hydrate, shared_target::write_cargo_config},
};
fn apply_error(err: anyhow::Error) -> HeddleError {
match err.downcast::<HeddleError>() {
Ok(heddle) => heddle,
Err(err) => match err
.downcast_ref::<std::io::Error>()
.map(std::io::Error::kind)
{
Some(kind) => HeddleError::Io(std::io::Error::new(kind, format!("{err:#}"))),
None => HeddleError::Conflict(format!("{err:#}")),
},
}
}
#[cfg(test)]
#[derive(Clone, Copy)]
pub(crate) enum StartFault {
Materialize,
HydrateNth(usize),
}
#[cfg(test)]
thread_local! {
static START_FAULT: std::cell::Cell<Option<StartFault>> = const { std::cell::Cell::new(None) };
}
#[cfg(test)]
pub(crate) fn with_start_fault<T>(fault: StartFault, body: impl FnOnce() -> T) -> T {
START_FAULT.with(|f| f.set(Some(fault)));
let out = body();
START_FAULT.with(|f| f.set(None));
out
}
#[cfg(test)]
fn materialize_fault_trips() -> bool {
START_FAULT.with(|f| match f.get() {
Some(StartFault::Materialize) => {
f.set(None);
true
}
_ => false,
})
}
#[cfg(test)]
fn hydrate_fault_trips() -> bool {
START_FAULT.with(|f| match f.get() {
Some(StartFault::HydrateNth(0)) => {
f.set(None);
true
}
Some(StartFault::HydrateNth(n)) => {
f.set(Some(StartFault::HydrateNth(n - 1)));
false
}
_ => false,
})
}
#[cfg(all(test, unix))]
#[derive(Clone, Copy, PartialEq, Eq)]
enum TargetSwapPoint {
BeforeManifest,
BeforePreserveIgnores,
}
#[cfg(all(test, unix))]
#[derive(Clone)]
struct TargetSwapFault {
point: TargetSwapPoint,
symlink_target: PathBuf,
}
#[cfg(all(test, unix))]
thread_local! {
static START_TARGET_SWAP: std::cell::RefCell<Option<TargetSwapFault>> =
const { std::cell::RefCell::new(None) };
}
#[cfg(all(test, unix))]
fn with_start_target_swap<T>(
point: TargetSwapPoint,
symlink_target: PathBuf,
body: impl FnOnce() -> T,
) -> T {
START_TARGET_SWAP.with(|f| {
*f.borrow_mut() = Some(TargetSwapFault {
point,
symlink_target,
})
});
let out = body();
START_TARGET_SWAP.with(|f| *f.borrow_mut() = None);
out
}
#[cfg(all(test, unix))]
fn maybe_swap_target_leaf(point: TargetSwapPoint, abs_path: &Path) -> HeddleResult<()> {
let fault = START_TARGET_SWAP.with(|f| {
let mut fault = f.borrow_mut();
if fault.as_ref().is_some_and(|fault| fault.point == point) {
fault.take()
} else {
None
}
});
let Some(fault) = fault else {
return Ok(());
};
let moved_name = abs_path
.file_name()
.map(|name| format!("{}.claimed-original", name.to_string_lossy()))
.unwrap_or_else(|| "claimed-original".to_string());
let moved = abs_path.with_file_name(moved_name);
std::fs::rename(abs_path, moved).map_err(HeddleError::from)?;
std::os::unix::fs::symlink(&fault.symlink_target, abs_path).map_err(HeddleError::from)
}
#[derive(Clone, PartialEq, Eq, Debug)]
pub(crate) enum TargetDirKind {
Created,
AdoptedEmpty,
}
#[derive(Clone, Debug)]
pub(crate) enum TargetDir {
Created(TargetDirHandle),
AdoptedEmpty(TargetDirHandle),
}
impl TargetDir {
fn created(handle: TargetDirHandle) -> Self {
Self::Created(handle)
}
fn adopted_empty(handle: TargetDirHandle) -> Self {
Self::AdoptedEmpty(handle)
}
fn kind(&self) -> TargetDirKind {
match self {
Self::Created(_) => TargetDirKind::Created,
Self::AdoptedEmpty(_) => TargetDirKind::AdoptedEmpty,
}
}
fn handle(&self) -> &TargetDirHandle {
match self {
Self::Created(handle) | Self::AdoptedEmpty(handle) => handle,
}
}
}
#[derive(Clone)]
pub(crate) struct TargetDirHandle {
#[cfg(unix)]
dir: Rc<File>,
#[cfg(unix)]
dev: u64,
#[cfg(unix)]
ino: u64,
#[cfg(unix)]
path: PathBuf,
#[cfg(not(unix))]
path: PathBuf,
}
impl std::fmt::Debug for TargetDirHandle {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
#[cfg(unix)]
{
f.debug_struct("TargetDirHandle")
.field("dev", &self.dev)
.field("ino", &self.ino)
.finish_non_exhaustive()
}
#[cfg(not(unix))]
{
f.debug_struct("TargetDirHandle")
.field("path", &self.path)
.finish()
}
}
}
impl TargetDirHandle {
fn open(abs_path: &Path) -> std::io::Result<Self> {
#[cfg(unix)]
{
use std::os::unix::fs::{MetadataExt, OpenOptionsExt};
let dir = std::fs::OpenOptions::new()
.read(true)
.custom_flags(libc::O_CLOEXEC | libc::O_DIRECTORY | libc::O_NOFOLLOW)
.open(abs_path)?;
let metadata = dir.metadata()?;
if !metadata.is_dir() {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
"target is not a directory",
));
}
Ok(Self {
dir: Rc::new(dir),
dev: metadata.dev(),
ino: metadata.ino(),
path: abs_path.to_path_buf(),
})
}
#[cfg(not(unix))]
{
let metadata = std::fs::symlink_metadata(abs_path)?;
if !metadata.is_dir() {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
"target is not a directory",
));
}
Ok(Self {
path: abs_path.to_path_buf(),
})
}
}
#[cfg(unix)]
fn fd_traversal_path(&self) -> Option<PathBuf> {
use std::os::fd::AsRawFd;
Path::new("/proc/self/fd").is_dir().then(|| {
let fd = self.dir.as_raw_fd();
PathBuf::from(format!("/proc/self/fd/{fd}"))
})
}
fn io_path_if_current(&self, abs_path: &Path) -> std::io::Result<Option<PathBuf>> {
#[cfg(unix)]
{
if let Some(path) = self.fd_traversal_path() {
Ok(Some(path))
} else {
match self.same_identity_at_path(abs_path) {
Ok(true) => Ok(Some(self.path.clone())),
Ok(false) => Ok(None),
Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(None),
Err(err) if err.kind() == std::io::ErrorKind::NotADirectory => Ok(None),
Err(err) => Err(err),
}
}
}
#[cfg(not(unix))]
{
Ok((abs_path == self.path).then(|| self.path.clone()))
}
}
fn same_identity_at_path(&self, abs_path: &Path) -> std::io::Result<bool> {
#[cfg(unix)]
{
let current = Self::open(abs_path)?;
Ok(current.dev == self.dev && current.ino == self.ino)
}
#[cfg(not(unix))]
{
Ok(abs_path == self.path)
}
}
}
fn claim_from_cell(target_claim: &Cell<Option<TargetDir>>) -> Option<TargetDir> {
let claim = target_claim.take();
let snapshot = claim.clone();
target_claim.set(claim);
snapshot
}
fn outcome_from_cell(
cell: &Cell<Option<CheckoutMaterialization>>,
) -> Option<CheckoutMaterialization> {
let outcome = cell.take();
let snapshot = outcome.clone();
cell.set(outcome);
snapshot
}
fn adopt_existing_empty_dir(abs_path: &Path) -> HeddleResult<TargetDirHandle> {
let handle = TargetDirHandle::open(abs_path)
.map_err(|_| target_dir_shape_refusal(abs_path, &target_dir_shape_reason(abs_path)))?;
let io_path = handle
.io_path_if_current(abs_path)
.map_err(HeddleError::from)?
.ok_or_else(|| target_dir_shape_refusal(abs_path, "changed since it was claimed"))?;
if std::fs::read_dir(io_path)
.map_err(HeddleError::from)?
.next()
.transpose()
.map_err(HeddleError::from)?
.is_some()
{
return Err(target_dir_shape_refusal(abs_path, "is not empty"));
}
Ok(handle)
}
fn target_dir_shape_reason(abs_path: &Path) -> String {
match std::fs::symlink_metadata(abs_path) {
Ok(meta) if meta.file_type().is_symlink() => "is a symlink".to_string(),
Ok(meta) if !meta.is_dir() => "is not a directory".to_string(),
Ok(_) => "could not be opened as a real directory".to_string(),
Err(err) if err.kind() == std::io::ErrorKind::NotFound => "does not exist".to_string(),
Err(err) => format!("could not be inspected ({err})"),
}
}
fn target_dir_shape_refusal(abs_path: &Path, reason: &str) -> HeddleError {
HeddleError::Conflict(format!(
"refusing to start: worktree target '{}' {} — not a real empty directory heddle can \
own. A concurrent process or a pre-existing filesystem object occupies the target, and \
heddle will not write the isolated checkout through it. Choose an empty real directory \
for `--path`, or let heddle create a managed materialized checkout.",
abs_path.display(),
reason,
))
}
fn clear_dir_contents(dir: &Path) -> std::io::Result<()> {
for entry in std::fs::read_dir(dir)? {
let entry = entry?;
let path = entry.path();
if entry.file_type()?.is_dir() {
std::fs::remove_dir_all(&path)?;
} else {
std::fs::remove_file(&path)?;
}
}
Ok(())
}
fn clear_claimed_dir_contents(abs_path: &Path, claim: &TargetDir) -> HeddleResult<()> {
let Some(io_path) = claim
.handle()
.io_path_if_current(abs_path)
.map_err(HeddleError::from)?
else {
return Ok(());
};
match clear_dir_contents(&io_path) {
Ok(()) => Ok(()),
Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(err) if err.kind() == std::io::ErrorKind::NotADirectory => Ok(()),
Err(err) => Err(HeddleError::from(err)),
}
}
fn rewind_checkout(abs_path: &Path, claim: Option<TargetDir>) -> HeddleResult<()> {
let Some(claim) = claim else {
return Ok(());
};
match claim.kind() {
TargetDirKind::Created => {
clear_claimed_dir_contents(abs_path, &claim)?;
remove_claimed_created_dir_if_still_at_path(abs_path, &claim)
}
TargetDirKind::AdoptedEmpty => clear_claimed_dir_contents(abs_path, &claim),
}
}
fn remove_claimed_created_dir_if_still_at_path(
abs_path: &Path,
claim: &TargetDir,
) -> HeddleResult<()> {
match claim.handle().same_identity_at_path(abs_path) {
Ok(true) => {}
Ok(false) => return Ok(()),
Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(()),
Err(err) if err.kind() == std::io::ErrorKind::NotADirectory => return Ok(()),
Err(err) => return Err(HeddleError::from(err)),
}
match std::fs::remove_dir(abs_path) {
Ok(()) => Ok(()),
Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(err) if err.kind() == std::io::ErrorKind::DirectoryNotEmpty => Ok(()),
Err(err) => Err(HeddleError::from(err)),
}
}
fn claimed_worktree_path(claim: Option<TargetDir>, abs_path: &Path) -> HeddleResult<PathBuf> {
match claim {
Some(claim) => match claim.handle().same_identity_at_path(abs_path) {
Ok(true) => claim
.handle()
.io_path_if_current(abs_path)
.map_err(HeddleError::from)?
.ok_or_else(|| target_dir_shape_refusal(abs_path, "changed since it was claimed")),
Ok(false) => Err(target_dir_shape_refusal(
abs_path,
"changed since it was claimed",
)),
Err(err) => Err(target_dir_shape_refusal(
abs_path,
&format!("changed since it was claimed ({err})"),
)),
},
None => Err(target_dir_shape_refusal(abs_path, "was not established")),
}
}
fn create_target_dir(abs_path: &Path, plan_created: bool) -> HeddleResult<TargetDir> {
if !plan_created {
return adopt_existing_empty_dir(abs_path).map(TargetDir::adopted_empty);
}
if let Some(parent) = abs_path.parent() {
std::fs::create_dir_all(parent).map_err(HeddleError::from)?;
}
match std::fs::create_dir(abs_path) {
Ok(()) => TargetDirHandle::open(abs_path)
.map(TargetDir::created)
.map_err(HeddleError::from),
Err(err) if err.kind() == std::io::ErrorKind::AlreadyExists => {
adopt_existing_empty_dir(abs_path).map(TargetDir::adopted_empty)
}
Err(err) => Err(HeddleError::from(err)),
}
}
fn remove_self_created_dir(abs_path: &Path, claim: Option<TargetDir>) -> HeddleResult<()> {
match claim {
Some(claim) if claim.kind() == TargetDirKind::Created => {
remove_claimed_created_dir_if_still_at_path(abs_path, &claim)
}
_ => Ok(()),
}
}
pub(crate) struct StartThread {
pub transaction_id: String,
pub name: String,
pub base_state: ChangeId,
pub existing_thread_state: Option<ChangeId>,
pub thread_mode: ThreadMode,
pub abs_path: PathBuf,
pub target_dir_created: bool,
pub shared_target_dir: Option<PathBuf>,
pub hydrate: bool,
pub mount_ownership: MountOwnership,
pub record: Thread,
}
pub(crate) struct StartThreadOutput {
pub linked: Vec<String>,
pub fskit_readiness: Option<mount_lifecycle::FskitReadinessReport>,
}
impl StartThread {
fn stage_target_dir(
&self,
tx: &mut Tx<'_>,
target_claim: Rc<Cell<Option<TargetDir>>>,
) -> HeddleResult<()> {
let abs = self.abs_path.clone();
let rewind_abs = self.abs_path.clone();
let plan_created = self.target_dir_created;
let fwd_claim = Rc::clone(&target_claim);
tx.step(
move || {
let established = create_target_dir(&abs, plan_created)?;
fwd_claim.set(Some(established));
Ok(())
},
move || remove_self_created_dir(&rewind_abs, claim_from_cell(&target_claim)),
)
}
fn stage_ref(&self, tx: &mut Tx<'_>, oplog: &mut Vec<OpRecord>) -> HeddleResult<()> {
let repo = tx.repo();
let base_state = self.base_state;
match self.existing_thread_state {
Some(existing) => {
let fwd_name = ThreadName::new(&self.name);
let inv_name = ThreadName::new(&self.name);
tx.step(
move || {
repo.refs().set_thread_cas(
&fwd_name,
RefExpectation::Value(existing),
&base_state,
)
},
move || {
repo.cas_guarded_thread_ref_rollback(&inv_name, base_state, Some(existing))
},
)?;
}
None => {
let fwd_name = ThreadName::new(&self.name);
let inv_name = ThreadName::new(&self.name);
tx.step(
move || {
repo.refs()
.set_thread_cas(&fwd_name, RefExpectation::Missing, &base_state)
},
move || repo.cas_guarded_thread_ref_rollback(&inv_name, base_state, None),
)?;
oplog.push(repo.thread_create_op_record(&self.name, base_state));
}
}
Ok(())
}
fn stage_checkout(
&self,
tx: &mut Tx<'_>,
target_claim: Rc<Cell<Option<TargetDir>>>,
checkout_outcome: Rc<Cell<Option<CheckoutMaterialization>>>,
) -> HeddleResult<()> {
let repo = tx.repo();
let abs = self.abs_path.clone();
let rewind_abs = self.abs_path.clone();
let base_state = self.base_state;
let name = self.name.clone();
let rewind_claim = Rc::clone(&target_claim);
let rewind_repo = tx.repo();
tx.step_nonatomic(
move || Ok(()),
move |()| {
let claim = claim_from_cell(&rewind_claim);
if let Some(claim) = claim.as_ref()
&& let Some(io_path) = claim
.handle()
.io_path_if_current(&rewind_abs)
.map_err(HeddleError::from)?
{
rewind_repo.clear_materialized_root_records(&io_path)?;
}
rewind_checkout(&rewind_abs, claim)
},
move || {
#[cfg(test)]
if materialize_fault_trips() {
return Err(HeddleError::Conflict(
"injected materialize fault".to_string(),
));
}
let checkout_root = claimed_worktree_path(claim_from_cell(&target_claim), &abs)?;
let outcome =
write_isolated_checkout(repo, &checkout_root, &base_state, Some(&name))
.map_err(apply_error)?;
checkout_outcome.set(Some(outcome));
Ok(())
},
)
}
fn stage_manifest(
&self,
tx: &mut Tx<'_>,
target_claim: Rc<Cell<Option<TargetDir>>>,
checkout_outcome: Rc<Cell<Option<CheckoutMaterialization>>>,
) -> HeddleResult<()> {
let repo = tx.repo();
let abs = self.abs_path.clone();
let base_state = self.base_state;
let fwd_name = self.name.clone();
let inv_name = self.name.clone();
let cap_name = self.name.clone();
tx.step_nonatomic(
move || {
let path = repo::thread_manifest::manifest_path(repo.heddle_dir(), &cap_name);
Ok(std::fs::read(&path).ok())
},
move |prior| repo.restore_thread_manifest(&inv_name, prior),
move || {
#[cfg(all(test, unix))]
maybe_swap_target_leaf(TargetSwapPoint::BeforeManifest, &abs)?;
let checkout_root = claimed_worktree_path(claim_from_cell(&target_claim), &abs)?;
match outcome_from_cell(&checkout_outcome) {
Some(CheckoutMaterialization::Withheld { .. }) => repo
.record_withheld_thread_manifest(&fwd_name, &base_state, &checkout_root)
.map(|_| ()),
_ => repo
.record_thread_manifest(&fwd_name, &base_state, &checkout_root)
.map(|_| ()),
}
},
)
}
fn stage_cargo_config(
&self,
tx: &mut Tx<'_>,
dir: &Path,
target_claim: Rc<Cell<Option<TargetDir>>>,
) -> HeddleResult<bool> {
let cap_abs = self.abs_path.clone();
let restore_abs = self.abs_path.clone();
let fwd_abs = self.abs_path.clone();
let dir = dir.to_path_buf();
let cap_claim = Rc::clone(&target_claim);
let restore_claim = Rc::clone(&target_claim);
tx.step_nonatomic(
move || {
let checkout_root = claimed_worktree_path(claim_from_cell(&cap_claim), &cap_abs)?;
Ok(std::fs::read(checkout_root.join(".cargo").join("config.toml")).ok())
},
move |prior| match prior {
Some(bytes) => {
let checkout_root =
claimed_worktree_path(claim_from_cell(&restore_claim), &restore_abs)?;
std::fs::write(checkout_root.join(".cargo").join("config.toml"), bytes)
.map_err(HeddleError::from)
}
None => {
let checkout_root =
claimed_worktree_path(claim_from_cell(&restore_claim), &restore_abs)?;
match std::fs::remove_file(checkout_root.join(".cargo").join("config.toml")) {
Ok(()) => Ok(()),
Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(err) => Err(HeddleError::from(err)),
}
}
},
move || {
let checkout_root =
claimed_worktree_path(claim_from_cell(&target_claim), &fwd_abs)?;
write_cargo_config(&checkout_root, &dir).map_err(apply_error)
},
)
}
fn stage_hydrate(
&self,
tx: &mut Tx<'_>,
target_claim: Rc<Cell<Option<TargetDir>>>,
) -> HeddleResult<Vec<String>> {
let repo = tx.repo();
let sources = hydrate::hydratable_ignored_dirs(repo).map_err(apply_error)?;
let mut linked: Vec<String> = Vec::new();
let checkout_root = claimed_worktree_path(claim_from_cell(&target_claim), &self.abs_path)?;
for source in &sources {
let Some((dest, link_name)) = hydrate::plan_link(&checkout_root, source) else {
continue;
};
let src = source.clone();
let dest_fwd = dest.clone();
let inv_checkout = checkout_root.clone();
let inv_name = link_name.clone();
let err_name = link_name.clone();
tx.step(
move || {
#[cfg(test)]
if hydrate_fault_trips() {
return Err(symlink_unsupported_error(&err_name));
}
hydrate::create_symlink(&src, &dest_fwd)
.map_err(|e| symlink_unsupported_error_from(&err_name, e))
},
move || {
hydrate::unlink_hydrated(&inv_checkout, &inv_name).map_err(HeddleError::from)
},
)?;
linked.push(link_name);
}
if !linked.is_empty() {
let cap_abs = self.abs_path.clone();
let restore_abs = self.abs_path.clone();
let fwd_abs = self.abs_path.clone();
let cap_claim = Rc::clone(&target_claim);
let restore_claim = Rc::clone(&target_claim);
let linked_fwd = linked.clone();
tx.step_nonatomic(
move || {
#[cfg(all(test, unix))]
maybe_swap_target_leaf(TargetSwapPoint::BeforePreserveIgnores, &cap_abs)?;
let checkout_root =
claimed_worktree_path(claim_from_cell(&cap_claim), &cap_abs)?;
let exclude_path = hydrate::hydrate_exclude_path(&checkout_root);
Ok(std::fs::read(&exclude_path).ok())
},
move |prior| {
let checkout_root =
claimed_worktree_path(claim_from_cell(&restore_claim), &restore_abs)?;
let restore_path = hydrate::hydrate_exclude_path(&checkout_root);
restore_ignore_file(&restore_path, prior)
},
move || {
let checkout_root =
claimed_worktree_path(claim_from_cell(&target_claim), &fwd_abs)?;
hydrate::preserve_hydrated_ignores(&checkout_root, &linked_fwd)
.map_err(apply_error)
},
)?;
}
Ok(linked)
}
fn stage_mount(
&self,
tx: &mut Tx<'_>,
) -> HeddleResult<mount_lifecycle::VirtualizedMountOutcome> {
let repo = tx.repo();
let root = repo.root().to_path_buf();
let abs = self.abs_path.clone();
let fwd_name = self.name.clone();
let inv_name = self.name.clone();
let ownership = self.mount_ownership;
let mounted_owner = Rc::new(Cell::new(None));
let fwd_owner = Rc::clone(&mounted_owner);
let inv_owner = Rc::clone(&mounted_owner);
let inv_root = root.clone();
tx.step(
move || {
let outcome =
mount_lifecycle::establish_virtualized_mount(&root, &fwd_name, &abs, ownership)
.map_err(apply_error)?;
fwd_owner.set(Some(outcome.owner));
Ok(outcome)
},
move || {
let Some(owner) = inv_owner.get() else {
return Ok(());
};
mount_lifecycle::cleanup_virtualized_mount(&inv_root, &inv_name, owner)
.map_err(apply_error)?;
Ok(())
},
)
}
fn stage_record(&self, tx: &mut Tx<'_>) -> HeddleResult<()> {
let repo = tx.repo();
let record = self.record.clone();
let fwd_name = self.name.clone();
let inv_name = self.name.clone();
let prior = ThreadManager::new(repo.heddle_dir()).snapshot_records(&self.name)?;
tx.step_nonatomic(
|| Ok(prior),
move |prior| ThreadManager::new(repo.heddle_dir()).converge_records(&inv_name, &prior),
move || {
ThreadManager::new(repo.heddle_dir())
.converge_records(&fwd_name, std::slice::from_ref(&record))
},
)
}
}
impl AtomicMutation for StartThread {
type Output = StartThreadOutput;
fn transaction_id(&self) -> String {
self.transaction_id.clone()
}
fn isolation_keys(&self, _repo: &repo::Repository) -> HeddleResult<BTreeSet<IsolationKey>> {
let mut keys = BTreeSet::new();
keys.insert(IsolationKey::Thread(self.name.clone()));
if let Some(thread) = &self.record.target_thread {
keys.insert(IsolationKey::Thread(thread.clone()));
}
if let Some(thread) = &self.record.parent_thread {
keys.insert(IsolationKey::Thread(thread.clone()));
}
Ok(keys)
}
fn apply(&mut self, tx: &mut Tx<'_>) -> HeddleResult<StagedCommit<StartThreadOutput>> {
let mut oplog: Vec<OpRecord> = Vec::new();
let target_claim = Rc::new(Cell::new(None));
let checkout_outcome = Rc::new(Cell::new(None));
self.stage_target_dir(tx, Rc::clone(&target_claim))?;
self.stage_ref(tx, &mut oplog)?;
let mut fskit_readiness = None;
let linked = match self.thread_mode {
ThreadMode::Solid | ThreadMode::Materialized => {
self.stage_checkout(tx, Rc::clone(&target_claim), Rc::clone(&checkout_outcome))?;
if matches!(self.thread_mode, ThreadMode::Materialized) {
self.stage_manifest(
tx,
Rc::clone(&target_claim),
Rc::clone(&checkout_outcome),
)?;
}
if let Some(dir) = self.shared_target_dir.clone() {
let applied = self.stage_cargo_config(tx, &dir, Rc::clone(&target_claim))?;
if !applied {
self.record.shared_target_dir = None;
}
}
if self.hydrate {
self.stage_hydrate(tx, Rc::clone(&target_claim))?
} else {
Vec::new()
}
}
ThreadMode::Virtualized => {
fskit_readiness = self.stage_mount(tx)?.fskit_readiness;
Vec::new()
}
};
self.stage_record(tx)?;
Ok(StagedCommit::new(
StartThreadOutput {
linked,
fskit_readiness,
},
oplog,
))
}
}
fn restore_ignore_file(path: &Path, prior: Option<Vec<u8>>) -> HeddleResult<()> {
match prior {
Some(bytes) => std::fs::write(path, bytes).map_err(HeddleError::from),
None => match std::fs::remove_file(path) {
Ok(()) => Ok(()),
Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(err) => Err(HeddleError::from(err)),
},
}
}
fn symlink_unsupported_error_from(link: &str, err: anyhow::Error) -> HeddleError {
HeddleError::Conflict(format!(
"--hydrate could not create a directory symlink for '{link}' in the new checkout. \
This host or filesystem appears to reject directory symlinks (e.g. Windows without \
Developer Mode / the SeCreateSymbolicLink privilege, or a filesystem that doesn't \
support them). The partially-created thread has been rolled back — re-run \
`heddle start` without --hydrate, or enable directory-symlink support on this host \
and retry. (cause: {err:#})"
))
}
#[cfg(test)]
fn symlink_unsupported_error(link: &str) -> HeddleError {
symlink_unsupported_error_from(link, anyhow::anyhow!("injected hydrate symlink fault"))
}
#[cfg(test)]
mod tests {
use repo::Repository;
use tempfile::TempDir;
use super::{
super::{
thread::{
find_active_thread_entry, resolve_start_epoch, start_thread, start_transaction_id,
},
thread_cmd::drop_thread_silent,
worktree_cmd::helpers::plan_worktree_target,
},
*,
};
use crate::cli::{ThreadStartArgs, WorkspaceModeArg};
#[test]
fn apply_error_preserves_io_and_does_not_mislabel_as_conflict() {
let bare_io = anyhow::Error::new(std::io::Error::new(
std::io::ErrorKind::NotFound,
"No such file or directory (os error 2)",
));
let mapped = apply_error(bare_io);
assert!(
matches!(mapped, HeddleError::Io(_)),
"a bare io error must surface as Io, got {mapped:?}"
);
assert!(
!format!("{mapped}").starts_with("conflict:"),
"io error must not be reported as a conflict: {mapped}"
);
let structured_io = anyhow::Error::new(HeddleError::Io(std::io::Error::new(
std::io::ErrorKind::NotFound,
"No such file or directory (os error 2)",
)));
assert!(
matches!(apply_error(structured_io), HeddleError::Io(_)),
"a propagated HeddleError::Io must keep its variant"
);
let conflict = anyhow::Error::new(HeddleError::Conflict("real merge conflict".to_string()));
assert!(
matches!(apply_error(conflict), HeddleError::Conflict(_)),
"a genuine conflict must remain a conflict"
);
}
#[test]
fn apply_error_preserves_context_when_reclassifying_io() {
use anyhow::Context as _;
let with_ctx = Err::<(), _>(std::io::Error::new(
std::io::ErrorKind::PermissionDenied,
"os error 13",
))
.context("writing .cargo/config.toml to /work/.cargo/config.toml")
.unwrap_err();
let mapped = apply_error(with_ctx);
assert!(
matches!(&mapped, HeddleError::Io(io) if io.kind() == std::io::ErrorKind::PermissionDenied),
"io kind must survive reclassification, got {mapped:?}"
);
let msg = format!("{mapped}");
assert!(
msg.contains(".cargo/config.toml") && msg.contains("writing"),
"reclassified io error must retain the path/action context: {msg}"
);
}
fn solid_args(
name: &str,
path: &std::path::Path,
from: &ChangeId,
hydrate: bool,
) -> ThreadStartArgs {
ThreadStartArgs {
name: name.to_string(),
from: Some(from.to_string()),
path: Some(path.to_path_buf()),
workspace: WorkspaceModeArg::Solid,
agent_provider: None,
agent_model: None,
task: None,
parent_thread: None,
automated: true,
print_cd_path: false,
daemon: false,
no_daemon: true,
shared_target: false,
hydrate,
}
}
fn materialized_args(
name: &str,
path: &std::path::Path,
from: &ChangeId,
hydrate: bool,
) -> ThreadStartArgs {
let mut args = solid_args(name, path, from, hydrate);
args.workspace = WorkspaceModeArg::Materialized;
args
}
fn has_thread_ref(repo: &Repository, name: &str) -> bool {
repo.refs()
.get_thread(&ThreadName::new(name))
.unwrap()
.is_some()
}
fn has_thread_record(repo: &Repository, name: &str) -> bool {
ThreadManager::new(repo.heddle_dir())
.find_by_thread(name)
.unwrap()
.is_some()
}
fn repo_with_state(deps: &[&str]) -> (TempDir, Repository, ChangeId) {
let temp = TempDir::new().unwrap();
let repo = Repository::init_default(temp.path()).unwrap();
std::fs::write(temp.path().join("a.txt"), "a").unwrap();
if !deps.is_empty() {
let ignore = deps.iter().map(|d| format!("{d}/\n")).collect::<String>();
std::fs::write(temp.path().join(".heddleignore"), ignore).unwrap();
for dep in deps {
std::fs::create_dir_all(temp.path().join(dep)).unwrap();
}
}
let state = repo.snapshot(Some("s1".to_string()), None).unwrap();
(temp, repo, state.change_id)
}
#[test]
fn start_happy_path_materializes_records_and_hydrates() {
let (temp, repo, state) = repo_with_state(&["dep_a", "dep_b"]);
let checkout = temp.path().join("iso");
let out = start_thread(&repo, solid_args("iso", &checkout, &state, true));
assert!(
out.is_ok(),
"happy-path start should succeed: {:?}",
out.err()
);
assert!(
checkout.join(".heddle").is_dir(),
"checkout .heddle should exist"
);
assert!(
checkout.join("a.txt").is_file(),
"tracked file should materialize"
);
for dep in ["dep_a", "dep_b"] {
assert!(
std::fs::symlink_metadata(checkout.join(dep))
.map(|m| m.file_type().is_symlink())
.unwrap_or(false),
"{dep} should be hydrated as a symlink"
);
}
assert!(has_thread_ref(&repo, "iso"), "thread ref should be created");
assert!(
has_thread_record(&repo, "iso"),
"thread record should be persisted"
);
}
#[test]
fn start_on_private_base_yields_withheld_checkout_not_error() {
use objects::object::{Principal, StateVisibility, VisibilityTier};
const COURTESY_STUB_FILENAME: &str = "HEDDLE-EMBARGO.txt";
let (temp, repo, state) = repo_with_state(&[]);
repo.put_state_visibility(StateVisibility {
state,
tier: VisibilityTier::Private {
scope_label: "sec-embargo".into(),
},
embargo_until: None,
declarer: Principal {
name: "Grace Hopper".into(),
email: "grace@example.com".into(),
},
declared_at: chrono::Utc::now(),
signature: None,
supersedes: None,
})
.expect("put visibility");
let checkout = temp.path().join("iso");
let out = start_thread(&repo, materialized_args("iso", &checkout, &state, false));
assert!(
out.is_ok(),
"start on a Private base must succeed (withheld checkout), got {:?}",
out.err()
);
assert!(
checkout.join(COURTESY_STUB_FILENAME).exists(),
"a withheld start must write the courtesy stub"
);
assert!(
!checkout.join("a.txt").exists(),
"the Private base's tracked bytes must NOT be materialized"
);
let manifest = repo::thread_manifest::read_manifest(repo.heddle_dir(), "iso")
.unwrap()
.expect("manifest must be recorded");
assert!(
manifest.withheld,
"manifest must mark the checkout withheld"
);
assert!(
manifest.files.is_empty(),
"withheld manifest must record NO tracked-leaf stat entries, got {:?}",
manifest.files.keys().collect::<Vec<_>>()
);
let outcome = repo
.capture_thread_from_disk("iso", &checkout)
.expect("capture of a withheld checkout must not error");
assert_eq!(
outcome,
repo::ThreadCaptureOutcome::NoOp,
"a withheld checkout is non-capturable"
);
}
#[test]
fn start_shared_target_writes_cargo_config_through_claimed_dir() {
let temp = TempDir::new().unwrap();
let repo = Repository::init_default(temp.path()).unwrap();
std::fs::write(temp.path().join("Cargo.toml"), "[package]\nname = \"p\"\n").unwrap();
let state = repo
.snapshot(Some("rust workspace".to_string()), None)
.unwrap();
let checkout = temp.path().join("iso");
let mut args = solid_args("iso", &checkout, &state.change_id, false);
args.shared_target = true;
start_thread(&repo, args).expect("shared-target start should succeed");
let config = std::fs::read_to_string(checkout.join(".cargo").join("config.toml"))
.expect("cargo config should be written inside the checkout");
assert!(
config.contains(".heddle/targets"),
"cargo config should point at the shared target dir: {config}"
);
let record = ThreadManager::new(repo.heddle_dir())
.load("iso")
.unwrap()
.expect("thread record should persist");
assert!(
record.shared_target_dir.is_some(),
"record should advertise the applied shared target"
);
}
#[test]
fn start_materialize_fault_rolls_back_self_created_dir() {
let (temp, repo, state) = repo_with_state(&[]);
let checkout = temp.path().join("iso");
let out = with_start_fault(StartFault::Materialize, || {
start_thread(&repo, solid_args("iso", &checkout, &state, false))
});
assert!(out.is_err(), "a materialize fault must fail the start");
assert!(
std::fs::symlink_metadata(&checkout).is_err(),
"the self-created checkout must be removed on rollback"
);
assert!(
!has_thread_ref(&repo, "iso"),
"the thread ref must be rolled back"
);
assert!(
!has_thread_record(&repo, "iso"),
"no record must survive the rollback"
);
}
#[test]
fn start_materialize_fault_preserves_preexisting_dir() {
let (temp, repo, state) = repo_with_state(&[]);
let checkout = temp.path().join("iso");
std::fs::create_dir(&checkout).unwrap();
let out = with_start_fault(StartFault::Materialize, || {
start_thread(&repo, solid_args("iso", &checkout, &state, false))
});
assert!(out.is_err(), "a materialize fault must fail the start");
assert!(
checkout.is_dir(),
"a pre-existing user dir must not be deleted"
);
let remaining: Vec<_> = std::fs::read_dir(&checkout)
.unwrap()
.map(|e| e.unwrap().file_name())
.collect();
assert!(
remaining.is_empty(),
"rollback must clear materialized contents: {remaining:?}"
);
assert!(
!has_thread_ref(&repo, "iso"),
"the thread ref must be rolled back"
);
}
#[test]
fn start_partial_hydrate_unwinds_links_and_checkout() {
let (temp, repo, state) = repo_with_state(&["dep_a", "dep_b"]);
let checkout = temp.path().join("iso");
let out = with_start_fault(StartFault::HydrateNth(1), || {
start_thread(&repo, solid_args("iso", &checkout, &state, true))
});
assert!(out.is_err(), "a partial hydrate must fail the start");
assert!(
std::fs::symlink_metadata(&checkout).is_err(),
"a partial hydrate must remove the checkout (and every created link)"
);
assert!(
!has_thread_ref(&repo, "iso"),
"the thread ref must be rolled back"
);
assert!(temp.path().join("dep_a").is_dir());
assert!(temp.path().join("dep_b").is_dir());
}
fn sidecar_entries(dir: &std::path::Path) -> Vec<String> {
let mut names: Vec<String> = std::fs::read_dir(dir)
.into_iter()
.flatten()
.filter_map(|e| e.ok())
.map(|e| e.file_name().to_string_lossy().into_owned())
.collect();
names.sort();
names
}
#[test]
fn failed_atomic_start_rolls_back_leaves_sidecar() {
let (temp, repo, state) = repo_with_state(&["dep_a", "dep_b"]);
let checkout = temp.path().join("iso");
let roots_dir = repo.heddle_dir().join("materialized-roots");
let withheld_dir = repo.heddle_dir().join("withheld-checkouts");
let before_roots = sidecar_entries(&roots_dir);
let before_withheld = sidecar_entries(&withheld_dir);
let out = with_start_fault(StartFault::HydrateNth(1), || {
start_thread(&repo, solid_args("iso", &checkout, &state, true))
});
assert!(out.is_err(), "a partial hydrate must fail the start");
assert!(
std::fs::symlink_metadata(&checkout).is_err(),
"the checkout dir must be removed on rollback"
);
let after_roots = sidecar_entries(&roots_dir);
let after_withheld = sidecar_entries(&withheld_dir);
assert_eq!(
before_roots, after_roots,
"a rolled-back start must not orphan a per-root .leaves sidecar"
);
assert_eq!(
before_withheld, after_withheld,
"a rolled-back start must not orphan a withheld marker"
);
}
#[cfg(unix)]
#[test]
fn start_manifest_refuses_swapped_target_and_spares_symlink_target() {
let (temp, repo, state) = repo_with_state(&[]);
let checkout = temp.path().join("iso");
let victim = temp.path().join("victim");
std::fs::create_dir(&victim).unwrap();
std::fs::write(victim.join("a.txt"), b"victim").unwrap();
std::fs::write(victim.join("precious.txt"), b"precious").unwrap();
let out = with_start_target_swap(TargetSwapPoint::BeforeManifest, victim.clone(), || {
start_thread(&repo, materialized_args("iso", &checkout, &state, false))
});
assert!(
out.is_err(),
"manifest staging must refuse when the claimed target leaf was swapped"
);
assert!(
std::fs::symlink_metadata(&checkout)
.unwrap()
.file_type()
.is_symlink(),
"rollback must leave the substituted symlink itself alone"
);
assert_eq!(
std::fs::read(victim.join("a.txt")).unwrap(),
b"victim",
"manifest staging must not read/stat through the swapped symlink target"
);
assert_eq!(
std::fs::read(victim.join("precious.txt")).unwrap(),
b"precious",
"rollback must not touch the symlink target"
);
assert!(
repo::thread_manifest::manifest_path(repo.heddle_dir(), "iso")
.symlink_metadata()
.is_err(),
"a refused manifest stage must not leave a sidecar behind"
);
assert!(
!has_thread_ref(&repo, "iso"),
"the thread ref must be rolled back"
);
}
#[cfg(unix)]
#[test]
fn preserve_hydrated_ignores_refuses_swapped_target_and_spares_symlink_target() {
let (temp, repo, state) = repo_with_state(&["dep_a"]);
let checkout = temp.path().join("iso");
let victim = temp.path().join("victim");
std::fs::create_dir(&victim).unwrap();
std::fs::write(victim.join("precious.txt"), b"precious").unwrap();
let out = with_start_target_swap(
TargetSwapPoint::BeforePreserveIgnores,
victim.clone(),
|| start_thread(&repo, solid_args("iso", &checkout, &state, true)),
);
assert!(
out.is_err(),
"hydrate ignore preservation must refuse when the claimed target leaf was swapped"
);
assert!(
std::fs::symlink_metadata(&checkout)
.unwrap()
.file_type()
.is_symlink(),
"rollback must leave the substituted symlink itself alone"
);
assert!(
!victim.join(".heddle").exists(),
"hydrate ignore preservation must not create an exclude file through the symlink target"
);
assert_eq!(
std::fs::read(victim.join("precious.txt")).unwrap(),
b"precious",
"rollback must not touch the symlink target"
);
assert!(
!has_thread_ref(&repo, "iso"),
"the thread ref must be rolled back"
);
}
#[test]
fn plan_worktree_target_defers_dir_creation() {
let (temp, repo, _state) = repo_with_state(&[]);
let target = temp.path().join("iso-deferred");
let prepared = plan_worktree_target(&repo, &target, None).unwrap();
assert!(
prepared.target_dir_created,
"a non-existent target is flagged as one this start will create"
);
assert!(
std::fs::symlink_metadata(&target).is_err(),
"plan must NOT create the target dir — creation is deferred into the \
transaction so a pre-execute failure can't orphan it"
);
}
#[test]
fn start_transaction_id_is_stable_across_oplog_advance() {
let (temp, repo, state) = repo_with_state(&[]);
let scope = repo.op_scope();
let epoch = chrono::Utc::now();
let id1 = start_transaction_id(&scope, "iso", &state, epoch);
std::fs::write(temp.path().join("b.txt"), "b").unwrap();
repo.snapshot(Some("s2".to_string()), None).unwrap();
let id2 = start_transaction_id(&scope, "iso", &state, epoch);
assert_eq!(
id1, id2,
"the start transaction key must be independent of the advancing oplog head"
);
}
#[test]
fn crash_retry_of_same_start_rederives_committed_key() {
let (temp, repo, state) = repo_with_state(&[]);
let checkout = temp.path().join("iso");
let scope = repo.op_scope();
start_thread(&repo, solid_args("iso", &checkout, &state, false))
.expect("start should succeed");
let epoch = resolve_start_epoch(&repo, "iso").unwrap();
let retry_id = start_transaction_id(&scope, "iso", &state, epoch);
assert!(
!repo
.oplog()
.committed_batch_records(&retry_id)
.unwrap()
.is_empty(),
"a crash-retry must re-derive the committed key (the Active record's epoch) so \
the executor dedups it instead of re-applying the committed start"
);
}
#[test]
fn start_after_silent_drop_at_same_base_actually_starts() {
let (temp, repo, state) = repo_with_state(&[]);
let checkout = temp.path().join("iso");
start_thread(&repo, solid_args("iso", &checkout, &state, false))
.expect("first start should succeed");
drop_thread_silent(&repo, "iso", false, true).expect("silent drop should succeed");
assert!(
has_thread_ref(&repo, "iso"),
"a silent drop keeps the ref at the same base (the collision premise)"
);
assert!(
std::fs::symlink_metadata(&checkout).is_err(),
"the drop removes the prior checkout"
);
start_thread(&repo, solid_args("iso", &checkout, &state, false))
.expect("start after a silent drop must actually start, not dedup to a no-op");
assert!(
checkout.join(".heddle").is_dir(),
"the restart must re-materialize the checkout (not dedup into a no-op)"
);
let record = ThreadManager::new(repo.heddle_dir())
.load("iso")
.unwrap()
.expect("the restart must persist a record");
assert_eq!(
record.state,
repo::ThreadState::Active,
"the restart must leave an Active record, not the dropped Abandoned one"
);
}
#[test]
fn committed_before_bookkeeping_retry_short_circuits() {
let (temp, repo, state) = repo_with_state(&[]);
let checkout = temp.path().join("iso");
start_thread(&repo, solid_args("iso", &checkout, &state, false))
.expect("first start should succeed");
assert!(
checkout.join(".heddle").is_dir(),
"the first start materialized the checkout"
);
let entry = find_active_thread_entry(&repo, "iso")
.unwrap()
.expect("the first start created a reservation");
objects::store::AgentRegistry::new(repo.heddle_dir())
.delete(&entry.session_id)
.unwrap();
assert!(
find_active_thread_entry(&repo, "iso").unwrap().is_none(),
"the reservation is gone — exactly the committed-before-bookkeeping window"
);
start_thread(&repo, solid_args("iso", &checkout, &state, false)).expect(
"a committed-before-bookkeeping retry must short-circuit, not be rejected by \
worktree_target_not_empty",
);
assert!(
checkout.join(".heddle").is_dir(),
"the committed checkout survives the retry"
);
assert!(
checkout.join("a.txt").is_file(),
"the committed tree survives the retry"
);
let record = ThreadManager::new(repo.heddle_dir())
.load("iso")
.unwrap()
.expect("the committed record persists");
assert_eq!(record.state, repo::ThreadState::Active);
assert!(
find_active_thread_entry(&repo, "iso").unwrap().is_some(),
"the retry completes the interrupted reservation exactly-once"
);
}
#[test]
fn restore_thread_manifest_restores_prior_and_removes_when_absent() {
let (temp, repo, _state) = repo_with_state(&[]);
let _ = &temp;
let heddle_dir = repo.heddle_dir();
let path = repo::thread_manifest::manifest_path(heddle_dir, "foo");
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(&path, b"OLD").unwrap();
let prior = std::fs::read(&path).ok();
std::fs::write(&path, b"NEW").unwrap();
repo.restore_thread_manifest("foo", prior).unwrap();
assert_eq!(
std::fs::read(&path).unwrap(),
b"OLD",
"rollback must restore the prior manifest snapshot, not delete it"
);
let path2 = repo::thread_manifest::manifest_path(heddle_dir, "bar");
std::fs::create_dir_all(path2.parent().unwrap()).unwrap();
std::fs::write(&path2, b"NEW").unwrap();
repo.restore_thread_manifest("bar", None).unwrap();
assert!(
std::fs::symlink_metadata(&path2).is_err(),
"rollback of a freshly-created manifest must remove it"
);
}
#[test]
fn cas_guarded_ref_rollback_does_not_clobber_concurrent_advance() {
let (temp, repo, base) = repo_with_state(&[]);
let _ = &temp;
let name = ThreadName::new("foo");
let advanced = ChangeId::generate();
let prior = ChangeId::generate();
repo.refs().set_thread(&name, &advanced).unwrap();
repo.cas_guarded_thread_ref_rollback(&name, base, None)
.unwrap();
assert_eq!(
repo.refs().get_thread(&name).unwrap(),
Some(advanced),
"rollback must not delete a ref a concurrent process advanced"
);
repo.refs().set_thread(&name, &base).unwrap();
repo.cas_guarded_thread_ref_rollback(&name, base, None)
.unwrap();
assert_eq!(
repo.refs().get_thread(&name).unwrap(),
None,
"rollback must delete a ref still holding our forward value"
);
repo.refs().set_thread(&name, &advanced).unwrap();
repo.cas_guarded_thread_ref_rollback(&name, base, Some(prior))
.unwrap();
assert_eq!(
repo.refs().get_thread(&name).unwrap(),
Some(advanced),
"rollback must not reset a ref a concurrent process advanced"
);
repo.refs().set_thread(&name, &base).unwrap();
repo.cas_guarded_thread_ref_rollback(&name, base, Some(prior))
.unwrap();
assert_eq!(
repo.refs().get_thread(&name).unwrap(),
Some(prior),
"rollback must restore the prior value when the ref still holds our forward value"
);
}
#[test]
fn target_dir_rollback_leaves_concurrently_created_dir_intact() {
let temp = TempDir::new().unwrap();
let target = temp.path().join("iso");
std::fs::create_dir(&target).unwrap();
let claim = create_target_dir(&target, true).unwrap();
assert_eq!(
claim.kind(),
TargetDirKind::AdoptedEmpty,
"a real empty dir a concurrent process created is ADOPTED, not claimed as created \
(a stale plan-time bool would have claimed it)"
);
remove_self_created_dir(&target, Some(claim.clone())).unwrap();
rewind_checkout(&target, Some(claim)).unwrap();
assert!(
target.is_dir(),
"rollback must not delete a directory this start did not create"
);
}
#[test]
fn target_dir_rollback_removes_genuinely_created_dir() {
let temp = TempDir::new().unwrap();
let target = temp.path().join("nested").join("iso");
let claim = create_target_dir(&target, true).unwrap();
assert_eq!(
claim.kind(),
TargetDirKind::Created,
"an absent target this start creates is owned by us"
);
assert!(
target.is_dir(),
"the forward must create the leaf (and its parents)"
);
remove_self_created_dir(&target, Some(claim)).unwrap();
assert!(
std::fs::symlink_metadata(&target).is_err(),
"rollback must remove a directory this start genuinely created"
);
}
#[test]
fn target_dir_user_supplied_dir_is_never_removed() {
let temp = TempDir::new().unwrap();
let target = temp.path().join("iso");
std::fs::create_dir(&target).unwrap();
let claim = create_target_dir(&target, false).unwrap();
assert_eq!(
claim.kind(),
TargetDirKind::AdoptedEmpty,
"a user-supplied pre-existing empty dir is adopted, not ours to own/remove"
);
remove_self_created_dir(&target, Some(claim)).unwrap();
assert!(target.is_dir(), "a user-supplied dir must survive rollback");
}
#[cfg(unix)]
#[test]
fn claimed_target_swap_refuses_write_and_spares_symlink_target() {
let (temp, repo, state) = repo_with_state(&[]);
let checkout = temp.path().join("iso");
let victim = temp.path().join("victim");
std::fs::create_dir(&victim).unwrap();
std::fs::write(victim.join("precious.txt"), b"precious").unwrap();
let claim = create_target_dir(&checkout, true).unwrap();
assert_eq!(claim.kind(), TargetDirKind::Created);
std::fs::remove_dir(&checkout).unwrap();
std::os::unix::fs::symlink(&victim, &checkout).unwrap();
let checkout_root = claimed_worktree_path(Some(claim.clone()), &checkout);
assert!(
checkout_root.is_err(),
"a post-claim leaf swap must refuse before any checkout write"
);
rewind_checkout(&checkout, Some(claim.clone())).unwrap();
remove_self_created_dir(&checkout, Some(claim)).unwrap();
assert!(
std::fs::symlink_metadata(&checkout)
.unwrap()
.file_type()
.is_symlink(),
"rollback must not remove the substituted symlink"
);
assert!(
!victim.join("a.txt").exists() && !victim.join(".heddle").exists(),
"checkout materialization must not write into the symlink target"
);
assert_eq!(
std::fs::read(victim.join("precious.txt")).unwrap(),
b"precious",
"rollback must not clear the symlink target's existing data"
);
let _ = (repo, state);
}
#[test]
fn claimed_created_and_adopted_dirs_still_write_and_rewind() {
let (temp, repo, state) = repo_with_state(&[]);
let created = temp.path().join("created");
let created_claim = create_target_dir(&created, true).unwrap();
let created_root = claimed_worktree_path(Some(created_claim.clone()), &created).unwrap();
write_isolated_checkout(&repo, &created_root, &state, Some("created")).unwrap();
assert!(
created.join("a.txt").is_file(),
"created dir still receives checkout bytes"
);
rewind_checkout(&created, Some(created_claim)).unwrap();
assert!(
std::fs::symlink_metadata(&created).is_err(),
"created dir is removed on rollback"
);
let adopted = temp.path().join("adopted");
std::fs::create_dir(&adopted).unwrap();
let adopted_claim = create_target_dir(&adopted, false).unwrap();
let adopted_root = claimed_worktree_path(Some(adopted_claim.clone()), &adopted).unwrap();
write_isolated_checkout(&repo, &adopted_root, &state, Some("adopted")).unwrap();
assert!(
adopted.join("a.txt").is_file(),
"adopted dir still receives checkout bytes"
);
rewind_checkout(&adopted, Some(adopted_claim)).unwrap();
assert!(adopted.is_dir(), "adopted dir itself survives rollback");
let remaining: Vec<_> = std::fs::read_dir(&adopted)
.unwrap()
.map(|entry| entry.unwrap().file_name())
.collect();
assert!(
remaining.is_empty(),
"adopted dir contents are cleared: {remaining:?}"
);
}
#[test]
fn create_target_dir_refuses_symlink_leaf_and_spares_target_contents() {
let temp = TempDir::new().unwrap();
let victim = temp.path().join("victim");
std::fs::create_dir(&victim).unwrap();
std::fs::write(victim.join("precious.txt"), b"precious").unwrap();
let leaf = temp.path().join("iso");
std::os::unix::fs::symlink(&victim, &leaf).unwrap();
let claim = create_target_dir(&leaf, true);
assert!(
claim.is_err(),
"a symlink at the leaf must REFUSE the start, never be adopted/written through"
);
rewind_checkout(&leaf, None).unwrap();
remove_self_created_dir(&leaf, None).unwrap();
assert!(
std::fs::symlink_metadata(&leaf)
.unwrap()
.file_type()
.is_symlink(),
"the symlink itself must be left untouched"
);
assert!(
victim.join("precious.txt").is_file(),
"rollback must NOT delete through the symlink into its target's contents"
);
assert_eq!(
std::fs::read(victim.join("precious.txt")).unwrap(),
b"precious",
"the symlink target's data must survive intact"
);
}
#[test]
fn create_target_dir_refuses_non_directory_leaf() {
let temp = TempDir::new().unwrap();
let leaf = temp.path().join("iso");
std::fs::write(&leaf, b"i am a file, not a dir").unwrap();
let claim = create_target_dir(&leaf, true);
assert!(claim.is_err(), "a non-directory leaf must refuse the start");
assert!(
leaf.is_file(),
"the pre-existing file must be left untouched"
);
assert_eq!(std::fs::read(&leaf).unwrap(), b"i am a file, not a dir");
}
#[test]
fn create_target_dir_refuses_non_empty_dir_leaf() {
let temp = TempDir::new().unwrap();
let leaf = temp.path().join("iso");
std::fs::create_dir(&leaf).unwrap();
std::fs::write(leaf.join("someone-elses-work.txt"), b"keep me").unwrap();
let claim = create_target_dir(&leaf, true);
assert!(
claim.is_err(),
"a non-empty dir at the leaf must refuse the start"
);
assert!(
leaf.join("someone-elses-work.txt").is_file(),
"the pre-existing contents must be left untouched"
);
}
}