use std::path::{Path, PathBuf};
use anyhow::Result;
use objects::{object::ChangeId, store::ObjectStore};
use repo::{AudienceTier, CheckoutMaterialization, Repository};
use super::super::advice::RecoveryAdvice;
pub(crate) struct PreparedWorktreeTarget {
pub path: PathBuf,
pub target_dir_created: bool,
}
pub(crate) fn prepare_worktree_target(
repo: &Repository,
path: &Path,
self_thread: Option<&str>,
) -> Result<PreparedWorktreeTarget> {
let prepared = plan_worktree_target(repo, path, self_thread)?;
let requested = absolute_path(path)?;
std::fs::create_dir_all(&prepared.path).map_err(|error| {
anyhow::anyhow!(worktree_target_prepare_failed_advice(&requested, error))
})?;
validate_worktree_target(repo, &prepared.path, self_thread)?;
Ok(prepared)
}
pub(crate) fn plan_worktree_target(
repo: &Repository,
path: &Path,
self_thread: Option<&str>,
) -> Result<PreparedWorktreeTarget> {
let requested = absolute_path(path)?;
if let Ok(metadata) = std::fs::symlink_metadata(&requested)
&& metadata.file_type().is_symlink()
{
return Err(anyhow::anyhow!(worktree_target_symlink_advice(&requested)));
}
let resolved = canonicalize_existing_ancestor(&requested)?;
validate_worktree_target(repo, &resolved, self_thread)?;
let target_dir_created = !resolved.exists();
Ok(PreparedWorktreeTarget {
path: resolved,
target_dir_created,
})
}
fn absolute_path(path: &Path) -> Result<PathBuf> {
if path.is_absolute() {
Ok(path.to_path_buf())
} else {
Ok(std::env::current_dir()?.join(path))
}
}
fn canonicalize_existing_ancestor(path: &Path) -> Result<PathBuf> {
let mut ancestor = path;
while !ancestor.exists() {
ancestor = ancestor
.parent()
.ok_or_else(|| anyhow::anyhow!(worktree_target_invalid_path_advice(path)))?;
}
let mut resolved = ancestor.canonicalize()?;
let remainder = path
.strip_prefix(ancestor)
.map_err(|_| anyhow::anyhow!(worktree_target_invalid_path_advice(path)))?;
for component in remainder.components() {
match component {
std::path::Component::Normal(part) => resolved.push(part),
std::path::Component::CurDir => {}
std::path::Component::ParentDir
| std::path::Component::Prefix(_)
| std::path::Component::RootDir => {
return Err(anyhow::anyhow!(worktree_target_unsafe_path_advice(path)));
}
}
}
Ok(resolved)
}
fn validate_worktree_target(
repo: &Repository,
path: &Path,
self_thread: Option<&str>,
) -> Result<()> {
let threads_root = repo.heddle_dir().join("threads");
let in_threads_root = path == threads_root || path.starts_with(&threads_root);
if in_threads_root {
if path == threads_root {
return Err(anyhow::anyhow!(worktree_target_nested_thread_advice(path)));
}
if path.parent() == Some(threads_root.as_path()) {
return Err(anyhow::anyhow!(worktree_target_managed_needs_leaf_advice(
path
)));
}
if is_inside_existing_thread(repo, &threads_root, path, self_thread)? {
return Err(anyhow::anyhow!(worktree_target_nested_thread_advice(path)));
}
} else if path == repo.heddle_dir() || path.starts_with(repo.heddle_dir()) {
return Err(anyhow::anyhow!(worktree_target_storage_advice(path)));
}
repo::validate_thread_worktree_target(path).map_err(worktree_target_shape_advice)?;
Ok(())
}
fn worktree_target_shape_advice(error: repo::ThreadWorktreeTargetError) -> anyhow::Error {
match error {
repo::ThreadWorktreeTargetError::Symlink { path } => {
anyhow::anyhow!(worktree_target_symlink_advice(&path))
}
repo::ThreadWorktreeTargetError::NotDirectory { path } => {
anyhow::anyhow!(worktree_target_not_directory_advice(&path))
}
repo::ThreadWorktreeTargetError::NotEmpty { path } => {
anyhow::anyhow!(worktree_target_not_empty_advice(&path))
}
repo::ThreadWorktreeTargetError::Io { source, .. } => anyhow::anyhow!(source),
}
}
fn is_inside_existing_thread(
repo: &Repository,
threads_root: &Path,
candidate: &Path,
self_thread: Option<&str>,
) -> Result<bool> {
if !candidate.starts_with(threads_root) {
return Ok(false);
}
for thread in repo::ThreadManager::new(repo.heddle_dir()).list()? {
let is_self = self_thread == Some(thread.thread.as_str());
let dir = repo::thread_manifest::thread_dir(repo.heddle_dir(), &thread.thread);
let canonical_checkout = repo.managed_checkout_path(&thread.thread);
if candidate.starts_with(&dir) && !(is_self && candidate == canonical_checkout) {
return Ok(true);
}
for recorded in [
Some(&thread.execution_path),
thread.materialized_path.as_ref(),
]
.into_iter()
.flatten()
{
if recorded.as_os_str().is_empty() || !recorded.starts_with(threads_root) {
continue;
}
if candidate.starts_with(recorded) && !(is_self && candidate == recorded.as_path()) {
return Ok(true);
}
}
}
Ok(false)
}
fn worktree_target_symlink_advice(path: &Path) -> RecoveryAdvice {
RecoveryAdvice::safety_refusal(
"worktree_target_symlink",
format!("worktree target '{}' cannot be a symlink", path.display()),
"Choose an empty real directory for `--path`.",
format!(
"target path '{}' resolves through a symlink",
path.display()
),
"writing an isolated checkout through a symlink could target a different location than the caller sees",
"no thread, checkout, repository object, ref, or worktree file was changed",
"heddle start <name> --path <empty-path>",
vec!["heddle start <name> --path <empty-path>".to_string()],
)
}
fn worktree_target_prepare_failed_advice(path: &Path, error: std::io::Error) -> RecoveryAdvice {
RecoveryAdvice::safety_refusal(
"worktree_target_prepare_failed",
format!(
"Could not prepare isolated thread workspace '{}': {error}",
path.display()
),
"Choose an empty writable path with `--path`.",
format!("target path '{}' could not be created", path.display()),
"continuing would leave the isolated checkout only partially prepared",
"no thread, checkout, repository object, ref, or worktree file was changed",
"heddle start <name> --path <empty-path>",
vec!["heddle start <name> --path <empty-path>".to_string()],
)
}
fn worktree_target_invalid_path_advice(path: &Path) -> RecoveryAdvice {
RecoveryAdvice::safety_refusal(
"worktree_target_invalid_path",
format!("invalid worktree path '{}'", path.display()),
"Choose an empty writable path for `--path`.",
format!("target path '{}' has no usable ancestor", path.display()),
"continuing would make checkout placement ambiguous",
"no thread, checkout, repository object, ref, or worktree file was changed",
"heddle start <name> --path <empty-path>",
vec!["heddle start <name> --path <empty-path>".to_string()],
)
}
fn worktree_target_unsafe_path_advice(path: &Path) -> RecoveryAdvice {
RecoveryAdvice::safety_refusal(
"worktree_target_unsafe_path",
format!("unsafe worktree path '{}'", path.display()),
"Choose a normal empty path for `--path`; avoid parent-directory traversal.",
format!(
"target path '{}' contains an unsafe component",
path.display()
),
"continuing could write outside the intended checkout location",
"no thread, checkout, repository object, ref, or worktree file was changed",
"heddle start <name> --path <empty-path>",
vec!["heddle start <name> --path <empty-path>".to_string()],
)
}
fn worktree_target_storage_advice(path: &Path) -> RecoveryAdvice {
RecoveryAdvice::safety_refusal(
"worktree_target_in_heddle_storage",
format!(
"worktree target '{}' cannot point into .heddle storage",
path.display()
),
"Choose a checkout path outside `.heddle`, preferably a sibling directory.",
format!(
"target path '{}' is inside repository metadata storage",
path.display()
),
"writing a checkout there could corrupt Heddle repository metadata",
"no thread, checkout, repository object, ref, or worktree file was changed",
"heddle start <name> --path ../<name>",
vec!["heddle start <name> --path ../<name>".to_string()],
)
}
fn worktree_target_nested_thread_advice(path: &Path) -> RecoveryAdvice {
RecoveryAdvice::safety_refusal(
"worktree_target_nested_thread",
format!(
"worktree target '{}' is nested inside an existing thread's checkout",
path.display()
),
"Choose a sibling directory outside the repository.",
format!(
"target path '{}' falls under another thread's reserved `.heddle/threads/<name>` subtree",
path.display()
),
"writing a checkout there would land it inside another thread's worktree",
"no thread, checkout, repository object, ref, or worktree file was changed",
"heddle start <name> --path ../<name>",
vec!["heddle start <name> --path ../<name>".to_string()],
)
}
fn worktree_target_managed_needs_leaf_advice(path: &Path) -> RecoveryAdvice {
RecoveryAdvice::safety_refusal(
"worktree_target_managed_needs_leaf",
format!(
"managed worktree target '{}' must be a per-thread checkout leaf, not the per-thread directory itself",
path.display()
),
"Append a checkout leaf such as the repository directory name, or choose a sibling directory outside the repository.",
format!(
"target path '{}' is the bare `.heddle/threads/<name>` directory, where the per-thread manifest sidecar lives",
path.display()
),
"checking out onto the per-thread directory would write Heddle's `manifest.toml` sidecar inside the worktree, starting it dirty",
"no thread, checkout, repository object, ref, or worktree file was changed",
"heddle start <name> --path <path>/<repo-name>",
vec!["heddle start <name> --path <path>/<repo-name>".to_string()],
)
}
fn worktree_target_not_directory_advice(path: &Path) -> RecoveryAdvice {
RecoveryAdvice::safety_refusal(
"worktree_target_not_directory",
format!("worktree target '{}' must be a directory", path.display()),
"Choose an empty directory path for `--path`.",
format!(
"target path '{}' exists but is not a directory",
path.display()
),
"continuing would overwrite a non-directory path",
"no thread, checkout, repository object, ref, or worktree file was changed",
"heddle start <name> --path <empty-path>",
vec!["heddle start <name> --path <empty-path>".to_string()],
)
}
fn worktree_target_not_empty_advice(path: &Path) -> RecoveryAdvice {
RecoveryAdvice::safety_refusal(
"worktree_target_not_empty",
format!("worktree target '{}' is not empty", path.display()),
"Use an empty path, or capture current work with `heddle capture`.",
format!("target path '{}' already contains files", path.display()),
"writing an isolated checkout there could overwrite or mix with existing work",
"no thread, checkout, repository object, ref, or worktree file was changed",
"heddle start <name> --path <empty-path>",
vec![
"heddle start <name> --path <empty-path>".to_string(),
"heddle capture -m \"...\"".to_string(),
],
)
}
pub(crate) fn write_isolated_checkout(
repo: &Repository,
abs_path: &Path,
base_state: &ChangeId,
thread: Option<&str>,
) -> Result<CheckoutMaterialization> {
let heddle_dir = abs_path.join(".heddle");
if heddle_dir.exists() {
return Err(anyhow::anyhow!(worktree_target_existing_heddle_advice(
abs_path
)));
}
let shared_galeed_dir = repo.heddle_dir();
std::fs::create_dir_all(&heddle_dir)?;
{
use std::io::Write as _;
let mut pointer_file = std::fs::File::create(heddle_dir.join("objectstore"))?;
pointer_file
.write_all(format!("objectstore: {}\n", shared_galeed_dir.display()).as_bytes())?;
pointer_file.sync_all()?;
}
std::fs::create_dir_all(heddle_dir.join("state"))?;
objects::fault_inject::maybe_fail_at("start_materialize_checkout")
.map_err(|error| anyhow::anyhow!(error))?;
let checkout_head = heddle_dir.join("HEAD");
let head_content = match thread {
Some(thread) => format!("ref: {}\n", thread),
None => format!("{}\n", base_state.to_string_full()),
};
{
use std::io::Write as _;
let mut head_file = std::fs::File::create(&checkout_head)?;
head_file.write_all(head_content.as_bytes())?;
head_file.sync_all()?;
}
let state = repo
.store()
.get_state(base_state)?
.ok_or_else(|| anyhow::anyhow!("State not found in object store"))?;
let outcome =
repo.checkout_state_gated(base_state, &state, abs_path, &AudienceTier::Internal)?;
Ok(outcome)
}
fn worktree_target_existing_heddle_advice(path: &Path) -> RecoveryAdvice {
RecoveryAdvice::safety_refusal(
"worktree_target_existing_heddle",
format!("'{}' already has a .heddle directory", path.display()),
"Choose a path that is not already a Heddle checkout.",
format!(
"target path '{}' already contains Heddle checkout metadata",
path.display()
),
"reusing that path could attach the new thread to the wrong checkout metadata",
"no thread, checkout, repository object, ref, or worktree file was changed",
"heddle start <name> --path <empty-path>",
vec!["heddle start <name> --path <empty-path>".to_string()],
)
}
#[cfg(test)]
mod gate_tests {
use chrono::Utc;
use objects::object::{Principal, StateVisibility, ThreadName, VisibilityTier};
use tempfile::TempDir;
use super::*;
const COURTESY_STUB_FILENAME: &str = "HEDDLE-EMBARGO.txt";
fn embargo_head(repo: &Repository) -> ChangeId {
let state_id = repo
.refs()
.get_thread(&ThreadName::new("main"))
.unwrap()
.expect("head present");
repo.put_state_visibility(StateVisibility {
state: state_id,
tier: VisibilityTier::Private {
scope_label: "sec-embargo".into(),
},
embargo_until: None,
declarer: Principal {
name: "Grace Hopper".into(),
email: "grace@example.com".into(),
},
declared_at: Utc::now(),
signature: None,
supersedes: None,
})
.expect("put visibility");
state_id
}
#[test]
fn write_isolated_checkout_withholds_embargoed_state() {
let repo_dir = TempDir::new().unwrap();
let repo = Repository::init_default(repo_dir.path()).unwrap();
std::fs::write(repo_dir.path().join("secret.rs"), b"fn exploit() {}\n").unwrap();
repo.snapshot(Some("embargoed".into()), None).unwrap();
let state_id = embargo_head(&repo);
let holder = TempDir::new().unwrap();
let dest = holder.path().join("out");
write_isolated_checkout(&repo, &dest, &state_id, Some("main")).expect("checkout");
assert!(
dest.join(COURTESY_STUB_FILENAME).exists(),
"embargoed start --path must write the courtesy stub"
);
assert!(
!dest.join("secret.rs").exists(),
"embargoed bytes must NOT be materialized via write_isolated_checkout"
);
}
fn register_thread(repo: &Repository, name: &str, mode: repo::ThreadMode) {
register_thread_at(repo, name, mode, PathBuf::new());
}
fn register_thread_at(
repo: &Repository,
name: &str,
mode: repo::ThreadMode,
execution_path: PathBuf,
) {
register_thread_inner(repo, name, mode, execution_path)
}
fn register_thread_inner(
repo: &Repository,
name: &str,
mode: repo::ThreadMode,
execution_path: PathBuf,
) {
let now = Utc::now();
let thread = repo::Thread {
id: name.to_string(),
thread: name.to_string(),
target_thread: None,
parent_thread: None,
mode,
state: repo::ThreadState::Active,
base_state: "deadbeef".to_string(),
base_root: "deadbeef".to_string(),
current_state: None,
merged_state: None,
task: None,
materialized_path: (!execution_path.as_os_str().is_empty())
.then(|| execution_path.clone()),
execution_path,
changed_paths: Vec::new(),
impact_categories: Vec::new(),
heavy_impact_paths: Vec::new(),
promotion_suggested: false,
freshness: repo::ThreadFreshness::Unknown,
verification_summary: Default::default(),
confidence_summary: Default::default(),
integration_policy_result: Default::default(),
created_at: now,
updated_at: now,
ephemeral: None,
auto: false,
shared_target_dir: None,
};
repo::ThreadManager::new(repo.heddle_dir())
.save(&thread)
.expect("save thread record");
}
#[test]
fn validate_rejects_path_nested_in_existing_thread_checkout() {
let repo_dir = TempDir::new().unwrap();
let repo = Repository::init_default(repo_dir.path()).unwrap();
let threads_root = repo.heddle_dir().join("threads");
let checkout_leaf = PathBuf::from(repo::thread_manifest::managed_checkout_leaf(
repo.managed_checkout_source_root(),
));
register_thread(&repo, "foo", repo::ThreadMode::Materialized);
register_thread(&repo, "virt", repo::ThreadMode::Virtualized);
for name in ["foo", "virt"] {
let nested = threads_root.join(name).join(&checkout_leaf).join("nested");
let err = validate_worktree_target(&repo, &nested, None).unwrap_err();
assert!(
err.to_string().contains("nested inside an existing thread"),
"path nested in existing {name} thread must be rejected: {err}"
);
}
validate_worktree_target(&repo, &threads_root, None)
.expect_err("the .heddle/threads metadata root must be rejected");
let err = validate_worktree_target(&repo, &threads_root.join("brandnew"), None)
.expect_err("a bare .heddle/threads/<name> dir (no leaf) must be rejected");
assert!(
err.to_string().contains("per-thread checkout leaf"),
"unexpected error for the no-leaf target: {err}"
);
validate_worktree_target(
&repo,
&threads_root.join("brandnew").join(&checkout_leaf),
None,
)
.expect("a fresh .heddle/threads/<name>/<repo-name> checkout is allowed");
let foo_checkout = repo.managed_checkout_path("foo");
validate_worktree_target(&repo, &foo_checkout, None)
.expect_err("another thread must not reuse foo's reserved checkout");
validate_worktree_target(&repo, &foo_checkout, Some("foo"))
.expect("re-materializing foo's own canonical checkout slot is allowed");
let custom_root = threads_root.join("custom-slot").join(&checkout_leaf);
register_thread_at(
&repo,
"custom",
repo::ThreadMode::Solid,
custom_root.clone(),
);
let nested_in_custom = custom_root.join("nested");
validate_worktree_target(&repo, &nested_in_custom, None)
.expect_err("a path nested in a thread's recorded custom checkout must be rejected");
validate_worktree_target(&repo, &custom_root, Some("custom"))
.expect("the same thread may re-use its own recorded checkout root");
validate_worktree_target(&repo, &custom_root, None)
.expect_err("another thread must not reuse a recorded custom checkout root");
}
#[test]
fn write_isolated_checkout_materializes_visible_state() {
let repo_dir = TempDir::new().unwrap();
let repo = Repository::init_default(repo_dir.path()).unwrap();
std::fs::write(repo_dir.path().join("ok.rs"), b"fn ok() {}\n").unwrap();
repo.snapshot(Some("public".into()), None).unwrap();
let state_id = repo
.refs()
.get_thread(&ThreadName::new("main"))
.unwrap()
.expect("head present");
let holder = TempDir::new().unwrap();
let dest = holder.path().join("out");
write_isolated_checkout(&repo, &dest, &state_id, Some("main")).expect("checkout");
assert!(
dest.join("ok.rs").exists(),
"a visible state materializes its real bytes"
);
assert!(
!dest.join(COURTESY_STUB_FILENAME).exists(),
"no courtesy stub for a visible state"
);
}
}