@@ -6,6 +6,106 @@ use uuid::Uuid;
66
77use crate :: worktree_guard:: WorktreeGuard ;
88
9+ /// Filename of the ownership manifest written at the root of every ADF
10+ /// worktree. Presence of this file with valid contents is the gate for
11+ /// cleanup: sweep only deletes entries carrying this sentinel.
12+ pub const WORKTREE_MANIFEST_FILENAME : & str = ".adf-worktree-manifest.json" ;
13+
14+ /// Ownership manifest stored at the root of each ADF worktree.
15+ ///
16+ /// Existence of this file with matching repo and path fields is the
17+ /// single gate for sweep/cleanup. Directories without a valid manifest
18+ /// are preserved regardless of name prefix or location.
19+ #[ derive( Debug , Clone , serde:: Serialize , serde:: Deserialize ) ]
20+ pub struct WorktreeManifest {
21+ /// Schema version for forward compatibility.
22+ pub version : u32 ,
23+ /// Git repository this worktree belongs to.
24+ pub repo_path : String ,
25+ /// Absolute path of this worktree (self-referential).
26+ pub worktree_path : String ,
27+ /// Name of the agent or component that created this worktree.
28+ pub creator : String ,
29+ /// Session or correlation ID linking this worktree to its task.
30+ pub session_id : String ,
31+ /// Process ID that performed the `git worktree add`.
32+ pub pid : u32 ,
33+ /// ISO-8601 timestamp of creation.
34+ pub created_at : String ,
35+ }
36+
37+ impl WorktreeManifest {
38+ /// Current schema version. Increment when the struct changes
39+ /// incompatibly.
40+ pub const CURRENT_VERSION : u32 = 1 ;
41+
42+ /// Write a manifest to `dir / WORKTREE_MANIFEST_FILENAME`.
43+ pub fn write_to_dir (
44+ & self ,
45+ dir : & Path ,
46+ ) -> Result < ( ) , std:: io:: Error > {
47+ let path = dir. join ( WORKTREE_MANIFEST_FILENAME ) ;
48+ let json = serde_json:: to_string_pretty ( self ) . map_err ( |e| {
49+ std:: io:: Error :: new ( std:: io:: ErrorKind :: InvalidData , e)
50+ } ) ?;
51+ std:: fs:: write ( & path, json) ?;
52+ debug ! ( path = %path. display( ) , "worktree manifest written" ) ;
53+ Ok ( ( ) )
54+ }
55+
56+ /// Read and validate a manifest from a directory.
57+ ///
58+ /// Returns `None` if the file is absent, unreadable, unparseable,
59+ /// or fails validation (wrong repo, wrong path, or future version).
60+ pub fn read_from_dir ( dir : & Path ) -> Option < Self > {
61+ let path = dir. join ( WORKTREE_MANIFEST_FILENAME ) ;
62+ let bytes = std:: fs:: read ( & path) . ok ( ) ?;
63+ let m: WorktreeManifest = serde_json:: from_slice ( & bytes) . ok ( ) ?;
64+ if m. version > Self :: CURRENT_VERSION {
65+ warn ! (
66+ path = %path. display( ) ,
67+ manifest_version = m. version,
68+ current_version = Self :: CURRENT_VERSION ,
69+ "worktree manifest version too new, skipping"
70+ ) ;
71+ return None ;
72+ }
73+ Some ( m)
74+ }
75+
76+ /// Check that the manifest's embedded paths match the expected
77+ /// repository and the actual directory on disk.
78+ pub fn validate ( & self , expected_repo : & Path , dir_on_disk : & Path ) -> bool {
79+ if self . repo_path != expected_repo. to_string_lossy ( ) {
80+ warn ! (
81+ manifest_repo = %self . repo_path,
82+ expected_repo = %expected_repo. display( ) ,
83+ "worktree manifest repo mismatch"
84+ ) ;
85+ return false ;
86+ }
87+ if self . worktree_path != dir_on_disk. to_string_lossy ( ) {
88+ warn ! (
89+ manifest_path = %self . worktree_path,
90+ dir_on_disk = %dir_on_disk. display( ) ,
91+ "worktree manifest path mismatch"
92+ ) ;
93+ return false ;
94+ }
95+ true
96+ }
97+
98+ /// Convenience: read from `dir` and validate against `expected_repo`.
99+ pub fn read_valid ( dir : & Path , expected_repo : & Path ) -> Option < Self > {
100+ let m = Self :: read_from_dir ( dir) ?;
101+ if m. validate ( expected_repo, dir) {
102+ Some ( m)
103+ } else {
104+ None
105+ }
106+ }
107+ }
108+
9109/// Directory-name prefix for compound-review worktrees.
10110///
11111/// Single source of truth referenced by:
@@ -315,6 +415,27 @@ impl WorktreeManager {
315415 }
316416
317417 info ! ( name = %name, path = %worktree_path. display( ) , "worktree created" ) ;
418+
419+ // Write ownership manifest so sweep can safely identify this
420+ // worktree as ADF-managed. Best-effort; a missing or invalid
421+ // manifest inhibits cleanup rather than breaking the worktree.
422+ let manifest = WorktreeManifest {
423+ version : WorktreeManifest :: CURRENT_VERSION ,
424+ repo_path : self . repo_path . to_string_lossy ( ) . to_string ( ) ,
425+ worktree_path : worktree_path. to_string_lossy ( ) . to_string ( ) ,
426+ creator : "orchestrator" . to_string ( ) ,
427+ session_id : name. to_string ( ) ,
428+ pid : std:: process:: id ( ) ,
429+ created_at : chrono:: Utc :: now ( ) . to_rfc3339 ( ) ,
430+ } ;
431+ if let Err ( e) = manifest. write_to_dir ( & worktree_path) {
432+ warn ! (
433+ path = %worktree_path. display( ) ,
434+ error = %e,
435+ "failed to write worktree manifest; cleanup will skip this entry"
436+ ) ;
437+ }
438+
318439 Ok ( WorktreeGuard :: for_managed ( & self . repo_path , worktree_path) )
319440 }
320441
@@ -538,6 +659,7 @@ impl WorktreeManager {
538659 swept_count = report. swept_count,
539660 failed_count = report. failed_count,
540661 root_owned_skipped = report. root_owned_skipped,
662+ no_manifest_skipped = report. no_manifest_skipped,
541663 prune_succeeded = report. prune_succeeded,
542664 duration_ms = report. duration_ms,
543665 backlog_count = report. swept_count + report. root_owned_skipped,
@@ -548,12 +670,36 @@ impl WorktreeManager {
548670
549671 /// Remove one worktree path, updating `report` in place.
550672 ///
673+ /// Before deletion, checks for a valid [`WorktreeManifest`]. If the
674+ /// manifest is absent, invalid, or mismatched, the directory is
675+ /// preserved regardless of naming convention. This prevents
676+ /// accidental deletion of non-ADF directories that happen to share
677+ /// the `review-` prefix or live under `/tmp/adf-worktrees`.
678+ ///
551679 /// Tries `git worktree remove --force` first so the git admin
552680 /// registry stays in sync; falls back to `remove_dir_all` on
553681 /// non-zero exit. Permission-denied during the fallback path is
554682 /// counted as `root_owned_skipped` (Layer 3 territory) rather
555683 /// than a hard failure.
556684 fn sweep_one ( & self , path : & Path , report : & mut SweepReport ) {
685+ // Reject entries that do not carry a valid manifest.
686+ let manifest = match WorktreeManifest :: read_valid ( path, & self . repo_path ) {
687+ Some ( m) => m,
688+ None => {
689+ warn ! (
690+ path = %path. display( ) ,
691+ "sweep_stale skipping directory without valid ADF manifest"
692+ ) ;
693+ report. no_manifest_skipped += 1 ;
694+ return ;
695+ }
696+ } ;
697+ debug ! (
698+ path = %path. display( ) ,
699+ creator = %manifest. creator,
700+ session_id = %manifest. session_id,
701+ "sweep_stale found valid manifest, proceeding with removal"
702+ ) ;
557703 let status = std:: process:: Command :: new ( "git" )
558704 . arg ( "-C" )
559705 . arg ( & self . repo_path )
@@ -621,6 +767,10 @@ pub struct SweepReport {
621767 /// permission. These belong to Layer 3 (`adf-cleanup.sh` run as
622768 /// root via `ExecStartPre`).
623769 pub root_owned_skipped : usize ,
770+ /// Number of entries skipped because no valid ADF worktree
771+ /// manifest was found in the directory. Protects non-ADF data
772+ /// from accidental deletion.
773+ pub no_manifest_skipped : usize ,
624774 /// Whether `git worktree prune --verbose` exited zero. False
625775 /// implies the git admin registry under `<repo>/.git/worktrees`
626776 /// may still hold dangling entries; the next sweep will retry.
@@ -888,6 +1038,22 @@ mod tests {
8881038 ( temp_dir, repo_path)
8891039 }
8901040
1041+ /// Write a valid ADF worktree manifest into `dir` for testing.
1042+ /// Used by sweep tests to ensure test directories are recognised
1043+ /// as ADF-managed worktrees.
1044+ fn write_test_manifest ( dir : & Path , repo_path : & Path ) {
1045+ let manifest = WorktreeManifest {
1046+ version : WorktreeManifest :: CURRENT_VERSION ,
1047+ repo_path : repo_path. to_string_lossy ( ) . to_string ( ) ,
1048+ worktree_path : dir. to_string_lossy ( ) . to_string ( ) ,
1049+ creator : "test" . to_string ( ) ,
1050+ session_id : "test-session" . to_string ( ) ,
1051+ pid : std:: process:: id ( ) ,
1052+ created_at : chrono:: Utc :: now ( ) . to_rfc3339 ( ) ,
1053+ } ;
1054+ manifest. write_to_dir ( dir) . expect ( "write test manifest" ) ;
1055+ }
1056+
8911057 #[ tokio:: test]
8921058 async fn test_create_worktree ( ) {
8931059 let ( _temp_dir, repo_path) = setup_git_repo ( ) ;
@@ -1057,7 +1223,8 @@ mod tests {
10571223 // Seed three `review-*` directories as plain dirs (not real
10581224 // worktrees -- sweep_one falls back to remove_dir_all when git
10591225 // refuses an unregistered path, which is exactly the
1060- // residue-after-SIGKILL shape).
1226+ // residue-after-SIGKILL shape). Each must carry a valid
1227+ // manifest or the sweep will preserve it.
10611228 let ( _temp_dir, repo_path) = setup_git_repo ( ) ;
10621229 let base = repo_path. join ( ".worktrees" ) ;
10631230 std:: fs:: create_dir_all ( & base) . unwrap ( ) ;
@@ -1066,6 +1233,7 @@ mod tests {
10661233 let dir = base. join ( format ! ( "{}{}" , WORKTREE_REVIEW_PREFIX , i) ) ;
10671234 std:: fs:: create_dir_all ( & dir) . unwrap ( ) ;
10681235 std:: fs:: write ( dir. join ( "dummy.txt" ) , "residue" ) . unwrap ( ) ;
1236+ write_test_manifest ( & dir, & repo_path) ;
10691237 }
10701238
10711239 let manager = WorktreeManager :: with_base ( & repo_path, & base) ;
@@ -1074,7 +1242,7 @@ mod tests {
10741242 assert_eq ! ( report. swept_count, 3 , "all three review dirs swept" ) ;
10751243 assert_eq ! ( report. failed_count, 0 ) ;
10761244 assert_eq ! ( report. root_owned_skipped, 0 ) ;
1077- assert ! ( report. prune_succeeded ) ;
1245+ assert_eq ! ( report. no_manifest_skipped , 0 ) ;
10781246
10791247 for i in 0 ..3 {
10801248 let dir = base. join ( format ! ( "{}{}" , WORKTREE_REVIEW_PREFIX , i) ) ;
@@ -1084,8 +1252,9 @@ mod tests {
10841252
10851253 #[ test]
10861254 fn test_sweep_stale_preserves_non_review_prefix ( ) {
1087- // Only `review-` prefixed entries are swept from worktree_base.
1088- // Sibling directories under the same base must be preserved.
1255+ // Only `review-` prefixed entries with valid manifests are
1256+ // swept from worktree_base. `keep-me` lacks BOTH a review
1257+ // prefix AND a manifest, so it must survive.
10891258 let ( _temp_dir, repo_path) = setup_git_repo ( ) ;
10901259 let base = repo_path. join ( ".worktrees" ) ;
10911260 std:: fs:: create_dir_all ( & base) . unwrap ( ) ;
@@ -1095,6 +1264,7 @@ mod tests {
10951264 std:: fs:: create_dir_all ( & review_dir) . unwrap ( ) ;
10961265 std:: fs:: create_dir_all ( & keep_dir) . unwrap ( ) ;
10971266 std:: fs:: write ( keep_dir. join ( "important.txt" ) , "data" ) . unwrap ( ) ;
1267+ write_test_manifest ( & review_dir, & repo_path) ;
10981268
10991269 let manager = WorktreeManager :: with_base ( & repo_path, & base) ;
11001270 let report = manager. sweep_stale ( & [ ] ) ;
@@ -1129,6 +1299,7 @@ mod tests {
11291299 fn test_sweep_stale_extra_roots_no_prefix_filter ( ) {
11301300 // Entries under extra_roots are swept regardless of prefix --
11311301 // the per-agent root convention has no naming convention.
1302+ // Each must carry a valid manifest.
11321303 let ( _temp_dir, repo_path) = setup_git_repo ( ) ;
11331304 let base = repo_path. join ( ".worktrees" ) ;
11341305 std:: fs:: create_dir_all ( & base) . unwrap ( ) ;
@@ -1140,6 +1311,7 @@ mod tests {
11401311 let agent_dir = extra. join ( "agent-alpha" ) ;
11411312 std:: fs:: create_dir_all ( & agent_dir) . unwrap ( ) ;
11421313 std:: fs:: write ( agent_dir. join ( "scratch.txt" ) , "tmp" ) . unwrap ( ) ;
1314+ write_test_manifest ( & agent_dir, & repo_path) ;
11431315
11441316 let manager = WorktreeManager :: with_base ( & repo_path, & base) ;
11451317 let report = manager. sweep_stale ( std:: slice:: from_ref ( & extra) ) ;
0 commit comments