@@ -6,6 +6,102 @@ 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 ( & self , dir : & Path ) -> Result < ( ) , std:: io:: Error > {
44+ let path = dir. join ( WORKTREE_MANIFEST_FILENAME ) ;
45+ let json = serde_json:: to_string_pretty ( self )
46+ . map_err ( |e| std:: io:: Error :: new ( std:: io:: ErrorKind :: InvalidData , e) ) ?;
47+ std:: fs:: write ( & path, json) ?;
48+ debug ! ( path = %path. display( ) , "worktree manifest written" ) ;
49+ Ok ( ( ) )
50+ }
51+
52+ /// Read and validate a manifest from a directory.
53+ ///
54+ /// Returns `None` if the file is absent, unreadable, unparseable,
55+ /// or fails validation (wrong repo, wrong path, or future version).
56+ pub fn read_from_dir ( dir : & Path ) -> Option < Self > {
57+ let path = dir. join ( WORKTREE_MANIFEST_FILENAME ) ;
58+ let bytes = std:: fs:: read ( & path) . ok ( ) ?;
59+ let m: WorktreeManifest = serde_json:: from_slice ( & bytes) . ok ( ) ?;
60+ if m. version > Self :: CURRENT_VERSION {
61+ warn ! (
62+ path = %path. display( ) ,
63+ manifest_version = m. version,
64+ current_version = Self :: CURRENT_VERSION ,
65+ "worktree manifest version too new, skipping"
66+ ) ;
67+ return None ;
68+ }
69+ Some ( m)
70+ }
71+
72+ /// Check that the manifest's embedded paths match the expected
73+ /// repository and the actual directory on disk.
74+ pub fn validate ( & self , expected_repo : & Path , dir_on_disk : & Path ) -> bool {
75+ if self . repo_path != expected_repo. to_string_lossy ( ) {
76+ warn ! (
77+ manifest_repo = %self . repo_path,
78+ expected_repo = %expected_repo. display( ) ,
79+ "worktree manifest repo mismatch"
80+ ) ;
81+ return false ;
82+ }
83+ if self . worktree_path != dir_on_disk. to_string_lossy ( ) {
84+ warn ! (
85+ manifest_path = %self . worktree_path,
86+ dir_on_disk = %dir_on_disk. display( ) ,
87+ "worktree manifest path mismatch"
88+ ) ;
89+ return false ;
90+ }
91+ true
92+ }
93+
94+ /// Convenience: read from `dir` and validate against `expected_repo`.
95+ pub fn read_valid ( dir : & Path , expected_repo : & Path ) -> Option < Self > {
96+ let m = Self :: read_from_dir ( dir) ?;
97+ if m. validate ( expected_repo, dir) {
98+ Some ( m)
99+ } else {
100+ None
101+ }
102+ }
103+ }
104+
9105/// Directory-name prefix for compound-review worktrees.
10106///
11107/// Single source of truth referenced by:
@@ -315,6 +411,27 @@ impl WorktreeManager {
315411 }
316412
317413 info ! ( name = %name, path = %worktree_path. display( ) , "worktree created" ) ;
414+
415+ // Write ownership manifest so sweep can safely identify this
416+ // worktree as ADF-managed. Best-effort; a missing or invalid
417+ // manifest inhibits cleanup rather than breaking the worktree.
418+ let manifest = WorktreeManifest {
419+ version : WorktreeManifest :: CURRENT_VERSION ,
420+ repo_path : self . repo_path . to_string_lossy ( ) . to_string ( ) ,
421+ worktree_path : worktree_path. to_string_lossy ( ) . to_string ( ) ,
422+ creator : "orchestrator" . to_string ( ) ,
423+ session_id : name. to_string ( ) ,
424+ pid : std:: process:: id ( ) ,
425+ created_at : chrono:: Utc :: now ( ) . to_rfc3339 ( ) ,
426+ } ;
427+ if let Err ( e) = manifest. write_to_dir ( & worktree_path) {
428+ warn ! (
429+ path = %worktree_path. display( ) ,
430+ error = %e,
431+ "failed to write worktree manifest; cleanup will skip this entry"
432+ ) ;
433+ }
434+
318435 Ok ( WorktreeGuard :: for_managed ( & self . repo_path , worktree_path) )
319436 }
320437
@@ -538,6 +655,7 @@ impl WorktreeManager {
538655 swept_count = report. swept_count,
539656 failed_count = report. failed_count,
540657 root_owned_skipped = report. root_owned_skipped,
658+ no_manifest_skipped = report. no_manifest_skipped,
541659 prune_succeeded = report. prune_succeeded,
542660 duration_ms = report. duration_ms,
543661 backlog_count = report. swept_count + report. root_owned_skipped,
@@ -548,12 +666,36 @@ impl WorktreeManager {
548666
549667 /// Remove one worktree path, updating `report` in place.
550668 ///
669+ /// Before deletion, checks for a valid [`WorktreeManifest`]. If the
670+ /// manifest is absent, invalid, or mismatched, the directory is
671+ /// preserved regardless of naming convention. This prevents
672+ /// accidental deletion of non-ADF directories that happen to share
673+ /// the `review-` prefix or live under `/tmp/adf-worktrees`.
674+ ///
551675 /// Tries `git worktree remove --force` first so the git admin
552676 /// registry stays in sync; falls back to `remove_dir_all` on
553677 /// non-zero exit. Permission-denied during the fallback path is
554678 /// counted as `root_owned_skipped` (Layer 3 territory) rather
555679 /// than a hard failure.
556680 fn sweep_one ( & self , path : & Path , report : & mut SweepReport ) {
681+ // Reject entries that do not carry a valid manifest.
682+ let manifest = match WorktreeManifest :: read_valid ( path, & self . repo_path ) {
683+ Some ( m) => m,
684+ None => {
685+ warn ! (
686+ path = %path. display( ) ,
687+ "sweep_stale skipping directory without valid ADF manifest"
688+ ) ;
689+ report. no_manifest_skipped += 1 ;
690+ return ;
691+ }
692+ } ;
693+ debug ! (
694+ path = %path. display( ) ,
695+ creator = %manifest. creator,
696+ session_id = %manifest. session_id,
697+ "sweep_stale found valid manifest, proceeding with removal"
698+ ) ;
557699 let status = std:: process:: Command :: new ( "git" )
558700 . arg ( "-C" )
559701 . arg ( & self . repo_path )
@@ -621,6 +763,10 @@ pub struct SweepReport {
621763 /// permission. These belong to Layer 3 (`adf-cleanup.sh` run as
622764 /// root via `ExecStartPre`).
623765 pub root_owned_skipped : usize ,
766+ /// Number of entries skipped because no valid ADF worktree
767+ /// manifest was found in the directory. Protects non-ADF data
768+ /// from accidental deletion.
769+ pub no_manifest_skipped : usize ,
624770 /// Whether `git worktree prune --verbose` exited zero. False
625771 /// implies the git admin registry under `<repo>/.git/worktrees`
626772 /// may still hold dangling entries; the next sweep will retry.
@@ -888,6 +1034,22 @@ mod tests {
8881034 ( temp_dir, repo_path)
8891035 }
8901036
1037+ /// Write a valid ADF worktree manifest into `dir` for testing.
1038+ /// Used by sweep tests to ensure test directories are recognised
1039+ /// as ADF-managed worktrees.
1040+ fn write_test_manifest ( dir : & Path , repo_path : & Path ) {
1041+ let manifest = WorktreeManifest {
1042+ version : WorktreeManifest :: CURRENT_VERSION ,
1043+ repo_path : repo_path. to_string_lossy ( ) . to_string ( ) ,
1044+ worktree_path : dir. to_string_lossy ( ) . to_string ( ) ,
1045+ creator : "test" . to_string ( ) ,
1046+ session_id : "test-session" . to_string ( ) ,
1047+ pid : std:: process:: id ( ) ,
1048+ created_at : chrono:: Utc :: now ( ) . to_rfc3339 ( ) ,
1049+ } ;
1050+ manifest. write_to_dir ( dir) . expect ( "write test manifest" ) ;
1051+ }
1052+
8911053 #[ tokio:: test]
8921054 async fn test_create_worktree ( ) {
8931055 let ( _temp_dir, repo_path) = setup_git_repo ( ) ;
@@ -1057,7 +1219,8 @@ mod tests {
10571219 // Seed three `review-*` directories as plain dirs (not real
10581220 // worktrees -- sweep_one falls back to remove_dir_all when git
10591221 // refuses an unregistered path, which is exactly the
1060- // residue-after-SIGKILL shape).
1222+ // residue-after-SIGKILL shape). Each must carry a valid
1223+ // manifest or the sweep will preserve it.
10611224 let ( _temp_dir, repo_path) = setup_git_repo ( ) ;
10621225 let base = repo_path. join ( ".worktrees" ) ;
10631226 std:: fs:: create_dir_all ( & base) . unwrap ( ) ;
@@ -1066,6 +1229,7 @@ mod tests {
10661229 let dir = base. join ( format ! ( "{}{}" , WORKTREE_REVIEW_PREFIX , i) ) ;
10671230 std:: fs:: create_dir_all ( & dir) . unwrap ( ) ;
10681231 std:: fs:: write ( dir. join ( "dummy.txt" ) , "residue" ) . unwrap ( ) ;
1232+ write_test_manifest ( & dir, & repo_path) ;
10691233 }
10701234
10711235 let manager = WorktreeManager :: with_base ( & repo_path, & base) ;
@@ -1074,7 +1238,7 @@ mod tests {
10741238 assert_eq ! ( report. swept_count, 3 , "all three review dirs swept" ) ;
10751239 assert_eq ! ( report. failed_count, 0 ) ;
10761240 assert_eq ! ( report. root_owned_skipped, 0 ) ;
1077- assert ! ( report. prune_succeeded ) ;
1241+ assert_eq ! ( report. no_manifest_skipped , 0 ) ;
10781242
10791243 for i in 0 ..3 {
10801244 let dir = base. join ( format ! ( "{}{}" , WORKTREE_REVIEW_PREFIX , i) ) ;
@@ -1084,8 +1248,9 @@ mod tests {
10841248
10851249 #[ test]
10861250 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.
1251+ // Only `review-` prefixed entries with valid manifests are
1252+ // swept from worktree_base. `keep-me` lacks BOTH a review
1253+ // prefix AND a manifest, so it must survive.
10891254 let ( _temp_dir, repo_path) = setup_git_repo ( ) ;
10901255 let base = repo_path. join ( ".worktrees" ) ;
10911256 std:: fs:: create_dir_all ( & base) . unwrap ( ) ;
@@ -1095,6 +1260,7 @@ mod tests {
10951260 std:: fs:: create_dir_all ( & review_dir) . unwrap ( ) ;
10961261 std:: fs:: create_dir_all ( & keep_dir) . unwrap ( ) ;
10971262 std:: fs:: write ( keep_dir. join ( "important.txt" ) , "data" ) . unwrap ( ) ;
1263+ write_test_manifest ( & review_dir, & repo_path) ;
10981264
10991265 let manager = WorktreeManager :: with_base ( & repo_path, & base) ;
11001266 let report = manager. sweep_stale ( & [ ] ) ;
@@ -1129,6 +1295,7 @@ mod tests {
11291295 fn test_sweep_stale_extra_roots_no_prefix_filter ( ) {
11301296 // Entries under extra_roots are swept regardless of prefix --
11311297 // the per-agent root convention has no naming convention.
1298+ // Each must carry a valid manifest.
11321299 let ( _temp_dir, repo_path) = setup_git_repo ( ) ;
11331300 let base = repo_path. join ( ".worktrees" ) ;
11341301 std:: fs:: create_dir_all ( & base) . unwrap ( ) ;
@@ -1140,6 +1307,7 @@ mod tests {
11401307 let agent_dir = extra. join ( "agent-alpha" ) ;
11411308 std:: fs:: create_dir_all ( & agent_dir) . unwrap ( ) ;
11421309 std:: fs:: write ( agent_dir. join ( "scratch.txt" ) , "tmp" ) . unwrap ( ) ;
1310+ write_test_manifest ( & agent_dir, & repo_path) ;
11431311
11441312 let manager = WorktreeManager :: with_base ( & repo_path, & base) ;
11451313 let report = manager. sweep_stale ( std:: slice:: from_ref ( & extra) ) ;
0 commit comments