From ab7aee87dae9a0afc67b153a1c29be95dfe04f5f Mon Sep 17 00:00:00 2001 From: Meta-Develop <116134763+Meta-Develop@users.noreply.github.com> Date: Thu, 1 Oct 2026 01:22:08 +0900 Subject: [PATCH 1/6] fix: keep default worktrees inside the repository --- README.md | 24 ++-- src/cli/part2.rs | 8 +- src/supervise/tests/run_artifacts.rs | 1 + src/worktree/part2.rs | 4 +- src/worktree/part3.rs | 40 ++++++- src/worktree/tests.rs | 170 ++++++++++++++++++++++++++- tests/supervise_cli.rs | 10 +- 7 files changed, 223 insertions(+), 34 deletions(-) diff --git a/README.md b/README.md index 999e0561..2693b56e 100644 --- a/README.md +++ b/README.md @@ -1017,8 +1017,14 @@ alias behavior is not established as supported, so such roots should not be decl The mount policy is intentionally conservative and does not claim to detect every conceivable physical alias. -Default linked worktrees are created outside the repository at -`../.maco/worktrees//`. Completed task branches can be +Default linked worktrees are created inside the repository at the stable path +`.worktrees/`. An explicit `--worktree-root` still overrides the root. +Existing lanes are not moved or migrated: to keep creating lanes in the old +layout, pass `--worktree-root ../.maco/worktrees/` with the original +sanitized repository group name. Pass that same root to GC or lifecycle commands +when selecting the old root explicitly. Without an explicit root, GC still +considers authenticated registered lanes across roots, but searches for +unregistered leftovers only under the new default. Completed task branches can be cleaned with `maco worktree gc`; branch refs remain available for later recreation, while dirty, claimed, or leased worktrees are left in place. @@ -2346,8 +2352,8 @@ cargo run -- worktree sweep --workspace /exact/path/to/workspace \ --apply --keep-targets ``` -The sweep discovers workspace-managed `.maco/worktrees/` roots through the -same default-root function used by managed creation. It also recognizes the +The sweep still authenticates legacy workspace-managed `.maco/worktrees/` +roots against their original layout. It also recognizes the new creation default: exact canonical `.worktrees` child of either the workspace repository itself or a direct repository child; custom per-creation roots have no persisted repository-level configuration to discover. The aggregate report identifies @@ -2362,11 +2368,11 @@ root does not stop later roots from being inspected. Retention flags and `--max-count` and `--max-total-bytes` are per-root limits and dirty worktrees or lanes with active leases or claims remain protected. Existing machine-global quarantine gates also remain in force; the sweep does not weaken them. A workspace-managed group -is associated only when it is the exact result of the creation default-root -function. In particular, `/.maco/worktrees` is not adopted as that -repository's managed root because its creation default is -`/.maco/worktrees/`; sweep the parent workspace for that -layout, or use the separately validated `/.worktrees` convention. +is associated only with its exact legacy root +`/.maco/worktrees/`; sweep the parent workspace +for that layout. `/.maco/worktrees` is not adopted as a managed root. +Sweep the repository itself or its parent workspace for the default +`/.worktrees` layout. Repository-local dry-runs also preview healthy Git-registered lanes that predate the authenticated MACO worktree registry. A stale linked-worktree child cannot diff --git a/src/cli/part2.rs b/src/cli/part2.rs index 44101c6b..3b8a8ddc 100644 --- a/src/cli/part2.rs +++ b/src/cli/part2.rs @@ -536,7 +536,7 @@ struct CreateWorktreeArgs { /// Base revision used when creating a new branch. Defaults to HEAD. #[arg(long)] base: Option, - /// Parent directory for agent worktrees. + /// Parent directory for agent worktrees. Defaults to /.worktrees. #[arg(long)] worktree_root: Option, /// After creation, remove eligible older clean worktrees older than this many seconds. @@ -567,7 +567,7 @@ struct GcWorktreeArgs { /// Repository path. #[arg(long, default_value = ".")] repo: PathBuf, - /// Parent directory for agent worktrees. + /// Parent directory for agent worktrees. Defaults to /.worktrees. #[arg(long)] worktree_root: Option, /// List cleanup actions without removing anything. @@ -607,7 +607,7 @@ struct GcWorktreeArgs { #[derive(Debug, Args)] struct SweepWorktreeArgs { - /// Workspace containing .maco/worktrees// directories. + /// Repository or workspace containing /.worktrees or legacy .maco/worktrees/ roots. #[arg(long)] workspace: PathBuf, /// Apply cleanup. Without this flag, the sweep is a dry-run. @@ -662,7 +662,7 @@ struct LifecycleWorktreeArgs { /// Schedule the bounded O2-launch run-artifact retention profile. #[arg(long)] o2_launch_retention: bool, - /// Parent directory for agent worktrees. + /// Parent directory for agent worktrees. Defaults to /.worktrees. #[arg(long)] worktree_root: Option, /// Remove only eligible clean worktrees older than this many seconds. diff --git a/src/supervise/tests/run_artifacts.rs b/src/supervise/tests/run_artifacts.rs index 6665833d..ee58f931 100644 --- a/src/supervise/tests/run_artifacts.rs +++ b/src/supervise/tests/run_artifacts.rs @@ -3457,6 +3457,7 @@ fn verified_run_entry_refuses_dirty_repository_before_assignment_creation() { .join(".maco/o2/runs/verified-capability-dirty-primary") .exists()); assert!(!temp.path().join(".maco/worktrees/repo/child-a").exists()); + assert!(!repo_path.join(".worktrees/child-a").exists()); assert!(crate::git_repository::open(&repo_path) .expect("reopen dirty primary") .find_branch("maco/child-a", git2::BranchType::Local) diff --git a/src/worktree/part2.rs b/src/worktree/part2.rs index d1e3b019..0e2db4fe 100644 --- a/src/worktree/part2.rs +++ b/src/worktree/part2.rs @@ -1661,14 +1661,14 @@ fn validate_primary_sweep_association( ) })?; let expected_group_root = match root_kind { - WorktreeSweepRootKind::WorkspaceManaged => default_worktree_root(primary), + WorktreeSweepRootKind::WorkspaceManaged => legacy_workspace_worktree_root(primary), WorktreeSweepRootKind::RepositoryLocal => canonical_primary.join(".worktrees"), }; let canonical_expected_root = fs::canonicalize(&expected_group_root).map_err(|error| { sweep_failure( WorktreeSweepFailureKind::RepositoryAssociation, anyhow::Error::new(error) - .context("failed to resolve primary repository default worktree root"), + .context("failed to resolve primary repository expected sweep root"), ) })?; if canonical_group_root != canonical_expected_root { diff --git a/src/worktree/part3.rs b/src/worktree/part3.rs index fc4b16f0..7031deb1 100644 --- a/src/worktree/part3.rs +++ b/src/worktree/part3.rs @@ -227,6 +227,13 @@ fn git_registered_worktree_names( } }; if path.parent() == Some(worktree_root) { + let name = path + .file_name() + .and_then(OsStr::to_str) + .context("Git-registered worktree has a non-UTF-8 child name")?; + if normalize_agent_id(name)? != name { + bail!("Git-registered worktree has a noncanonical child name: {name}"); + } names.insert(name.to_string()); } } @@ -254,12 +261,26 @@ fn git_registered_worktree_names_for_reconciliation( let worktree = repo.find_worktree(name).with_context(|| { format!("failed to inspect Git worktree '{name}' during startup reconciliation") })?; - let path = worktree.path(); - let belongs_to_root = path.parent() == Some(worktree_root) - || fs::canonicalize(path) - .ok() - .is_some_and(|canonical| canonical.parent() == Some(worktree_root)); - if belongs_to_root { + let path = match fs::canonicalize(worktree.path()) { + Ok(path) => path, + Err(error) if error.kind() == ErrorKind::NotFound => continue, + Err(error) => { + return Err(error).with_context(|| { + format!( + "failed to resolve Git worktree path {} during startup reconciliation", + worktree.path().display() + ) + }) + } + }; + if path.parent() == Some(worktree_root) { + let name = path + .file_name() + .and_then(OsStr::to_str) + .context("Git-registered worktree has a non-UTF-8 child name")?; + if normalize_agent_id(name)? != name { + bail!("Git-registered worktree has a noncanonical child name: {name}"); + } names.insert(name.to_string()); } } @@ -4107,6 +4128,13 @@ fn is_reserved_worktree_root_child(name: impl AsRef) -> bool { } fn default_worktree_root(repo: &Repository) -> PathBuf { + repo.workdir() + .unwrap_or_else(|| repo.path()) + .join(".worktrees") +} + +// Authenticate the pre-repository-local layout without changing creation defaults. +fn legacy_workspace_worktree_root(repo: &Repository) -> PathBuf { let repo_root = repo.workdir().unwrap_or_else(|| repo.path()); let repo_name = repo_root .file_name() diff --git a/src/worktree/tests.rs b/src/worktree/tests.rs index d25f80f0..15ebd0ec 100644 --- a/src/worktree/tests.rs +++ b/src/worktree/tests.rs @@ -2,6 +2,162 @@ use super::*; use git2::{Oid, Signature}; use tempfile::TempDir; +fn renamed_manual_worktree(temp: &TempDir) -> (PathBuf, PathBuf) { + let repo_path = temp.path().join("repo"); + WorktreeManager::init_repository(&repo_path, "main").expect("init repo"); + let repo = crate::git_repository::open(&repo_path).expect("open repo"); + commit_readme(&repo).expect("initial commit"); + let root = default_worktree_root(&repo); + fs::create_dir_all(&root).expect("manual worktree root"); + let renamed = root.join("manual-renamed"); + // A renamed lane keeps its original Git metadata name. + repo.worktree("manual-original", &renamed, None) + .expect("create Git-only manual worktree with a different directory name"); + (repo_path, renamed) +} + +#[test] +fn registered_manual_worktree_names_follow_renamed_directory() { + let temp = TempDir::new().expect("tempdir"); + let (repo_path, lane) = renamed_manual_worktree(&temp); + let repo = crate::git_repository::open(&repo_path).expect("open repo"); + assert_eq!( + fs::canonicalize( + repo.find_worktree("manual-original") + .expect("registration") + .path() + ) + .expect("registered path"), + fs::canonicalize(&lane).expect("lane path") + ); + let root = fs::canonicalize(lane.parent().expect("root")).expect("canonical root"); + let expected = BTreeSet::from(["manual-renamed".to_string()]); + assert_eq!( + git_registered_worktree_names(&repo, &root).expect("GC names"), + expected + ); + assert_eq!( + git_registered_worktree_names_for_reconciliation(&repo, &root) + .expect("reconciliation names"), + expected + ); + let outside = fs::canonicalize(temp.path()).expect("outside root"); + assert!(git_registered_worktree_names(&repo, &outside) + .expect("outside GC names") + .is_empty()); + assert!( + git_registered_worktree_names_for_reconciliation(&repo, &outside) + .expect("outside reconciliation names") + .is_empty() + ); +} + +#[cfg(target_os = "linux")] +#[test] +fn registered_manual_worktree_is_not_an_orphan_and_reconciliation_protects_it() { + let temp = TempDir::new().expect("tempdir"); + let (repo_path, lane) = renamed_manual_worktree(&temp); + let repo = crate::git_repository::open(&repo_path).expect("open repo"); + let root = fs::canonicalize(lane.parent().expect("root")).expect("canonical root"); + let gc = WorktreeManager::new(&repo_path) + .gc(gc_options(Some(root.clone()), true)) + .expect("registered manual lane must not be an orphan"); + assert_eq!(gc.orphan_removed_count, 0); + assert!(gc.entries.is_empty()); + let reconciliation = reconcile_managed_worktree_lifecycle(&repo, Some(root), true, true, None) + .expect("registered manual lane needs no quarantine binding"); + assert_eq!(reconciliation.quarantined_directory_count, 0); + assert_eq!(reconciliation.entries.len(), 1); + assert_eq!(reconciliation.entries[0].name, "manual-renamed"); + assert_eq!( + reconciliation.entries[0].state, + WorktreeReconciliationState::Ambiguous + ); + assert_eq!( + reconciliation.entries[0].action, + WorktreeReconciliationAction::Protected + ); + assert!(lane.exists()); +} + +#[test] +fn default_worktree_root_is_stable_and_repository_local() { + let temp = TempDir::new().expect("tempdir"); + let repo_path = temp.path().join("repo+name"); + WorktreeManager::init_repository(&repo_path, "main").expect("init repo"); + let repo = crate::git_repository::open(&repo_path).expect("open repo"); + let expected = repo.workdir().expect("workdir").join(".worktrees"); + + assert_eq!(default_worktree_root(&repo), expected); + assert_eq!( + resolve_worktree_root(&repo, None).expect("default root"), + expected + ); + assert_eq!(default_worktree_root(&repo), expected); + assert!( + !expected.exists(), + "resolving must not create or migrate state" + ); + assert!(!temp.path().join(".maco").exists()); +} + +#[test] +fn default_worktree_root_preserves_explicit_overrides() { + let temp = TempDir::new().expect("tempdir"); + let repo_path = temp.path().join("repo"); + WorktreeManager::init_repository(&repo_path, "main").expect("init repo"); + let repo = crate::git_repository::open(&repo_path).expect("open repo"); + let absolute = temp.path().join("custom-worktrees"); + assert_eq!( + resolve_worktree_root(&repo, Some(absolute.clone())).expect("absolute override"), + absolute + ); + assert_eq!( + resolve_worktree_root(&repo, Some(PathBuf::from("custom-worktrees"))) + .expect("relative override"), + repo.workdir().expect("workdir").join("custom-worktrees") + ); + assert!(!absolute.exists()); + assert!(!default_worktree_root(&repo).exists()); +} + +#[test] +fn sweep_root_association_preserves_exact_legacy_and_repository_local_layouts() { + let temp = TempDir::new().expect("tempdir"); + let repo_path = temp.path().join("repo+name"); + WorktreeManager::init_repository(&repo_path, "main").expect("init repo"); + let repo = crate::git_repository::open(&repo_path).expect("open repo"); + let workspace = fs::canonicalize(temp.path()).expect("workspace"); + let legacy = workspace.join(".maco/worktrees/repo_name"); + let local = default_worktree_root(&repo); + fs::create_dir_all(&legacy).expect("legacy root"); + fs::create_dir_all(&local).expect("local root"); + assert_eq!( + fs::canonicalize(legacy_workspace_worktree_root(&repo)).expect("canonical legacy root"), + legacy + ); + + for (root, kind) in [ + (&legacy, WorktreeSweepRootKind::WorkspaceManaged), + (&local, WorktreeSweepRootKind::RepositoryLocal), + ] { + validate_primary_sweep_association(&workspace, root, &repo_path, &repo, None, kind) + .expect("exact layout must retain repository association"); + } + for (root, kind) in [ + (&local, WorktreeSweepRootKind::WorkspaceManaged), + (&legacy, WorktreeSweepRootKind::RepositoryLocal), + ] { + let failure = + validate_primary_sweep_association(&workspace, root, &repo_path, &repo, None, kind) + .expect_err("root kinds must not adopt the other layout"); + assert_eq!( + failure.kind, + WorktreeSweepFailureKind::RepositoryAssociation + ); + } +} + #[cfg(unix)] #[test] fn bounded_status_parsers_are_lossless_and_fail_closed() { @@ -1594,11 +1750,15 @@ fn workspace_sweep_discovers_repository_local_worktree_root() { let repo = crate::git_repository::open(&repo_path).expect("open repo"); commit_readme(&repo).expect("initial commit"); let worktree_root = repo_path.join(".worktrees"); - let created = create_gc_worktree( - &WorktreeManager::new(&repo_path), - "repo-local-lane", - &worktree_root, - ); + let created = WorktreeManager::new(&repo_path) + .create_for_test(WorktreeCreateOptions { + agent_id: "repo-local-lane".to_string(), + branch: None, + base: None, + worktree_root: None, + }) + .expect("create in default repository-local root"); + assert_eq!(created.path, worktree_root.join("repo-local-lane")); let report = sweep_workspace_worktrees(workspace_sweep_options(&repo_path, false)) .expect("sweep repository-local root"); diff --git a/tests/supervise_cli.rs b/tests/supervise_cli.rs index b826be1e..416f83b0 100644 --- a/tests/supervise_cli.rs +++ b/tests/supervise_cli.rs @@ -1853,10 +1853,7 @@ fn supervise_run_rejects_zero_bound_before_reserving_state_and_accepts_one() -> ])?; assert!(zero_stderr.contains("--max-concurrent-children must be at least 1")); assert!(!repo_path.join(".maco/o2/runs/bounded-zero").exists()); - assert!(!temp - .path() - .join(".maco/worktrees/repo/bounded-child") - .exists()); + assert!(!repo_path.join(".worktrees/bounded-child").exists()); let repo = Repository::open(&repo_path)?; assert!(repo .find_branch("maco/bounded-child", git2::BranchType::Local) @@ -2266,10 +2263,7 @@ fn supervise_run_refuses_clean_stale_reused_child_worktree_before_execution() -> .as_str() .context("managed child worktree path")?, ); - assert_eq!( - child_path, - temp.path().join(".maco/worktrees/repo/child-clean") - ); + assert_eq!(child_path, repo_path.join(".worktrees/child-clean")); assert!(child_path.is_dir()); fs::write(repo_path.join("README.md"), "# advanced\n")?; let repo = Repository::open(&repo_path)?; From 65c39ad9cd044db30c1d3f39151e6f2eaf7e4686 Mon Sep 17 00:00:00 2001 From: Meta-Develop <116134763+Meta-Develop@users.noreply.github.com> Date: Thu, 1 Oct 2026 02:04:34 +0900 Subject: [PATCH 2/6] fix: keep managed lanes in project runtime namespace --- README.md | 24 +++-- src/cli/part2.rs | 8 +- src/supervise/tests/acceptance_integrity.rs | 79 ++++++++++++++ src/supervise/tests/run_artifacts.rs | 2 +- src/worktree.rs | 1 + src/worktree/part2.rs | 59 +++++++++-- src/worktree/part3.rs | 10 +- src/worktree/tests.rs | 110 +++++++++++++++++--- tests/supervise_cli.rs | 9 +- 9 files changed, 263 insertions(+), 39 deletions(-) diff --git a/README.md b/README.md index 2693b56e..12a60a9e 100644 --- a/README.md +++ b/README.md @@ -1018,7 +1018,10 @@ The mount policy is intentionally conservative and does not claim to detect ever conceivable physical alias. Default linked worktrees are created inside the repository at the stable path -`.worktrees/`. An explicit `--worktree-root` still overrides the root. +`.maco/worktrees//`. This keeps generated lanes and +their Cargo config in the existing runtime-owned area without exempting manual +`.worktrees` content from primary integrity checks. An explicit `--worktree-root` +still overrides the root. Existing lanes are not moved or migrated: to keep creating lanes in the old layout, pass `--worktree-root ../.maco/worktrees/` with the original sanitized repository group name. Pass that same root to GC or lifecycle commands @@ -2352,10 +2355,11 @@ cargo run -- worktree sweep --workspace /exact/path/to/workspace \ --apply --keep-targets ``` -The sweep still authenticates legacy workspace-managed `.maco/worktrees/` -roots against their original layout. It also recognizes the new creation default: -exact canonical `.worktrees` child of either the workspace repository itself or -a direct repository child; custom per-creation roots have no persisted +The sweep authenticates grouped roots at the exact creation default +`/.maco/worktrees/` and the legacy parent-workspace +`/.maco/worktrees/` layout. It also recognizes +the exact canonical manual `.worktrees` child of either the workspace repository +itself or a direct repository child; custom per-creation roots have no persisted repository-level configuration to discover. The aggregate report identifies each root kind and distinguishes roots that were inspected from roots skipped before GC and roots whose GC attempt failed. `discovery_status` is @@ -2368,11 +2372,11 @@ root does not stop later roots from being inspected. Retention flags and `--max-count` and `--max-total-bytes` are per-root limits and dirty worktrees or lanes with active leases or claims remain protected. Existing machine-global quarantine gates also remain in force; the sweep does not weaken them. A workspace-managed group -is associated only with its exact legacy root -`/.maco/worktrees/`; sweep the parent workspace -for that layout. `/.maco/worktrees` is not adopted as a managed root. -Sweep the repository itself or its parent workspace for the default -`/.worktrees` layout. +is associated only with one of those exact grouped roots. A primary that is the +workspace itself is admitted only for its exact new default group; the parent +workspace still discovers legacy groups and each direct child's new default. +`/.maco/worktrees` itself is not adopted as a lane root, and the sweep does +not search deeper repository descendants. Repository-local dry-runs also preview healthy Git-registered lanes that predate the authenticated MACO worktree registry. A stale linked-worktree child cannot diff --git a/src/cli/part2.rs b/src/cli/part2.rs index 3b8a8ddc..84c8743f 100644 --- a/src/cli/part2.rs +++ b/src/cli/part2.rs @@ -536,7 +536,7 @@ struct CreateWorktreeArgs { /// Base revision used when creating a new branch. Defaults to HEAD. #[arg(long)] base: Option, - /// Parent directory for agent worktrees. Defaults to /.worktrees. + /// Parent directory for agent worktrees. Defaults to /.maco/worktrees/. #[arg(long)] worktree_root: Option, /// After creation, remove eligible older clean worktrees older than this many seconds. @@ -567,7 +567,7 @@ struct GcWorktreeArgs { /// Repository path. #[arg(long, default_value = ".")] repo: PathBuf, - /// Parent directory for agent worktrees. Defaults to /.worktrees. + /// Parent directory for agent worktrees. Defaults to /.maco/worktrees/. #[arg(long)] worktree_root: Option, /// List cleanup actions without removing anything. @@ -607,7 +607,7 @@ struct GcWorktreeArgs { #[derive(Debug, Args)] struct SweepWorktreeArgs { - /// Repository or workspace containing /.worktrees or legacy .maco/worktrees/ roots. + /// Repository or workspace containing grouped .maco/worktrees roots or manual /.worktrees roots. #[arg(long)] workspace: PathBuf, /// Apply cleanup. Without this flag, the sweep is a dry-run. @@ -662,7 +662,7 @@ struct LifecycleWorktreeArgs { /// Schedule the bounded O2-launch run-artifact retention profile. #[arg(long)] o2_launch_retention: bool, - /// Parent directory for agent worktrees. Defaults to /.worktrees. + /// Parent directory for agent worktrees. Defaults to /.maco/worktrees/. #[arg(long)] worktree_root: Option, /// Remove only eligible clean worktrees older than this many seconds. diff --git a/src/supervise/tests/acceptance_integrity.rs b/src/supervise/tests/acceptance_integrity.rs index 6f44a3e2..d01d5996 100644 --- a/src/supervise/tests/acceptance_integrity.rs +++ b/src/supervise/tests/acceptance_integrity.rs @@ -1630,6 +1630,85 @@ fn primary_snapshot_verified_rejects_unsafe_or_inaccessible_state_paths() { } } +#[cfg(target_os = "linux")] +#[test] +fn primary_snapshot_allows_managed_default_creation_and_still_detects_primary_edits() { + skip_without_containment!(); + let (_temp, repo_path) = injected_repository(); + fs::create_dir_all(repo_path.join(".maco")).expect("runtime directory"); + fs::write(repo_path.join(".maco/tracked.txt"), "tracked runtime\n") + .expect("tracked runtime file"); + commit_injected_repository(&repo_path, "track runtime file"); + fs::write(repo_path.join(".gitignore"), "ignored.txt\n").expect("ignore ordinary primary file"); + commit_injected_repository(&repo_path, "track ignore rule"); + fs::write(repo_path.join("ignored.txt"), "ignored baseline\n").expect("ignored primary file"); + run_injected_git( + &repo_path, + &["update-index", "--skip-worktree", "README.md"], + ); + let before = primary_worktree_snapshot( + &repo_path, + SupervisorExecutionRuntime::NonpublishableSimulation, + ) + .expect("primary baseline"); + let lane = WorktreeManager::new(&repo_path) + .create_for_test(crate::worktree::WorktreeCreateOptions { + agent_id: "integrity-child".to_string(), + branch: None, + base: None, + worktree_root: None, + }) + .expect("create authenticated managed lane"); + assert_eq!( + lane.path, + repo_path.join(".maco/worktrees/repo/integrity-child") + ); + assert!(repo_path + .join(".maco/worktrees/repo/.cargo/config.toml") + .is_file()); + let generated = primary_worktree_snapshot( + &repo_path, + SupervisorExecutionRuntime::NonpublishableSimulation, + ) + .expect("snapshot generated managed runtime"); + assert!(primary_integrity_changes(&before, &generated).is_empty()); + fs::write( + repo_path.join("README.md"), + "changed status-hidden primary file\n", + ) + .expect("primary edit"); + fs::write( + repo_path.join(".maco/tracked.txt"), + "changed tracked runtime\n", + ) + .expect("tracked runtime edit"); + fs::write(repo_path.join("ignored.txt"), "changed ignored primary\n") + .expect("ignored primary edit"); + fs::create_dir_all(repo_path.join(".worktrees")).expect("manual root"); + fs::write( + repo_path.join(".worktrees/foreign.txt"), + "ordinary primary content\n", + ) + .expect("foreign content"); + let after = primary_worktree_snapshot( + &repo_path, + SupervisorExecutionRuntime::NonpublishableSimulation, + ) + .expect("snapshot primary edits"); + let changes = primary_integrity_changes(&generated, &after); + for path in [ + "README.md", + ".maco/tracked.txt", + "ignored.txt", + ".worktrees/foreign.txt", + ] { + assert!( + changes.paths.contains(&PathBuf::from(path)), + "primary guard lost {path}: {changes:?}" + ); + } +} + #[test] fn primary_snapshot_detects_changes_to_preexisting_dirty_untracked_and_tracked_runtime_paths() { let (_temp, repo_path) = injected_repository(); diff --git a/src/supervise/tests/run_artifacts.rs b/src/supervise/tests/run_artifacts.rs index ee58f931..3fec18b1 100644 --- a/src/supervise/tests/run_artifacts.rs +++ b/src/supervise/tests/run_artifacts.rs @@ -3457,7 +3457,7 @@ fn verified_run_entry_refuses_dirty_repository_before_assignment_creation() { .join(".maco/o2/runs/verified-capability-dirty-primary") .exists()); assert!(!temp.path().join(".maco/worktrees/repo/child-a").exists()); - assert!(!repo_path.join(".worktrees/child-a").exists()); + assert!(!repo_path.join(".maco/worktrees/repo/child-a").exists()); assert!(crate::git_repository::open(&repo_path) .expect("reopen dirty primary") .find_branch("maco/child-a", git2::BranchType::Local) diff --git a/src/worktree.rs b/src/worktree.rs index e289969b..e9aba344 100644 --- a/src/worktree.rs +++ b/src/worktree.rs @@ -4400,6 +4400,7 @@ pub fn sweep_workspace_worktrees(options: WorktreeSweepOptions) -> Result { + candidates.push(workspace.to_path_buf()) + } + Ok(_) => {} + Err(error) if error.kind() == ErrorKind::NotFound => {} + Err(error) => { + return Err(sweep_failure( + WorktreeSweepFailureKind::RepositoryOpen, + anyhow::Error::new(error) + .context("failed to inspect workspace primary repository Git metadata"), + )) + } + } + } for child_name in child_names { if child_name == OsStr::new(".maco") { continue; @@ -1641,8 +1674,25 @@ fn validate_primary_sweep_association( message: "lane and primary repository common directories do not match".to_string(), }); } + let canonical_group_root = fs::canonicalize(group_root).map_err(|error| { + sweep_failure( + WorktreeSweepFailureKind::RepositoryAssociation, + anyhow::Error::new(error).context("failed to resolve workspace worktree group"), + ) + })?; + let project_managed_root = canonical_primary.join(".maco").join("worktrees").join( + canonical_primary + .file_name() + .and_then(OsStr::to_str) + .map(sanitize_path_segment) + .unwrap_or_else(|| "repository".to_string()), + ); + let is_project_managed_root = canonical_group_root == project_managed_root; let primary_is_in_scope = match root_kind { - WorktreeSweepRootKind::WorkspaceManaged => canonical_primary.parent() == Some(workspace), + WorktreeSweepRootKind::WorkspaceManaged => { + canonical_primary.parent() == Some(workspace) + || (canonical_primary == workspace && is_project_managed_root) + } WorktreeSweepRootKind::RepositoryLocal => { canonical_primary == workspace || canonical_primary.parent() == Some(workspace) } @@ -1654,13 +1704,8 @@ fn validate_primary_sweep_association( .to_string(), }); } - let canonical_group_root = fs::canonicalize(group_root).map_err(|error| { - sweep_failure( - WorktreeSweepFailureKind::RepositoryAssociation, - anyhow::Error::new(error).context("failed to resolve workspace worktree group"), - ) - })?; let expected_group_root = match root_kind { + WorktreeSweepRootKind::WorkspaceManaged if is_project_managed_root => project_managed_root, WorktreeSweepRootKind::WorkspaceManaged => legacy_workspace_worktree_root(primary), WorktreeSweepRootKind::RepositoryLocal => canonical_primary.join(".worktrees"), }; diff --git a/src/worktree/part3.rs b/src/worktree/part3.rs index 7031deb1..4e9acd99 100644 --- a/src/worktree/part3.rs +++ b/src/worktree/part3.rs @@ -4128,9 +4128,13 @@ fn is_reserved_worktree_root_child(name: impl AsRef) -> bool { } fn default_worktree_root(repo: &Repository) -> PathBuf { - repo.workdir() - .unwrap_or_else(|| repo.path()) - .join(".worktrees") + let repo_root = repo.workdir().unwrap_or_else(|| repo.path()); + let repo_name = repo_root + .file_name() + .and_then(|name| name.to_str()) + .map(sanitize_path_segment) + .unwrap_or_else(|| "repository".to_string()); + repo_root.join(".maco").join("worktrees").join(repo_name) } // Authenticate the pre-repository-local layout without changing creation defaults. diff --git a/src/worktree/tests.rs b/src/worktree/tests.rs index 15ebd0ec..3a8d9c40 100644 --- a/src/worktree/tests.rs +++ b/src/worktree/tests.rs @@ -7,7 +7,7 @@ fn renamed_manual_worktree(temp: &TempDir) -> (PathBuf, PathBuf) { WorktreeManager::init_repository(&repo_path, "main").expect("init repo"); let repo = crate::git_repository::open(&repo_path).expect("open repo"); commit_readme(&repo).expect("initial commit"); - let root = default_worktree_root(&repo); + let root = repo.workdir().expect("workdir").join(".worktrees"); fs::create_dir_all(&root).expect("manual worktree root"); let renamed = root.join("manual-renamed"); // A renamed lane keeps its original Git metadata name. @@ -86,7 +86,10 @@ fn default_worktree_root_is_stable_and_repository_local() { let repo_path = temp.path().join("repo+name"); WorktreeManager::init_repository(&repo_path, "main").expect("init repo"); let repo = crate::git_repository::open(&repo_path).expect("open repo"); - let expected = repo.workdir().expect("workdir").join(".worktrees"); + let expected = repo + .workdir() + .expect("workdir") + .join(".maco/worktrees/repo_name"); assert_eq!(default_worktree_root(&repo), expected); assert_eq!( @@ -129,9 +132,20 @@ fn sweep_root_association_preserves_exact_legacy_and_repository_local_layouts() let repo = crate::git_repository::open(&repo_path).expect("open repo"); let workspace = fs::canonicalize(temp.path()).expect("workspace"); let legacy = workspace.join(".maco/worktrees/repo_name"); - let local = default_worktree_root(&repo); - fs::create_dir_all(&legacy).expect("legacy root"); + let managed = default_worktree_root(&repo); + let local = repo.workdir().expect("workdir").join(".worktrees"); + fs::create_dir_all(&managed).expect("project managed root"); fs::create_dir_all(&local).expect("local root"); + validate_primary_sweep_association( + &workspace, + &managed, + &repo_path, + &repo, + None, + WorktreeSweepRootKind::WorkspaceManaged, + ) + .expect("project root does not require a legacy root to exist"); + fs::create_dir_all(&legacy).expect("legacy root"); assert_eq!( fs::canonicalize(legacy_workspace_worktree_root(&repo)).expect("canonical legacy root"), legacy @@ -139,6 +153,7 @@ fn sweep_root_association_preserves_exact_legacy_and_repository_local_layouts() for (root, kind) in [ (&legacy, WorktreeSweepRootKind::WorkspaceManaged), + (&managed, WorktreeSweepRootKind::WorkspaceManaged), (&local, WorktreeSweepRootKind::RepositoryLocal), ] { validate_primary_sweep_association(&workspace, root, &repo_path, &repo, None, kind) @@ -147,6 +162,7 @@ fn sweep_root_association_preserves_exact_legacy_and_repository_local_layouts() for (root, kind) in [ (&local, WorktreeSweepRootKind::WorkspaceManaged), (&legacy, WorktreeSweepRootKind::RepositoryLocal), + (&managed, WorktreeSweepRootKind::RepositoryLocal), ] { let failure = validate_primary_sweep_association(&workspace, root, &repo_path, &repo, None, kind) @@ -156,6 +172,79 @@ fn sweep_root_association_preserves_exact_legacy_and_repository_local_layouts() WorktreeSweepFailureKind::RepositoryAssociation ); } + let primary = fs::canonicalize(&repo_path).expect("primary"); + let managed = fs::canonicalize(&managed).expect("managed root"); + for scope in [&workspace, &primary] { + assert_eq!( + resolve_sweep_repository( + scope, + &managed, + "repo_name", + WorktreeSweepRootKind::WorkspaceManaged, + None, + ) + .expect("resolve empty exact project managed root"), + primary + ); + } + assert!( + validate_primary_sweep_association( + &primary, + &legacy, + &repo_path, + &repo, + None, + WorktreeSweepRootKind::WorkspaceManaged, + ) + .is_err(), + "self-workspace must not adopt the parent legacy root" + ); + assert!( + validate_primary_sweep_association( + workspace.parent().expect("ancestor workspace"), + &managed, + &repo_path, + &repo, + None, + WorktreeSweepRootKind::WorkspaceManaged, + ) + .is_err(), + "new default must not admit a more distant workspace ancestor" + ); + let discovered = + discover_repository_local_sweep_roots(&workspace).expect("discover child roots"); + assert_eq!( + discovered + .iter() + .filter(|candidate| candidate.worktree_root == managed) + .count(), + 1 + ); + commit_readme(&repo).expect("initial commit"); + repo.worktree("managed-lane", &managed.join("managed-lane"), None) + .expect("registered managed-root lane"); + assert_eq!( + resolve_sweep_repository( + &primary, + &managed, + "repo_name", + WorktreeSweepRootKind::WorkspaceManaged, + None, + ) + .expect("resolve exact project root from lane metadata"), + primary + ); + WorktreeManager::init_repository(primary.join("repo+name"), "main") + .expect("same-group nested primary"); + let failure = resolve_sweep_repository( + &primary, + &managed, + "repo_name", + WorktreeSweepRootKind::WorkspaceManaged, + None, + ) + .expect_err("self and child candidates must remain ambiguous"); + assert_eq!(failure.kind, WorktreeSweepFailureKind::AmbiguousRepository); } #[cfg(unix)] @@ -1750,14 +1839,11 @@ fn workspace_sweep_discovers_repository_local_worktree_root() { let repo = crate::git_repository::open(&repo_path).expect("open repo"); commit_readme(&repo).expect("initial commit"); let worktree_root = repo_path.join(".worktrees"); - let created = WorktreeManager::new(&repo_path) - .create_for_test(WorktreeCreateOptions { - agent_id: "repo-local-lane".to_string(), - branch: None, - base: None, - worktree_root: None, - }) - .expect("create in default repository-local root"); + let created = create_gc_worktree( + &WorktreeManager::new(&repo_path), + "repo-local-lane", + &worktree_root, + ); assert_eq!(created.path, worktree_root.join("repo-local-lane")); let report = sweep_workspace_worktrees(workspace_sweep_options(&repo_path, false)) diff --git a/tests/supervise_cli.rs b/tests/supervise_cli.rs index 416f83b0..89145199 100644 --- a/tests/supervise_cli.rs +++ b/tests/supervise_cli.rs @@ -1853,7 +1853,9 @@ fn supervise_run_rejects_zero_bound_before_reserving_state_and_accepts_one() -> ])?; assert!(zero_stderr.contains("--max-concurrent-children must be at least 1")); assert!(!repo_path.join(".maco/o2/runs/bounded-zero").exists()); - assert!(!repo_path.join(".worktrees/bounded-child").exists()); + assert!(!repo_path + .join(".maco/worktrees/repo/bounded-child") + .exists()); let repo = Repository::open(&repo_path)?; assert!(repo .find_branch("maco/bounded-child", git2::BranchType::Local) @@ -2263,7 +2265,10 @@ fn supervise_run_refuses_clean_stale_reused_child_worktree_before_execution() -> .as_str() .context("managed child worktree path")?, ); - assert_eq!(child_path, repo_path.join(".worktrees/child-clean")); + assert_eq!( + child_path, + repo_path.join(".maco/worktrees/repo/child-clean") + ); assert!(child_path.is_dir()); fs::write(repo_path.join("README.md"), "# advanced\n")?; let repo = Repository::open(&repo_path)?; From 96b5c80dbe6e6657b9eec689e74f66ff058995db Mon Sep 17 00:00:00 2001 From: Meta-Develop <116134763+Meta-Develop@users.noreply.github.com> Date: Thu, 1 Oct 2026 02:39:03 +0900 Subject: [PATCH 3/6] test: show aggregate orchestration validation failures --- tests/cli_smoke.rs | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/tests/cli_smoke.rs b/tests/cli_smoke.rs index a96f4dbd..d4913c40 100644 --- a/tests/cli_smoke.rs +++ b/tests/cli_smoke.rs @@ -1502,7 +1502,11 @@ fn cli_orchestrate_reports_committed_agent_change_and_patch() -> Result<()> { // candidate patches may still be exported for inspection. Pin the // fail-closed boundary: a failed summary and no primary mutation. assert_eq!(summary["success"], false); - assert_eq!(summary["agents"][0]["status"], "failed"); + assert_eq!( + summary["agents"][0]["status"], "failed", + "unexpected aggregate failure: {}", + summary["repo_validation"] + ); assert!(summary["agents"][0]["error"].as_str().is_some()); assert_eq!( fs::read_to_string(repo_path.join("README.md"))?, @@ -2281,7 +2285,11 @@ fn run_json_regardless(args: [&str; N]) -> Result<(Value, bool)> fn assert_orchestration_failed_closed(summary: &Value) -> Result<()> { assert_eq!(summary["success"], false); - assert_eq!(summary["agents"][0]["status"], "failed"); + assert_eq!( + summary["agents"][0]["status"], "failed", + "unexpected aggregate failure: {}", + summary["repo_validation"] + ); let error = summary["agents"][0]["error"] .as_str() .context("orchestration error")?; From 88f405f23c2212940688c5c7a795353779170d36 Mon Sep 17 00:00:00 2001 From: Meta-Develop <116134763+Meta-Develop@users.noreply.github.com> Date: Thu, 1 Oct 2026 04:05:37 +0900 Subject: [PATCH 4/6] fix: exclude implicit managed storage locally --- README.md | 6 +- src/worktree.rs | 12 + src/worktree/part3.rs | 785 ++++++++++++++++++++++++++++++++++++++++++ src/worktree/tests.rs | 557 ++++++++++++++++++++++++++++++ 4 files changed, 1359 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 12a60a9e..1a67587d 100644 --- a/README.md +++ b/README.md @@ -1021,7 +1021,11 @@ Default linked worktrees are created inside the repository at the stable path `.maco/worktrees//`. This keeps generated lanes and their Cargo config in the existing runtime-owned area without exempting manual `.worktrees` content from primary integrity checks. An explicit `--worktree-root` -still overrides the root. +still overrides the root. Implicit default creation registers only +`/.maco/worktrees//` in checkout-local Git `info/exclude` +before creating lanes. Conflicting operator negations and unadmitted local Git +config indirection are refused without changing the exclude file. Explicit +`--worktree-root` paths remain caller-controlled and receive no automatic ignore. Existing lanes are not moved or migrated: to keep creating lanes in the old layout, pass `--worktree-root ../.maco/worktrees/` with the original sanitized repository group name. Pass that same root to GC or lifecycle commands diff --git a/src/worktree.rs b/src/worktree.rs index e9aba344..bb6b474b 100644 --- a/src/worktree.rs +++ b/src/worktree.rs @@ -2633,6 +2633,15 @@ impl WorktreeManager { ); } } + let default_exclude = if options.worktree_root.is_none() { + Some(ensure_default_worktree_exclude( + &repo, + ®istry_store, + ®istry_lock, + )?) + } else { + None + }; let requested_root = options .worktree_root .unwrap_or_else(|| default_worktree_root(&repo)); @@ -2643,6 +2652,9 @@ impl WorktreeManager { .context("worktree creation requires a non-bare repository")? .join(requested_root) }; + if let Some(exclude) = &default_exclude { + exclude.verify()?; + } let root = SafeRoot::open_or_create_managed(&requested_root)?; crate::lane_build::ensure_lane_build_configuration(root.path())?; let worktree_path = root.direct_child(&name)?; diff --git a/src/worktree/part3.rs b/src/worktree/part3.rs index 4e9acd99..57ce0787 100644 --- a/src/worktree/part3.rs +++ b/src/worktree/part3.rs @@ -4137,6 +4137,791 @@ fn default_worktree_root(repo: &Repository) -> PathBuf { repo_root.join(".maco").join("worktrees").join(repo_name) } +fn default_worktree_exclude_rule(repo: &Repository) -> Result { + let root = default_worktree_root(repo); + let name = root + .file_name() + .and_then(OsStr::to_str) + .context("default root is not UTF-8")?; + if name.is_empty() + || !name + .bytes() + .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_' | b'.')) + { + bail!("default worktree group is not a literal Git ignore path"); + } + Ok(format!("/.maco/worktrees/{name}/")) +} + +fn append_default_worktree_exclude(prior: &[u8], rule: &str) -> Result> { + let text = std::str::from_utf8(prior).context("Git exclude file is not UTF-8")?; + if prior.contains(&0) || prior.len() as u64 > MAX_WORKTREE_GIT_TEXT_FILE_BYTES { + bail!("Git exclude file exceeds its bounded text contract"); + } + if text.lines().any(|line| line == rule) { + return Ok(prior.to_vec()); + } + let newline: &[u8] = if prior.windows(2).any(|bytes| bytes == b"\r\n") { + b"\r\n" + } else { + b"\n" + }; + let mut next = prior.to_vec(); + if !next.is_empty() && !next.ends_with(b"\n") { + next.extend_from_slice(newline); + } + next.extend_from_slice(rule.as_bytes()); + next.extend_from_slice(newline); + if next.len() as u64 > MAX_WORKTREE_GIT_TEXT_FILE_BYTES { + bail!("default worktree exclusion exceeds the Git text file byte limit"); + } + Ok(next) +} + +// Count the prospective exclude along with the same bounded inputs used by +// status. Do not admit an over-budget write and discover it only afterward. +fn validate_default_exclude_text_budget( + binding: &RepositoryBindingGuard, + additional_bytes: usize, + deadline: Instant, +) -> Result<()> { + ensure_worktree_status_deadline(deadline, "before default exclude budget recount")?; + let inputs = validate_bounded_git_text_inputs_bound(binding, deadline)?; + let mut total = inputs + .info_exclude + .as_ref() + .map_or(0, Vec::len) + .saturating_sub(MACO_STATUS_EXCLUDES.len()) as u64; + total = total + .checked_add(additional_bytes as u64) + .context("Git text byte count overflowed")?; + ensure_default_exclude_budget(total, deadline)?; + let inventory = BoundedTreeWalker::walk_bound_with_options_detailed( + binding.worktree_binding(), + BoundedTreeWalkLimits { + max_depth: 128, + max_entries: MAX_WORKTREE_STATUS_ENTRIES, + max_path_bytes: MAX_PERSISTED_PATH_BYTES, + max_total_path_bytes: MAX_WORKTREE_STATUS_OUTPUT_BYTES.saturating_mul(32), + max_duration: remaining_worktree_status_time( + deadline, + "before default exclude budget", + )?, + same_device: true, + }, + crate::safe_state::BoundedTreeWalkOptions { + stop_at_nested_repositories: true, + }, + |entry| { + if entry.relative_path == Path::new(".git") + || is_bounded_status_runtime_path(&entry.relative_path) + { + return Ok(BoundedTreeWalkAction::Skip); + } + if entry.kind == BoundedTreeEntryKind::Directory { + return Ok(BoundedTreeWalkAction::RecordAndDescend); + } + if matches!( + entry.relative_path.file_name().and_then(OsStr::to_str), + Some(".gitignore" | ".gitmodules") + ) { + if !entry.is_safe_regular_file() { + bail!("unsafe Git text input"); + } + return Ok(BoundedTreeWalkAction::Record); + } + Ok(BoundedTreeWalkAction::Skip) + }, + )?; + for entry in inventory + .entries + .iter() + .filter(|entry| entry.kind == BoundedTreeEntryKind::RegularFile) + { + ensure_worktree_status_deadline(deadline, "during default exclude budget recount")?; + total += binding + .worktree_binding() + .read_relative(&entry.relative_path, MAX_WORKTREE_GIT_TEXT_FILE_BYTES)? + .len() as u64; + ensure_default_exclude_budget(total, deadline)?; + } + for (common, relative) in [ + (false, "config"), + (true, "config"), + (true, "config.worktree"), + ] { + ensure_worktree_status_deadline(deadline, "between default exclude config reads")?; + let bytes = if common { + binding.read_common_relative_optional( + Path::new(relative), + MAX_WORKTREE_GIT_TEXT_FILE_BYTES, + )? + } else { + binding + .read_git_relative_optional(Path::new(relative), MAX_WORKTREE_GIT_TEXT_FILE_BYTES)? + }; + total += bytes.as_ref().map_or(0, Vec::len) as u64; + ensure_default_exclude_budget(total, deadline)?; + } + binding.verify_status_generation() +} + +fn ensure_default_exclude_budget(total: u64, deadline: Instant) -> Result<()> { + ensure_worktree_status_deadline(deadline, "during default exclude budget recount")?; + if total > MAX_WORKTREE_GIT_TEXT_TOTAL_BYTES { + bail!("default worktree exclusion exceeds the Git text aggregate byte limit"); + } + Ok(()) +} + +// Use Git's pattern engine without changing the operator's exclude file. +// Test each common-exclude negation against an otherwise ignored exact group, +// then test the prospective file against the actual worktree .gitignore inputs. +fn validate_default_exclude_patterns( + preview: &SafeRoot, + workdir: &Path, + prior: &[u8], + next: &[u8], + rule: &str, + runtime: &SafeRoot, + deadline: Instant, +) -> Result<()> { + let info = SafeRoot::open_existing(preview.path().join("info"))?; + let empty = runtime.reserve_direct_child_directory("empty-worktree")?; + let negations = default_exclude_negation_probe(prior, rule, deadline)?; + AtomicStateWriter::write_direct(&info, "exclude", &negations)?; + require_default_group_ignored(preview.path(), empty.path(), rule, runtime, deadline) + .context("operator Git exclude negation conflicts with the default worktree group")?; + AtomicStateWriter::write_direct(&info, "exclude", next)?; + require_default_group_ignored(preview.path(), workdir, rule, runtime, deadline) +} + +fn default_exclude_negation_probe(prior: &[u8], rule: &str, deadline: Instant) -> Result> { + ensure_worktree_status_deadline(deadline, "before default exclude negation probe")?; + let mut probe = format!("{rule}\n").into_bytes(); + for line in std::str::from_utf8(prior)?.lines() { + ensure_worktree_status_deadline(deadline, "during default exclude negation probe")?; + if line.starts_with('!') { + probe.extend_from_slice(line.as_bytes()); + probe.push(b'\n'); + } + } + if probe.len() as u64 > MAX_WORKTREE_GIT_TEXT_FILE_BYTES { + bail!("Git negation probe exceeds its text bound"); + } + Ok(probe) +} + +#[derive(Default)] +struct DefaultExcludeLocalConfig { + ignore_case: Option, + worktree_config: Option, +} + +// Admit only literal, single-line syntax before any layered config read. +fn parse_default_exclude_local_config(bytes: &[u8]) -> Result { + if bytes.len() as u64 > MAX_WORKTREE_GIT_TEXT_FILE_BYTES || bytes.contains(&0) { + bail!("default exclusion Git config exceeds its bounded text contract"); + } + let text = std::str::from_utf8(bytes).context("default exclusion Git config is not UTF-8")?; + let mut config = DefaultExcludeLocalConfig::default(); + let mut section = String::new(); + for raw in text.lines() { + let mut quoted = false; + let mut end = raw.len(); + for (offset, ch) in raw.char_indices() { + match ch { + '\\' => bail!("Git config escapes and multiline continuations are not admitted for default exclusion"), + '"' => quoted = !quoted, + '#' | ';' if !quoted => { end = offset; break; }, + _ => {}, + } + } + if quoted { + bail!("unclosed Git config quote"); + } + let line = raw[..end].trim(); + if line.is_empty() { + continue; + } + if line.starts_with('[') { + let body = line + .strip_prefix('[') + .and_then(|line| line.strip_suffix(']')) + .context("unadmitted Git config section syntax")? + .trim(); + let name_end = body + .find(|ch: char| ch.is_ascii_whitespace() || ch == '.') + .unwrap_or(body.len()); + let name = &body[..name_end]; + if name.is_empty() || !name.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'-') { + bail!("unadmitted Git config section name"); + } + section = name.to_ascii_lowercase(); + if matches!(section.as_str(), "include" | "includeif") { + bail!("Git config include indirection is not admitted for default exclusion"); + } + let rest = body[name_end..].trim(); + if !rest.is_empty() { + if matches!(section.as_str(), "core" | "extensions") { + bail!("Git core/extensions subsections are not admitted for default exclusion"); + } + if !(rest.len() >= 2 + && rest.starts_with('"') + && rest.ends_with('"') + && !rest[1..rest.len() - 1].contains('"')) + { + bail!("unadmitted Git config subsection syntax"); + } + } + continue; + } + if section.is_empty() { + bail!("Git config key has no admitted section"); + } + let (key, value) = line + .split_once('=') + .map_or((line.trim(), None), |(key, value)| { + (key.trim(), Some(value.trim())) + }); + if key.is_empty() + || !key.as_bytes()[0].is_ascii_alphabetic() + || !key.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'-') + { + bail!("unadmitted Git config key syntax"); + } + if section == "core" && key.eq_ignore_ascii_case("excludesfile") { + bail!("Git core.excludesFile indirection is not admitted for default exclusion"); + } + let slot = if section == "core" && key.eq_ignore_ascii_case("ignorecase") { + Some(&mut config.ignore_case) + } else if section == "extensions" && key.eq_ignore_ascii_case("worktreeconfig") { + Some(&mut config.worktree_config) + } else { + None + }; + if let Some(slot) = slot { + if slot.is_some() { + bail!("duplicate Git config policy key"); + } + let value = value.unwrap_or("true"); + let value = value + .strip_prefix('"') + .and_then(|value| value.strip_suffix('"')) + .unwrap_or(value); + *slot = Some(match value.to_ascii_lowercase().as_str() { + "true" | "yes" | "on" | "1" => true, + "false" | "no" | "off" | "0" => false, + _ => bail!("Git config policy boolean is not admitted"), + }); + } + } + Ok(config) +} + +fn admitted_default_exclude_ignorecase(common: &[u8], worktree: Option<&[u8]>) -> Result { + let common = parse_default_exclude_local_config(common)?; + let worktree = worktree + .map(parse_default_exclude_local_config) + .transpose()?; + if worktree.is_some() && common.worktree_config != Some(true) { + bail!("Git config.worktree is not admitted without extensions.worktreeConfig"); + } + Ok(worktree + .and_then(|config| config.ignore_case) + .or(common.ignore_case) + .unwrap_or(false)) +} + +// libgit2 drops a negation that has no preceding positive pattern in the same +// file. Native Git must decide the effective cross-file ignore precedence. +fn require_default_group_ignored( + git_dir: &Path, + workdir: &Path, + rule: &str, + runtime: &SafeRoot, + deadline: Instant, +) -> Result<()> { + let git = crate::merge::resolve_trusted_executable("git")?; + let mut environment = BTreeMap::new(); + for (key, value) in [ + ("GIT_CONFIG_NOSYSTEM", "1"), + ("GIT_CONFIG_GLOBAL", "/dev/null"), + ("GIT_OPTIONAL_LOCKS", "0"), + ("GIT_TERMINAL_PROMPT", "0"), + ("LANG", "C"), + ("LC_ALL", "C"), + ] { + environment.insert(key.to_string(), value.to_string()); + } + let args = [ + OsString::from("--git-dir"), + git_dir.as_os_str().to_owned(), + OsString::from("--work-tree"), + workdir.as_os_str().to_owned(), + OsString::from("check-ignore"), + OsString::from("--no-index"), + OsString::from("--quiet"), + OsString::from("--"), + OsString::from(rule.trim_start_matches('/')), + ]; + runtime.verify()?; + let output = run_process( + ProcessSpec::direct( + "default worktree Git exclusion", + git, + args, + runtime.path(), + 4096, + ) + .with_environment(EnvironmentMode::ClearAndSet(environment)) + .with_stdin(StdinMode::Null) + .with_timeout(Some( + remaining_worktree_status_time(deadline, "before default exclude Git check")? + .min(WORKTREE_STATUS_COMMAND_TIMEOUT), + )) + .with_containment(ContainmentPolicy::TrustedBestEffort), + )?; + if output.timed_out || output.stdout.is_truncated() || output.stderr.is_truncated() { + bail!("default worktree Git exclusion exceeded its time/output bound"); + } + require_bounded_git_process(&output, BoundedGitIsolation::Trusted)?; + if !output.status.is_some_and(|status| status.success()) { + bail!( + "operator Git ignore configuration leaves the default worktree group visible: {}", + output.stderr.summarize_chars(512).text + ); + } + runtime.verify() +} + +#[cfg(test)] +fn reject_default_exclude_negations( + preview: &Repository, + prior: &[u8], + rule: &str, + deadline: Instant, +) -> Result<()> { + ensure_worktree_status_deadline(deadline, "before Git pattern evaluation")?; + let relative = Path::new( + rule.strip_prefix('/') + .context("unanchored default exclude")?, + ); + for line in std::str::from_utf8(prior)? + .lines() + .filter(|line| line.starts_with('!')) + { + ensure_worktree_status_deadline(deadline, "between Git pattern evaluations")?; + preview.clear_ignore_rules()?; + preview.add_ignore_rule(&format!("{rule}\n{line}\n"))?; + if !preview.status_should_ignore(relative)? { + bail!("operator Git exclude negation conflicts with the default worktree group"); + } + ensure_worktree_status_deadline(deadline, "after Git pattern evaluation")?; + } + preview.clear_ignore_rules()?; + Ok(()) +} + +fn ensure_default_worktree_exclude( + repo: &Repository, + store: &ManagedWorktreeRegistryStore, + lock: &ManagedWorktreeRegistryLock, +) -> Result { + store.verify_lock(lock)?; + // Refuse indirection using authenticated, held metadata before libgit2 can + // open the repository and follow config includes. + let common = DirectoryBindingGuard::bind(&store.repository.common_dir)?; + if common.identity() != &store.repository.common_dir_identity + || common.path().parent() != Some(store.repository.repository_workdir.as_path()) + { + bail!("default worktree exclusion requires the authenticated primary repository"); + } + let config = RegularFileBindingGuard::bind( + common.path().join("config"), + MAX_WORKTREE_GIT_TEXT_FILE_BYTES, + )?; + let worktree_config_path = common.path().join("config.worktree"); + let worktree_config = match fs::symlink_metadata(&worktree_config_path) { + Ok(_) => Some(RegularFileBindingGuard::bind( + &worktree_config_path, + MAX_WORKTREE_GIT_TEXT_FILE_BYTES, + )?), + Err(error) if error.kind() == ErrorKind::NotFound => None, + Err(error) => return Err(error).context("failed to bind Git worktree config"), + }; + let ignore_case = admitted_default_exclude_ignorecase( + config.contents(), + worktree_config + .as_ref() + .map(RegularFileBindingGuard::contents), + )?; + let verify_config = || -> Result<()> { + common.verify()?; + config.verify()?; + if let Some(config) = &worktree_config { + config.verify()?; + } else { + match fs::symlink_metadata(&worktree_config_path) { + Err(error) if error.kind() == ErrorKind::NotFound => {} + _ => bail!("Git worktree config appeared during default exclusion"), + } + } + if common.read_relative(Path::new("config"), MAX_WORKTREE_GIT_TEXT_FILE_BYTES)? + != config.contents() + || common + .read_relative_optional( + Path::new("config.worktree"), + MAX_WORKTREE_GIT_TEXT_FILE_BYTES, + )? + .as_deref() + != worktree_config + .as_ref() + .map(RegularFileBindingGuard::contents) + { + bail!("Git config changed during default exclusion"); + } + Ok(()) + }; + verify_config()?; + let binding = RepositoryBindingGuard::bind(&store.repository.repository_workdir)?; + verify_config()?; + if managed_repository_binding(repo)? != store.repository + || binding.worktree.path() != store.repository.repository_workdir + || binding.worktree.identity() != &store.repository.repository_workdir_identity + || binding.common_dir.path() != common.path() + || binding.common_dir.identity() != common.identity() + || binding.git_dir.path() != binding.common_dir.path() + { + bail!("default worktree exclusion requires the authenticated primary repository"); + } + let root_bindings = preflight_default_worktree_root(&binding, repo)?; + let ignore_files = bind_default_ignore_ancestors(&binding)?; + let ancestor_bytes = ignore_files + .iter() + .filter(|(path, _)| path != Path::new(".gitignore")) + .map(|(_, file)| file.as_ref().map_or(0, |file| file.contents().len())) + .sum::(); + let info = SafeRoot::open_existing(binding.common_dir().join("info"))?; + let exclude = info.direct_child("exclude")?; + let old_metadata = match fs::symlink_metadata(&exclude) { + Ok(metadata) => Some(metadata), + Err(error) if error.kind() == ErrorKind::NotFound => None, + Err(error) => return Err(error).context("failed to inspect common Git exclude"), + }; + #[cfg(unix)] + if old_metadata + .as_ref() + .is_some_and(|metadata| metadata.uid() != unsafe { libc::geteuid() }) + { + bail!("common Git exclude is not owned by the current operator"); + } + let old = old_metadata + .as_ref() + .map(|_| RegularFileBindingGuard::bind(&exclude, MAX_WORKTREE_GIT_TEXT_FILE_BYTES)) + .transpose()?; + #[cfg(unix)] + let old_metadata = old + .as_ref() + .map(|old| -> Result { + let metadata = fs::symlink_metadata(&exclude)?; + old.verify()?; + #[cfg(unix)] + if metadata.uid() != unsafe { libc::geteuid() } + || metadata.permissions().mode() & 0o022 != 0 + { + bail!("common Git exclude ownership or write permissions are unsafe"); + } + Ok(metadata) + }) + .transpose()?; + let prior = binding.read_common_relative_optional( + Path::new("info/exclude"), + MAX_WORKTREE_GIT_TEXT_FILE_BYTES, + )?; + if prior.as_deref() != old.as_ref().map(RegularFileBindingGuard::contents) { + bail!("common Git exclude changed while binding it"); + } + let rule = default_worktree_exclude_rule(repo)?; + let next = append_default_worktree_exclude(prior.as_deref().unwrap_or_default(), &rule)?; + let deadline = Instant::now() + WORKTREE_GC_STATUS_TIMEOUT; + validate_default_exclude_text_budget( + &binding, + next.len() + .saturating_sub(prior.as_ref().map_or(0, Vec::len)) + + ancestor_bytes, + deadline, + )?; + let scratch = store + .state_root + .reserve_random_direct_child_directory("maco-exclude-preview")?; + let result = (|| -> Result { + let runtime = SafeRoot::open_existing(scratch.path())?; + let preview_directory = runtime.reserve_direct_child_directory("git")?; + let preview = SafeRoot::open_existing(preview_directory.path())?; + for child in ["objects", "refs", "info"] { + preview.reserve_direct_child_directory(child)?; + } + AtomicStateWriter::write_direct( + &preview, + "HEAD", + b"ref: refs/heads/maco-exclude-preview\n", + )?; + AtomicStateWriter::write_direct( + &preview, + "config", + format!( + "[core]\nrepositoryformatversion = 0\nbare = false\nignorecase = {ignore_case}\n" + ) + .as_bytes(), + )?; + verify_config()?; + validate_default_exclude_patterns( + &preview, + binding.worktree(), + prior.as_deref().unwrap_or_default(), + &next, + &rule, + &runtime, + deadline, + )?; + let evidence = crate::secure_output::SecureOutputRoot::open_private(scratch.path())?; + let mut prior_slot = evidence.reserve(OsStr::new("prior-exclude"))?; + prior_slot.write_bytes_atomic( + prior.as_deref().unwrap_or_default(), + MAX_WORKTREE_GIT_TEXT_FILE_BYTES as usize, + )?; + let mut next_slot = evidence.reserve(OsStr::new("next-exclude"))?; + next_slot.write_bytes_atomic(&next, MAX_WORKTREE_GIT_TEXT_FILE_BYTES as usize)?; + let mut replaced = false; + let mut installed = None; + let mut fence = || -> Result<()> { + store.verify_lock(lock)?; + verify_config()?; + binding.verify()?; + info.verify()?; + verify_default_worktree_root(&root_bindings, &binding, repo)?; + verify_default_ignore_ancestors(&binding, &ignore_files)?; + if !replaced { + #[cfg(test)] + run_default_exclude_before_write_hook(&exclude); + if let Some(old) = &old { + old.verify()?; + } else { + info.ensure_direct_child_absent("exclude")?; + } + let observed = binding.read_common_relative_optional( + Path::new("info/exclude"), + MAX_WORKTREE_GIT_TEXT_FILE_BYTES, + )?; + if observed != prior + || observed.as_deref().unwrap_or_default() + != prior_slot.read_bounded(MAX_WORKTREE_GIT_TEXT_FILE_BYTES as usize)? + { + bail!("operator Git exclude changed before replacement"); + } + replaced = true; + } else { + let new = + RegularFileBindingGuard::bind(&exclude, MAX_WORKTREE_GIT_TEXT_FILE_BYTES)?; + if new.contents() + != next_slot.read_bounded(MAX_WORKTREE_GIT_TEXT_FILE_BYTES as usize)? + { + bail!("common Git exclude changed after replacement"); + } + #[cfg(unix)] + if prior.as_deref() != Some(next.as_slice()) { + if let Some(metadata) = &old_metadata { + info.bind_owned_direct_child( + "exclude", + new.identity(), + crate::safe_state::DirectChildType::SingleLinkRegularFile, + )? + .set_permissions_fenced(&info, metadata.permissions().mode() & 0o7777)?; + } + } + let new = + RegularFileBindingGuard::bind(&exclude, MAX_WORKTREE_GIT_TEXT_FILE_BYTES)?; + if new.contents() + != next_slot.read_bounded(MAX_WORKTREE_GIT_TEXT_FILE_BYTES as usize)? + { + bail!("common Git exclude changed after replacement"); + } + validate_default_exclude_text_budget(&binding, ancestor_bytes, deadline)?; + verify_config()?; + require_default_group_ignored( + binding.common_dir(), + binding.worktree(), + &rule, + &runtime, + deadline, + )?; + #[cfg(test)] + run_default_exclude_after_git_hook(binding.worktree()); + store.verify_lock(lock)?; + verify_config()?; + info.verify()?; + verify_default_worktree_root(&root_bindings, &binding, repo)?; + verify_default_ignore_ancestors(&binding, &ignore_files)?; + new.verify()?; + verify_config()?; + binding.verify()?; + installed = Some(new); + } + Ok(()) + }; + if prior.as_deref() == Some(next.as_slice()) { + fence()?; + fence()?; + } else { + AtomicStateWriter::write_direct_fenced(&info, "exclude", &next, &mut fence)?; + } + installed.context("missing default worktree exclude evidence") + })(); + scratch.verify(&store.state_root)?; + if result.is_err() { + return result.with_context(|| { + format!( + "default exclusion refused; inspection evidence retained at {}", + scratch.path().display() + ) + }); + } + remove_direct_child_tree( + &store.state_root, + scratch.path().file_name().context("missing preview name")?, + Some(scratch.identity()), + TreeLinkPolicy::RejectLinksAndSpecialFiles, + )?; + result +} + +// Opening every existing component separately proves ownership and prevents a +// linked or foreign-owned default store from causing an exclude mutation. +fn preflight_default_worktree_root( + binding: &RepositoryBindingGuard, + repo: &Repository, +) -> Result> { + let root = default_worktree_root(repo); + let relative = root + .strip_prefix(binding.worktree()) + .context("default worktree root escaped its repository")?; + let mut path = binding.worktree().to_path_buf(); + let mut held = Vec::new(); + #[cfg(target_os = "linux")] + let primary_mount = SafeRoot::open_existing(binding.worktree())?.linux_mount_id()?; + for component in relative.components() { + let std::path::Component::Normal(name) = component else { + bail!("unsafe default root component"); + }; + path.push(name); + match fs::symlink_metadata(&path) { + Ok(_) => { + let root = SafeRoot::open_existing(&path)?; + if root.identity().device != binding.worktree.identity().device { + bail!("default root crosses the repository filesystem"); + } + #[cfg(target_os = "linux")] + root.verify_linux_mount_id(primary_mount)?; + held.push(root); + } + Err(error) if error.kind() == ErrorKind::NotFound => break, + Err(error) => return Err(error).context("failed to preflight default worktree root"), + } + } + binding.verify_status_generation()?; + Ok(held) +} + +fn verify_default_worktree_root( + held: &[SafeRoot], + binding: &RepositoryBindingGuard, + repo: &Repository, +) -> Result<()> { + for root in held { + root.verify()?; + } + let rebound = preflight_default_worktree_root(binding, repo)?; + if rebound.len() != held.len() + || rebound + .iter() + .zip(held) + .any(|(a, b)| a.identity() != b.identity()) + { + bail!("default worktree root changed before exclude mutation"); + } + Ok(()) +} + +type DefaultIgnoreFiles = Vec<(PathBuf, Option)>; + +fn bind_default_ignore_ancestors(binding: &RepositoryBindingGuard) -> Result { + [ + ".gitignore", + ".maco/.gitignore", + ".maco/worktrees/.gitignore", + ] + .into_iter() + .map(|relative| { + let path = binding.worktree().join(relative); + let file = match fs::symlink_metadata(&path) { + Ok(_) => { + let file = RegularFileBindingGuard::bind(path, MAX_WORKTREE_GIT_TEXT_FILE_BYTES)?; + std::str::from_utf8(file.contents()).context("Git ignore file is not UTF-8")?; + Some(file) + } + Err(error) if error.kind() == ErrorKind::NotFound => None, + Err(error) => return Err(error).context("failed to bind default root ignore input"), + }; + Ok((PathBuf::from(relative), file)) + }) + .collect() +} + +fn verify_default_ignore_ancestors( + binding: &RepositoryBindingGuard, + files: &[(PathBuf, Option)], +) -> Result<()> { + for (relative, file) in files { + if let Some(file) = file { + file.verify()?; + } else { + match fs::symlink_metadata(binding.worktree().join(relative)) { + Err(error) if error.kind() == ErrorKind::NotFound => {} + _ => bail!("default root ignore input appeared during exclusion"), + } + } + } + binding.verify_status_generation() +} + +#[cfg(test)] +type DefaultExcludeBeforeWriteHook = Box; + +#[cfg(test)] +thread_local! { + static DEFAULT_EXCLUDE_BEFORE_WRITE_HOOK: std::cell::RefCell> = const { std::cell::RefCell::new(None) }; + static DEFAULT_EXCLUDE_AFTER_GIT_HOOK: std::cell::RefCell> = const { std::cell::RefCell::new(None) }; +} + +#[cfg(test)] +fn run_default_exclude_before_write_hook(path: &Path) { + DEFAULT_EXCLUDE_BEFORE_WRITE_HOOK.with(|slot| { + if let Some(hook) = slot.borrow_mut().take() { + hook(path); + } + }); +} + +#[cfg(test)] +fn run_default_exclude_after_git_hook(path: &Path) { + DEFAULT_EXCLUDE_AFTER_GIT_HOOK.with(|slot| { + if let Some(hook) = slot.borrow_mut().take() { + hook(path); + } + }); +} + // Authenticate the pre-repository-local layout without changing creation defaults. fn legacy_workspace_worktree_root(repo: &Repository) -> PathBuf { let repo_root = repo.workdir().unwrap_or_else(|| repo.path()); diff --git a/src/worktree/tests.rs b/src/worktree/tests.rs index 3a8d9c40..137d0446 100644 --- a/src/worktree/tests.rs +++ b/src/worktree/tests.rs @@ -124,6 +124,563 @@ fn default_worktree_root_preserves_explicit_overrides() { assert!(!default_worktree_root(&repo).exists()); } +#[test] +fn default_exclude_append_preserves_bytes_newlines_and_idempotence() { + let rule = "/.maco/worktrees/repo/"; + for (prior, expected) in [ + (b"".as_slice(), b"/.maco/worktrees/repo/\n".as_slice()), + ( + b"# operator\nkeep\n", + b"# operator\nkeep\n/.maco/worktrees/repo/\n", + ), + ( + b"# operator\r\nkeep", + b"# operator\r\nkeep\r\n/.maco/worktrees/repo/\r\n", + ), + (b"keep", b"keep\n/.maco/worktrees/repo/\n"), + ] { + let next = + append_default_worktree_exclude(prior, rule).expect("append bounded literal rule"); + assert_eq!(next, expected); + assert!(next.starts_with(prior)); + assert_eq!(append_default_worktree_exclude(&next, rule).unwrap(), next); + } + assert!(append_default_worktree_exclude(b"\xff", rule).is_err()); + assert!(append_default_worktree_exclude(b"keep\0", rule).is_err()); + assert!(append_default_worktree_exclude( + &vec![b'a'; MAX_WORKTREE_GIT_TEXT_FILE_BYTES as usize], + rule + ) + .is_err()); +} + +#[test] +fn default_exclude_rule_is_literal_and_only_matches_selected_group() { + let temp = TempDir::new().unwrap(); + assert_eq!(sanitize_path_segment("repo+[x]*?!name"), "repo__x____name"); + let repo = Repository::init(temp.path().join("repo+[x]!name")).unwrap(); + let rule = default_worktree_exclude_rule(&repo).unwrap(); + assert_eq!(rule, "/.maco/worktrees/repo__x__name/"); + repo.add_ignore_rule(&format!("{rule}\n")).unwrap(); + assert!(repo + .status_should_ignore(Path::new(".maco/worktrees/repo__x__name/lane/README.md")) + .unwrap()); + for path in [ + ".maco/operator.txt", + ".maco/worktrees/other/lane/README.md", + ".worktrees/manual/README.md", + "README.md", + ] { + assert!( + !repo.status_should_ignore(Path::new(path)).unwrap(), + "unexpected exclusion: {path}" + ); + } +} + +#[test] +fn default_exclude_matching_negations_are_not_overridden() { + let temp = TempDir::new().unwrap(); + let repo = Repository::init(temp.path()).unwrap(); + let rule = "/.maco/worktrees/repo/"; + for prior in [ + "!/.maco/worktrees/repo/\n", + "!/.maco/worktrees/*/\r\n", + "!repo/\n", + "!/.maco/worktrees/repo/\n/.maco/worktrees/repo/\n", + ] { + assert!( + reject_default_exclude_negations( + &repo, + prior.as_bytes(), + rule, + Instant::now() + WORKTREE_GC_STATUS_TIMEOUT + ) + .is_err(), + "negation admitted: {prior}" + ); + } + reject_default_exclude_negations( + &repo, + b"# !repo/\n!elsewhere/\n\\!literal\n", + rule, + Instant::now() + WORKTREE_GC_STATUS_TIMEOUT, + ) + .unwrap(); +} + +#[test] +fn default_exclude_local_config_admits_only_literal_policy_values() { + let common = b"[core]\nignorecase = \"ON\" # comment\n[remote \"origin#name\"]\nurl = https://example.test/repo\n[extensions]\nworktreeConfig = true\n"; + assert!(admitted_default_exclude_ignorecase(common, None).unwrap()); + assert!( + !admitted_default_exclude_ignorecase(common, Some(b"[core]\nignoreCase = false\n")) + .unwrap() + ); + assert!(!admitted_default_exclude_ignorecase(b"[core]\nbare = false\n", None).unwrap()); + assert!(admitted_default_exclude_ignorecase(b"[core]\nignorecase\n", None).unwrap()); +} + +#[test] +fn default_exclude_local_config_refuses_indirection_and_ambiguous_syntax() { + for unsafe_config in [ + "[include]\npath = outside\n", + "[includeIf \"gitdir:../*\"]\npath = outside\n", + "[IncludeIf.onbranch:main]\npath = outside\n", + "[include \"quoted\"]\npath = outside\n", + "[\"include\"]\npath = outside\n", + "[core]\nExcludesFile = \"outside\"\n", + "[core \"excludesFile\"]\npath = outside\n", + "[core]\nexcludesFile = \\\n outside\n", + "[co\\\nre]\nexcludesFile = outside\n", + "[core]\nignorecase = true\nignorecase = false\n", + "[core]\nignorecase = \"true\n", + ] { + assert!( + admitted_default_exclude_ignorecase(unsafe_config.as_bytes(), None).is_err(), + "common config admitted: {unsafe_config}" + ); + assert!( + admitted_default_exclude_ignorecase( + b"[extensions]\nworktreeConfig = true\n", + Some(unsafe_config.as_bytes()) + ) + .is_err(), + "worktree config admitted: {unsafe_config}" + ); + } + assert!(admitted_default_exclude_ignorecase( + b"[core]\nignorecase = false\n", + Some(b"[core]\nignorecase = true\n") + ) + .is_err()); +} + +#[test] +fn default_exclude_expired_deadline_refuses_patterns_and_budget_recount() { + let temp = TempDir::new().unwrap(); + let repo = Repository::init(temp.path()).unwrap(); + let expired = Instant::now() - Duration::from_secs(1); + let rule = "/.maco/worktrees/repo/"; + assert!(reject_default_exclude_negations(&repo, b"!elsewhere/\n", rule, expired).is_err()); + assert!(default_exclude_negation_probe(b"!elsewhere/\n", rule, expired).is_err()); + assert!(ensure_default_exclude_budget(0, expired).is_err()); + assert!(ensure_default_exclude_budget( + MAX_WORKTREE_GIT_TEXT_TOTAL_BYTES + 1, + Instant::now() + Duration::from_secs(1) + ) + .is_err()); +} + +#[cfg(target_os = "linux")] +#[test] +fn default_exclude_config_indirection_refuses_before_exclude_write() { + for variant in 0..3 { + let temp = TempDir::new().unwrap(); + let repo_path = temp.path().join("repo"); + WorktreeManager::init_repository(&repo_path, "main").unwrap(); + let repo = crate::git_repository::open(&repo_path).unwrap(); + commit_readme(&repo).unwrap(); + let store = ManagedWorktreeRegistryStore::open(&repo).unwrap(); + let lock = store.lock().unwrap(); + let config_path = repo.commondir().join("config"); + let mut bytes = fs::read(&config_path).unwrap(); + if variant == 1 { + bytes.extend_from_slice(b"\n[extensions]\nworktreeConfig = true\n"); + fs::write(&config_path, bytes).unwrap(); + fs::write( + repo.commondir().join("config.worktree"), + b"[includeIf \"onbranch:*\"]\npath = never-authorized\n", + ) + .unwrap(); + } else if variant == 0 { + bytes.extend_from_slice(b"\n[core]\nexcludesFile = never-authorized\n"); + fs::write(&config_path, bytes).unwrap(); + } else { + // A libgit2 reopen would fail on this target before our refusal. + fs::write(repo.commondir().join("unadmitted-config"), b"[invalid\n").unwrap(); + bytes.extend_from_slice(b"\n[include]\npath = unadmitted-config\n"); + fs::write(&config_path, bytes).unwrap(); + } + let exclude = repo.commondir().join("info/exclude"); + let before = fs::read(&exclude).unwrap(); + let error = ensure_default_worktree_exclude(&repo, &store, &lock).unwrap_err(); + assert!(format!("{error:#}").contains("indirection"), "{error:#}"); + assert_eq!(fs::read(&exclude).unwrap(), before); + assert!(!default_worktree_root(&repo).exists()); + } +} + +#[cfg(target_os = "linux")] +#[test] +fn default_exclude_post_git_closing_fence_rejects_binding_races() { + for changed in [ + ".git/config", + ".git/config.worktree", + ".git/info", + ".git/objects", + ".maco", + ".gitignore", + ".maco/.gitignore", + ".maco/worktrees/.gitignore", + ".git/.maco/managed_worktrees.lock", + ] { + let temp = TempDir::new().unwrap(); + let repo_path = temp.path().join("repo"); + WorktreeManager::init_repository(&repo_path, "main").unwrap(); + let repo = crate::git_repository::open(&repo_path).unwrap(); + commit_readme(&repo).unwrap(); + fs::create_dir_all(repo_path.join(".maco/worktrees")).unwrap(); + let store = ManagedWorktreeRegistryStore::open(&repo).unwrap(); + let lock = store.lock().unwrap(); + let lock_path = store.state_root.path().join("managed_worktrees.lock"); + fs::write(repo.commondir().join("unadmitted-config"), b"[invalid\n").unwrap(); + let config_race = changed == ".git/config"; + let changed = changed.to_string(); + DEFAULT_EXCLUDE_AFTER_GIT_HOOK.with(|slot| { + *slot.borrow_mut() = Some(Box::new(move |primary| { + let path = if changed.ends_with("managed_worktrees.lock") { + lock_path + } else { + primary.join(&changed) + }; + if path.is_dir() { + fs::rename(&path, path.with_extension("operator-held")).unwrap(); + fs::create_dir(&path).unwrap(); + } else { + if path.exists() { + fs::rename(&path, path.with_extension("operator-held")).unwrap(); + } + fs::create_dir_all(path.parent().unwrap()).unwrap(); + let bytes: &[u8] = if changed == ".git/config" { + b"[include]\npath = unadmitted-config\n" + } else { + b"# operator race\n" + }; + fs::write(&path, bytes).unwrap(); + } + })); + }); + let error = ensure_default_worktree_exclude(&repo, &store, &lock).unwrap_err(); + if config_race { + assert!( + !format!("{error:#}").contains("failed to reopen"), + "changed config was reopened before its generation check: {error:#}" + ); + } + assert!(!default_worktree_root(&repo).exists()); + } +} + +#[test] +fn default_exclude_gitignore_can_defeat_common_exclude() { + let temp = TempDir::new().unwrap(); + let workdir = temp.path().join("repo"); + fs::create_dir(&workdir).unwrap(); + fs::create_dir_all(workdir.join(".maco/worktrees/repo")).unwrap(); + let preview = Repository::init(temp.path().join("preview")).unwrap(); + fs::write( + preview.commondir().join("info/exclude"), + "/.maco/worktrees/repo/\n", + ) + .unwrap(); + preview.set_workdir(&workdir, false).unwrap(); + assert!(preview + .status_should_ignore(Path::new(".maco/worktrees/repo/")) + .unwrap()); + fs::write(workdir.join(".gitignore"), "!/.maco/worktrees/repo/\n").unwrap(); + let status = std::process::Command::new("git") + .arg("--git-dir") + .arg(preview.commondir()) + .arg("--work-tree") + .arg(&workdir) + .args([ + "check-ignore", + "--no-index", + "--quiet", + "--", + ".maco/worktrees/repo/", + ]) + .status() + .unwrap(); + assert_eq!( + status.code(), + Some(1), + "native Git must honor the .gitignore negation" + ); +} + +#[cfg(target_os = "linux")] +#[test] +fn default_exclude_managed_creation_is_invisible_to_plain_git_and_index_add_all() { + skip_without_containment!(); + let temp = TempDir::new().unwrap(); + let repo_path = temp.path().join("repo"); + WorktreeManager::init_repository(&repo_path, "main").unwrap(); + let repo = crate::git_repository::open(&repo_path).unwrap(); + commit_readme(&repo).unwrap(); + commit_descendant(&repo, ".maco/operator.txt", "operator baseline\n").unwrap(); + let prior = b"# operator rules\r\noperator-ignored\r\n"; + let exclude = repo.commondir().join("info/exclude"); + fs::write(&exclude, prior).unwrap(); + let manager = WorktreeManager::new(&repo_path); + let options = |agent: &str, root| WorktreeCreateOptions { + agent_id: agent.into(), + branch: None, + base: None, + worktree_root: root, + }; + let first = manager + .create(options("default-a", None)) + .expect("real default creation"); + assert_eq!(first.path.parent().unwrap(), default_worktree_root(&repo)); + let once = fs::read(&exclude).unwrap(); + assert!(once.starts_with(prior)); + assert_eq!( + once, + append_default_worktree_exclude(prior, "/.maco/worktrees/repo/").unwrap() + ); + manager + .create(options("default-b", None)) + .expect("second default creation"); + assert_eq!(fs::read(&exclude).unwrap(), once); + let status = std::process::Command::new("git") + .arg("-C") + .arg(&repo_path) + .args(["status", "--porcelain=v1", "--untracked-files=all"]) + .output() + .unwrap(); + assert!( + status.status.success(), + "{}", + String::from_utf8_lossy(&status.stderr) + ); + assert!( + status.stdout.is_empty(), + "{}", + String::from_utf8_lossy(&status.stdout) + ); + let mut index = repo.index().unwrap(); + index + .add_all(["*"], git2::IndexAddOption::DEFAULT, None) + .expect("nested default Git lanes must not reach add_all"); + index.write().unwrap(); + assert!(!index + .iter() + .any(|entry| entry.path.starts_with(b".maco/worktrees/"))); + let manual = repo_path.join(".worktrees"); + manager + .create(options("manual", Some(manual))) + .expect("explicit manual root"); + assert_eq!( + fs::read(&exclude).unwrap(), + once, + "explicit root must not update exclude" + ); + fs::write(repo_path.join("README.md"), "indexed operator edit\n").unwrap(); + index.add_path(Path::new("README.md")).unwrap(); + index.write().unwrap(); + fs::write(repo_path.join(".maco/operator.txt"), "working tree edit\n").unwrap(); + fs::write( + repo_path.join(".maco/operator-new.txt"), + "untracked operator edit\n", + ) + .unwrap(); + let status = std::process::Command::new("git") + .arg("-C") + .arg(&repo_path) + .args(["status", "--porcelain=v1", "--untracked-files=all"]) + .output() + .unwrap(); + assert!(status.status.success()); + let visible = String::from_utf8(status.stdout).unwrap(); + for expected in [ + "M README.md", + " M .maco/operator.txt", + "?? .maco/operator-new.txt", + ".worktrees/manual/", + ] { + assert!(visible.contains(expected), "missing {expected}: {visible}"); + } + assert!(!visible.contains(".maco/worktrees/repo/")); +} + +#[cfg(target_os = "linux")] +#[test] +fn default_exclude_unsafe_inputs_and_operator_conflicts_fail_before_root_creation() { + for kind in [ + "symlink", + "hardlink", + "directory", + "utf8", + "oversize", + "negation", + "gitignore", + ] { + let temp = TempDir::new().unwrap(); + let repo_path = temp.path().join("repo"); + WorktreeManager::init_repository(&repo_path, "main").unwrap(); + let repo = crate::git_repository::open(&repo_path).unwrap(); + commit_readme(&repo).unwrap(); + let exclude = repo.commondir().join("info/exclude"); + let sentinel = temp.path().join("operator-sentinel"); + fs::write(&sentinel, "operator bytes\n").unwrap(); + fs::remove_file(&exclude).unwrap(); + match kind { + "symlink" => std::os::unix::fs::symlink(&sentinel, &exclude).unwrap(), + "hardlink" => fs::hard_link(&sentinel, &exclude).unwrap(), + "directory" => fs::create_dir(&exclude).unwrap(), + "utf8" => fs::write(&exclude, b"\xff").unwrap(), + "oversize" => fs::write( + &exclude, + vec![b'a'; MAX_WORKTREE_GIT_TEXT_FILE_BYTES as usize], + ) + .unwrap(), + "negation" => fs::write(&exclude, "!/.maco/worktrees/repo/\n").unwrap(), + "gitignore" => { + fs::write(&exclude, "# operator\n").unwrap(); + commit_descendant(&repo, ".gitignore", "!/.maco/worktrees/repo/\n").unwrap(); + } + _ => unreachable!(), + } + let before = fs::read(&exclude).ok(); + let store = ManagedWorktreeRegistryStore::open(&repo).unwrap(); + let lock = store.lock().unwrap(); + assert!( + ensure_default_worktree_exclude(&repo, &store, &lock).is_err(), + "unsafe case admitted: {kind}" + ); + assert_eq!(fs::read(&exclude).ok(), before, "exclude changed: {kind}"); + assert_eq!(fs::read(&sentinel).unwrap(), b"operator bytes\n"); + assert!(!default_worktree_root(&repo).exists()); + } +} + +#[cfg(target_os = "linux")] +#[test] +fn default_exclude_fence_preserves_concurrent_operator_edit_and_absence() { + for absent in [false, true] { + let temp = TempDir::new().unwrap(); + let repo_path = temp.path().join("repo"); + WorktreeManager::init_repository(&repo_path, "main").unwrap(); + let repo = crate::git_repository::open(&repo_path).unwrap(); + commit_readme(&repo).unwrap(); + let exclude = repo.commondir().join("info/exclude"); + if absent { + fs::remove_file(&exclude).unwrap(); + } + let store = ManagedWorktreeRegistryStore::open(&repo).unwrap(); + let lock = store.lock().unwrap(); + DEFAULT_EXCLUDE_BEFORE_WRITE_HOOK.with(|slot| { + *slot.borrow_mut() = Some(Box::new(|path| { + fs::write(path, "concurrent operator edit\n").unwrap(); + })); + }); + assert!(ensure_default_worktree_exclude(&repo, &store, &lock).is_err()); + assert_eq!(fs::read(&exclude).unwrap(), b"concurrent operator edit\n"); + assert!(!default_worktree_root(&repo).exists()); + } +} + +#[cfg(target_os = "linux")] +#[test] +fn default_exclude_root_and_ignore_ancestor_links_do_not_mutate_operator_exclude() { + for linked in [ + ".maco", + ".maco/worktrees", + ".maco/worktrees/repo", + ".maco/.gitignore", + ] { + let temp = TempDir::new().unwrap(); + let repo_path = temp.path().join("repo"); + WorktreeManager::init_repository(&repo_path, "main").unwrap(); + let repo = crate::git_repository::open(&repo_path).unwrap(); + commit_readme(&repo).unwrap(); + let outside = temp.path().join("operator-owned"); + fs::create_dir(&outside).unwrap(); + let target = repo_path.join(linked); + fs::create_dir_all(target.parent().unwrap()).unwrap(); + std::os::unix::fs::symlink(&outside, &target).unwrap(); + let exclude = repo.commondir().join("info/exclude"); + let prior = fs::read(&exclude).unwrap(); + let store = ManagedWorktreeRegistryStore::open(&repo).unwrap(); + let lock = store.lock().unwrap(); + assert!( + ensure_default_worktree_exclude(&repo, &store, &lock).is_err(), + "link admitted: {linked}" + ); + assert_eq!(fs::read(&exclude).unwrap(), prior); + assert_eq!(fs::read_dir(&outside).unwrap().count(), 0); + } +} + +#[cfg(target_os = "linux")] +#[test] +fn default_exclude_preserves_modes_and_missing_file_is_created_idempotently() { + for absent in [false, true] { + let temp = TempDir::new().unwrap(); + let repo_path = temp.path().join("repo"); + WorktreeManager::init_repository(&repo_path, "main").unwrap(); + let repo = crate::git_repository::open(&repo_path).unwrap(); + commit_readme(&repo).unwrap(); + let exclude = repo.commondir().join("info/exclude"); + let info = exclude.parent().unwrap(); + let info_mode = fs::metadata(info).unwrap().permissions().mode(); + if absent { + fs::remove_file(&exclude).unwrap(); + } else { + fs::set_permissions(&exclude, fs::Permissions::from_mode(0o640)).unwrap(); + } + let store = ManagedWorktreeRegistryStore::open(&repo).unwrap(); + let lock = store.lock().unwrap(); + ensure_default_worktree_exclude(&repo, &store, &lock).unwrap(); + let installed = + RegularFileBindingGuard::bind(&exclude, MAX_WORKTREE_GIT_TEXT_FILE_BYTES).unwrap(); + assert_eq!( + fs::metadata(&exclude).unwrap().permissions().mode() & 0o777, + if absent { 0o600 } else { 0o640 } + ); + assert_eq!(fs::metadata(info).unwrap().permissions().mode(), info_mode); + ensure_default_worktree_exclude(&repo, &store, &lock).unwrap(); + installed + .verify() + .expect("idempotence preserves the installed inode/generation"); + } +} + +#[cfg(target_os = "linux")] +#[test] +fn default_exclude_prospective_bytes_count_toward_aggregate_limit() { + let temp = TempDir::new().unwrap(); + let repo_path = temp.path().join("repo"); + WorktreeManager::init_repository(&repo_path, "main").unwrap(); + let repo = crate::git_repository::open(&repo_path).unwrap(); + commit_readme(&repo).unwrap(); + let config_len = fs::read(repo.commondir().join("config")).unwrap().len() * 2; + let per_file = MAX_WORKTREE_GIT_TEXT_FILE_BYTES as usize; + let capacity = MAX_WORKTREE_GIT_TEXT_TOTAL_BYTES as usize; + let mut remaining = capacity - config_len - 1; + for n in 0..16 { + let dir = repo_path.join(format!("ignore-{n}")); + fs::create_dir(&dir).unwrap(); + let len = remaining.min(per_file); + fs::write(dir.join(".gitignore"), vec![b'#'; len]).unwrap(); + remaining -= len; + } + assert_eq!(remaining, 0); + let exclude = repo.commondir().join("info/exclude"); + fs::write(&exclude, b"\n").unwrap(); + let store = ManagedWorktreeRegistryStore::open(&repo).unwrap(); + let lock = store.lock().unwrap(); + let error = ensure_default_worktree_exclude(&repo, &store, &lock).unwrap_err(); + assert!( + format!("{error:#}").contains("aggregate byte limit"), + "{error:#}" + ); + assert_eq!(fs::read(&exclude).unwrap(), b"\n"); + assert!(!default_worktree_root(&repo).exists()); +} + #[test] fn sweep_root_association_preserves_exact_legacy_and_repository_local_layouts() { let temp = TempDir::new().expect("tempdir"); From 3226feb62f44b4bf0ffacf3b24b729e30db8ba0e Mon Sep 17 00:00:00 2001 From: Meta-Develop <116134763+Meta-Develop@users.noreply.github.com> Date: Thu, 1 Oct 2026 04:37:53 +0900 Subject: [PATCH 5/6] fix: anchor managed exclusion checks to the repository --- src/worktree/part3.rs | 29 +++++++++++++++---------- src/worktree/tests.rs | 49 ++++++++++++++++++++++++------------------- 2 files changed, 45 insertions(+), 33 deletions(-) diff --git a/src/worktree/part3.rs b/src/worktree/part3.rs index 57ce0787..945f9337 100644 --- a/src/worktree/part3.rs +++ b/src/worktree/part3.rs @@ -4454,17 +4454,7 @@ fn require_default_group_ignored( ] { environment.insert(key.to_string(), value.to_string()); } - let args = [ - OsString::from("--git-dir"), - git_dir.as_os_str().to_owned(), - OsString::from("--work-tree"), - workdir.as_os_str().to_owned(), - OsString::from("check-ignore"), - OsString::from("--no-index"), - OsString::from("--quiet"), - OsString::from("--"), - OsString::from(rule.trim_start_matches('/')), - ]; + let args = default_group_ignore_args(git_dir, workdir, rule); runtime.verify()?; let output = run_process( ProcessSpec::direct( @@ -4495,6 +4485,23 @@ fn require_default_group_ignored( runtime.verify() } +fn default_group_ignore_args(git_dir: &Path, workdir: &Path, rule: &str) -> [OsString; 11] { + [ + // Git otherwise prefixes relative paths with the private runtime cwd. + OsString::from("-C"), + workdir.as_os_str().to_owned(), + OsString::from("--git-dir"), + git_dir.as_os_str().to_owned(), + OsString::from("--work-tree"), + workdir.as_os_str().to_owned(), + OsString::from("check-ignore"), + OsString::from("--no-index"), + OsString::from("--quiet"), + OsString::from("--"), + OsString::from(rule.trim_start_matches('/')), + ] +} + #[cfg(test)] fn reject_default_exclude_negations( preview: &Repository, diff --git a/src/worktree/tests.rs b/src/worktree/tests.rs index 137d0446..170baaae 100644 --- a/src/worktree/tests.rs +++ b/src/worktree/tests.rs @@ -373,38 +373,43 @@ fn default_exclude_post_git_closing_fence_rejects_binding_races() { } #[test] -fn default_exclude_gitignore_can_defeat_common_exclude() { +fn default_exclude_gitignore_from_nested_runtime_anchors_paths_and_preserves_negation() { let temp = TempDir::new().unwrap(); let workdir = temp.path().join("repo"); - fs::create_dir(&workdir).unwrap(); + let primary = Repository::init(&workdir).unwrap(); fs::create_dir_all(workdir.join(".maco/worktrees/repo")).unwrap(); - let preview = Repository::init(temp.path().join("preview")).unwrap(); + let runtime = primary.commondir().join("maco/state/exclude-preview"); + fs::create_dir_all(&runtime).unwrap(); + let preview = Repository::init(runtime.join("git")).unwrap(); + let rule = "/.maco/worktrees/repo/"; fs::write( preview.commondir().join("info/exclude"), "/.maco/worktrees/repo/\n", ) .unwrap(); - preview.set_workdir(&workdir, false).unwrap(); - assert!(preview - .status_should_ignore(Path::new(".maco/worktrees/repo/")) - .unwrap()); + let check = |anchored: bool| { + let args = default_group_ignore_args(preview.commondir(), &workdir, rule); + std::process::Command::new("git") + .args(if anchored { &args[..] } else { &args[2..] }) + .current_dir(&runtime) + .env_clear() + .env("GIT_CONFIG_NOSYSTEM", "1") + .env("GIT_CONFIG_GLOBAL", "/dev/null") + .status() + .unwrap() + }; + assert_eq!( + check(false).code(), + Some(1), + "old cwd prefix reproduces visibility" + ); + assert!( + check(true).success(), + "production args must anchor the exact group" + ); fs::write(workdir.join(".gitignore"), "!/.maco/worktrees/repo/\n").unwrap(); - let status = std::process::Command::new("git") - .arg("--git-dir") - .arg(preview.commondir()) - .arg("--work-tree") - .arg(&workdir) - .args([ - "check-ignore", - "--no-index", - "--quiet", - "--", - ".maco/worktrees/repo/", - ]) - .status() - .unwrap(); assert_eq!( - status.code(), + check(true).code(), Some(1), "native Git must honor the .gitignore negation" ); From 2c45e41132b050bd156c4e72a32f709d84dbdba5 Mon Sep 17 00:00:00 2001 From: Meta-Develop <116134763+Meta-Develop@users.noreply.github.com> Date: Thu, 1 Oct 2026 05:25:24 +0900 Subject: [PATCH 6/6] fix: keep nested worktree status caches outside the primary --- src/supervise/tests/acceptance_integrity.rs | 5 ++- src/worktree/part4.rs | 34 ++++++++++++--------- src/worktree/tests.rs | 27 ++++++++++++++++ 3 files changed, 51 insertions(+), 15 deletions(-) diff --git a/src/supervise/tests/acceptance_integrity.rs b/src/supervise/tests/acceptance_integrity.rs index d01d5996..b7e524d9 100644 --- a/src/supervise/tests/acceptance_integrity.rs +++ b/src/supervise/tests/acceptance_integrity.rs @@ -1641,7 +1641,10 @@ fn primary_snapshot_allows_managed_default_creation_and_still_detects_primary_ed commit_injected_repository(&repo_path, "track runtime file"); fs::write(repo_path.join(".gitignore"), "ignored.txt\n").expect("ignore ordinary primary file"); commit_injected_repository(&repo_path, "track ignore rule"); - fs::write(repo_path.join("ignored.txt"), "ignored baseline\n").expect("ignored primary file"); + fs::write(repo_path.join("ignored.txt"), "ignored baseline\n") + .expect("primary file matching ignore rule"); + run_injected_git(&repo_path, &["add", "-f", "ignored.txt"]); + commit_injected_repository(&repo_path, "track primary file matching ignore rule"); run_injected_git( &repo_path, &["update-index", "--skip-worktree", "README.md"], diff --git a/src/worktree/part4.rs b/src/worktree/part4.rs index 02504e85..fbf40c7e 100644 --- a/src/worktree/part4.rs +++ b/src/worktree/part4.rs @@ -1368,34 +1368,40 @@ fn worktree_local_bounded_status_runtime_root(worktree: &Path) -> Result(worktree: &'a Path, common_dir: &Path) -> Result<&'a Path> { let common_ancestor = worktree .ancestors() .find(|ancestor| common_dir.starts_with(ancestor)) .context( "worktree and Git common directory have no common ancestor for bounded-status runtime root", )?; - let outside_worktree = if common_ancestor == worktree { + let embedded_primary = common_dir.file_name() == Some(OsStr::new(".git")) + && common_dir.parent() == Some(common_ancestor); + let outside_worktree = if common_ancestor == worktree || embedded_primary { common_ancestor .parent() .context("worktree common ancestor has no parent for bounded-status runtime root")? } else { common_ancestor }; - let anchor = outside_worktree + outside_worktree .ancestors() .find(|ancestor| ancestor.to_str().is_some()) - .context("worktree has no UTF-8 ancestor for its private status alias")?; - let binding = stable_checksum(worktree.as_os_str().as_bytes()); - let directory_name = if cfg!(test) { - format!(".maco-test-worktree-status-{binding}") - } else { - format!( - ".maco-worktree-status-{}-{binding}", - bounded_status_runtime_user_id() - ) - }; - Ok(anchor.join(directory_name)) + .context("worktree has no UTF-8 ancestor for its private status alias") } #[cfg(target_os = "linux")] diff --git a/src/worktree/tests.rs b/src/worktree/tests.rs index 170baaae..690ca6f2 100644 --- a/src/worktree/tests.rs +++ b/src/worktree/tests.rs @@ -424,6 +424,7 @@ fn default_exclude_managed_creation_is_invisible_to_plain_git_and_index_add_all( WorktreeManager::init_repository(&repo_path, "main").unwrap(); let repo = crate::git_repository::open(&repo_path).unwrap(); commit_readme(&repo).unwrap(); + fs::create_dir(repo_path.join(".maco")).unwrap(); commit_descendant(&repo, ".maco/operator.txt", "operator baseline\n").unwrap(); let prior = b"# operator rules\r\noperator-ignored\r\n"; let exclude = repo.commondir().join("info/exclude"); @@ -827,6 +828,32 @@ fn bounded_status_parsers_are_lossless_and_fail_closed() { assert!(parse_nul_paths(b"../escape\0", 2).is_err()); } +#[test] +fn bounded_status_runtime_anchor_keeps_embedded_child_cache_outside_primary() { + let primary = Path::new("workspace/primary"); + let common = primary.join(".git"); + let child = primary.join(".maco/worktrees/primary/agent-a"); + let anchor = bounded_status_runtime_anchor(&child, &common).unwrap(); + assert_eq!(anchor, primary.parent().unwrap()); + assert!(!anchor.starts_with(primary)); + assert!(!anchor.starts_with(&child)); + assert_eq!( + bounded_status_runtime_anchor(primary, &common).unwrap(), + primary.parent().unwrap() + ); + + let external_child = Path::new("workspace/lanes/agent-a"); + assert_eq!( + bounded_status_runtime_anchor(external_child, &common).unwrap(), + Path::new("workspace") + ); + assert_eq!( + bounded_status_runtime_anchor(&child, &primary.join("shared.git")).unwrap(), + primary, + "other common-directory layouts retain their existing anchor" + ); +} + #[cfg(target_os = "linux")] fn init_bounded_status_runtime_root_repo(temp: &TempDir) -> PathBuf { let repo_path = temp.path().join("repo");