Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 25 additions & 11 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -1017,8 +1017,21 @@ 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/<repo-name>/<agent-id>`. Completed task branches can be
Default linked worktrees are created inside the repository at the stable path
`.maco/worktrees/<sanitized-repo-name>/<agent-id>`. 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. Implicit default creation registers only
`/.maco/worktrees/<sanitized-repo-name>/` 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/<repo-name>` 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.

Expand Down Expand Up @@ -2346,10 +2359,11 @@ cargo run -- worktree sweep --workspace /exact/path/to/workspace \
--apply --keep-targets
```

The sweep discovers workspace-managed `.maco/worktrees/<repo>` roots through the
same default-root function used by managed creation. It also recognizes the
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
`<repo>/.maco/worktrees/<sanitized-repo-name>` and the legacy parent-workspace
`<repo-parent>/.maco/worktrees/<sanitized-repo-name>` 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
Expand All @@ -2362,11 +2376,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, `<repo>/.maco/worktrees` is not adopted as that
repository's managed root because its creation default is
`<repo-parent>/.maco/worktrees/<repo>`; sweep the parent workspace for that
layout, or use the separately validated `<repo>/.worktrees` convention.
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.
`<repo>/.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
Expand Down
8 changes: 4 additions & 4 deletions src/cli/part2.rs
Original file line number Diff line number Diff line change
Expand Up @@ -536,7 +536,7 @@ struct CreateWorktreeArgs {
/// Base revision used when creating a new branch. Defaults to HEAD.
#[arg(long)]
base: Option<String>,
/// Parent directory for agent worktrees.
/// Parent directory for agent worktrees. Defaults to <repo>/.maco/worktrees/<sanitized-repo-name>.
#[arg(long)]
worktree_root: Option<PathBuf>,
/// After creation, remove eligible older clean worktrees older than this many seconds.
Expand Down Expand Up @@ -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 <repo>/.maco/worktrees/<sanitized-repo-name>.
#[arg(long)]
worktree_root: Option<PathBuf>,
/// List cleanup actions without removing anything.
Expand Down Expand Up @@ -607,7 +607,7 @@ struct GcWorktreeArgs {

#[derive(Debug, Args)]
struct SweepWorktreeArgs {
/// Workspace containing .maco/worktrees/<repo>/<lane> directories.
/// Repository or workspace containing grouped .maco/worktrees roots or manual <repo>/.worktrees roots.
#[arg(long)]
workspace: PathBuf,
/// Apply cleanup. Without this flag, the sweep is a dry-run.
Expand Down Expand Up @@ -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 <repo>/.maco/worktrees/<sanitized-repo-name>.
#[arg(long)]
worktree_root: Option<PathBuf>,
/// Remove only eligible clean worktrees older than this many seconds.
Expand Down
82 changes: 82 additions & 0 deletions src/supervise/tests/acceptance_integrity.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1630,6 +1630,88 @@ 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("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"],
);
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();
Expand Down
1 change: 1 addition & 0 deletions src/supervise/tests/run_artifacts.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(".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)
Expand Down
13 changes: 13 additions & 0 deletions src/worktree.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2633,6 +2633,15 @@ impl WorktreeManager {
);
}
}
let default_exclude = if options.worktree_root.is_none() {
Some(ensure_default_worktree_exclude(
&repo,
&registry_store,
&registry_lock,
)?)
} else {
None
};
let requested_root = options
.worktree_root
.unwrap_or_else(|| default_worktree_root(&repo));
Expand All @@ -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)?;
Expand Down Expand Up @@ -4400,6 +4412,7 @@ pub fn sweep_workspace_worktrees(options: WorktreeSweepOptions) -> Result<Worktr
.cmp(&right.group)
.then_with(|| left.worktree_root.cmp(&right.worktree_root))
});
roots.dedup_by(|left, right| left.worktree_root == right.worktree_root);
let discovery_status = if roots.is_empty() {
WorktreeSweepDiscoveryStatus::NoRootsDiscovered
} else {
Expand Down
63 changes: 54 additions & 9 deletions src/worktree/part2.rs
Original file line number Diff line number Diff line change
Expand Up @@ -796,6 +796,16 @@ fn discover_repository_local_sweep_roots(
let repository = workspace.join(&child.name);
if path_entry_exists(&repository.join(".git"))? {
add_repository_local_sweep_root(&repository, &mut roots)?;
let group = child
.name
.to_str()
.map(sanitize_path_segment)
.unwrap_or_else(|| "repository".to_string());
roots.extend(
discover_workspace_managed_sweep_roots(&repository)?
.into_iter()
.filter(|candidate| candidate.group == group),
);
}
}
Ok(roots)
Expand Down Expand Up @@ -1448,6 +1458,29 @@ fn resolve_sweep_repository_from_workspace(
sweep_failure(WorktreeSweepFailureKind::RepositoryAssociation, error)
})?;
let mut candidates = Vec::new();
if workspace
.file_name()
.and_then(OsStr::to_str)
.map(sanitize_path_segment)
.as_deref()
== Some(group)
&& group_root == workspace.join(".maco").join("worktrees").join(group)
{
match fs::symlink_metadata(workspace.join(".git")) {
Ok(metadata) if metadata.is_dir() && !metadata.file_type().is_symlink() => {
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;
Expand Down Expand Up @@ -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)
}
Expand All @@ -1654,21 +1704,16 @@ 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 => default_worktree_root(primary),
WorktreeSweepRootKind::WorkspaceManaged if is_project_managed_root => project_managed_root,
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 {
Expand Down
Loading
Loading