1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
//! Which registered worktrees one `prune-worktrees` invocation may touch (#8782).
//!
//! Why: `tm session prune-worktrees` enumerated every registered project's
//! worktrees, so a run typed inside one repository surveyed — and under
//! `--force` could reclaim — the worktrees of every project the daemon knows.
//! It also could not be exercised against one throwaway repository.
//! What: [`WorktreeScope`], an optional project root and an optional allowlist
//! of paths. A scanned worktree is in scope only when both admit it; the empty
//! scope ([`WorktreeScope::all`]) admits everything, which is the pre-#8782
//! daemon-global behaviour the automatic sweep keeps. [`PruneScope`] is one
//! request's pair of them: one per pass, each with its own allowlist.
//! Test: `worktree_scope_tests`.
use std::collections::BTreeSet;
use std::path::{Path, PathBuf};
use super::worktree_registry::ScannedWorktree;
/// The project and path bounds of one prune pass (#8782).
///
/// Why: a scope that fails to resolve must never widen to every project, so
/// both bounds are canonical paths fixed when the request is parsed.
/// What: `project` is the canonical checkout that owns the worktree registry;
/// `only` is the canonical allowlist a `--force` run carries — the set its own
/// preview listed. `discard` is the subset whose unsaved work that preview
/// named; `--discard-dirty` removes no other dirty tree.
/// Test: `a_project_scope_admits_only_that_projects_worktrees`,
/// `an_allowlist_admits_only_listed_paths`,
/// `a_tree_dirtied_after_a_clean_preview_is_not_discarded`.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct WorktreeScope {
project: Option<PathBuf>,
only: Option<BTreeSet<PathBuf>>,
discard: Option<BTreeSet<PathBuf>>,
}
/// Canonicalize each path of an optional allowlist (raw when it does not resolve).
fn canonical_set(paths: Option<&[String]>) -> Option<BTreeSet<PathBuf>> {
paths.map(|paths| paths.iter().map(|p| canonical(Path::new(p))).collect())
}
/// Resolve a path's symlinks, keeping the raw path when it does not resolve.
fn canonical(path: &Path) -> PathBuf {
std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf())
}
impl WorktreeScope {
/// The unbounded scope: every registered project, every path.
pub fn all() -> Self {
Self::default()
}
/// The scope of one project's checkout, every path in it (#8782).
///
/// What: `Err` when `root` does not resolve — never the unbounded scope.
/// Test: `a_pause_prunes_only_its_own_projects_orphans`.
pub fn for_project(root: &Path) -> std::io::Result<Self> {
Ok(Self {
project: Some(std::fs::canonicalize(root)?),
..Self::default()
})
}
/// Build a scope from the prune route's request fields (#8782).
///
/// Why: a named project root that does not resolve is a caller error. Treating
/// it as "no scope" would turn a typo into a daemon-global sweep.
/// What: canonicalizes `project_root` (an `Err` when it does not resolve) and
/// every `only_paths` entry (kept raw when it does not resolve, so it can
/// match nothing a scan reports).
/// Test: `a_project_root_that_does_not_resolve_is_refused`.
pub fn from_request(
project_root: Option<&str>,
only_paths: Option<&[String]>,
) -> Result<Self, String> {
let project = match project_root {
None => None,
Some(raw) => Some(std::fs::canonicalize(raw).map_err(|e| {
format!(
"project_root `{raw}` does not resolve ({e}) — refusing rather than \
widening the prune to every project (#8782)"
)
})?),
};
Ok(Self {
project,
only: canonical_set(only_paths),
discard: None,
})
}
/// Bound `--discard-dirty` to `paths`: the trees whose unsaved work the
/// operator's preview named (#8782). `None` leaves it unbounded.
pub fn with_discard_only(mut self, paths: Option<&[String]>) -> Self {
self.discard = canonical_set(paths);
self
}
/// Whether a removal may discard `path`'s unsaved work: `true` when no
/// discard allowlist is set, else only when it lists `path` (#8782).
/// Test: `a_tree_dirtied_after_a_clean_preview_is_not_discarded`.
pub(crate) fn may_discard(&self, path: &Path) -> bool {
self.discard
.as_ref()
.is_none_or(|d| d.contains(&canonical(path)))
}
/// Whether `scanned` lies inside this scope.
///
/// What: in the project bound when `project` is unset or equals either the
/// registry root or the managed project directory the scan attributed; in
/// the path bound when `only` is unset or lists the path exactly.
///
/// The two project arms are deliberately asymmetric for a walk project
/// `<repo>` with a `.base` clone. Scoped to `<repo>`, the `project` arm
/// admits the worktrees of BOTH registries, `<repo>` and `<repo>/.base`,
/// because the scan attributes both to the managed directory `<repo>` — one
/// project, two clones. Scoped to `<repo>/.base` (a run from inside the
/// `.base` clone), only the `registry_root` arm can match, so it admits
/// `.base`'s own worktrees and not `<repo>`'s. The narrower answer is the
/// safe one: a scope never reaches a registry the caller did not run from,
/// except the `.base` clone of the checkout the caller did run from.
/// Test: `a_project_scope_admits_only_that_projects_worktrees`,
/// `an_allowlist_admits_only_listed_paths`.
pub(crate) fn admits(&self, scanned: &ScannedWorktree) -> bool {
let in_project = self.project.as_ref().is_none_or(|p| {
*p == canonical(&scanned.registry_root) || *p == canonical(&scanned.project)
});
let listed = self
.only
.as_ref()
.is_none_or(|only| only.contains(&canonical(&scanned.path)));
in_project && listed
}
/// Whether the daemon's scan reaches this scope's project at all (#8782).
///
/// Why: a scoped run from a checkout the daemon does not scan finds
/// nothing, and `total: 0` alone reads as "nothing to prune".
/// What: `true` for the unbounded scope. Otherwise `true` when the project
/// is one of the scan's anchors — an adopted checkout, or a
/// `<repos_root>/<owner>/<repo>` directory or its `.base` clone. Reads
/// directories only; spawns no `git`.
/// Test: `project_known_is_false_for_a_checkout_the_daemon_does_not_scan`.
pub fn project_known(&self, repos_root: &Path, adopted: &[PathBuf]) -> bool {
let Some(project) = self.project.as_ref() else {
return true;
};
if adopted.iter().any(|a| canonical(a) == *project) {
return true;
}
let Ok(owners) = std::fs::read_dir(repos_root) else {
return false;
};
owners
.flatten()
.filter_map(|owner| std::fs::read_dir(owner.path()).ok())
.flat_map(|repos| repos.flatten())
.map(|repo| canonical(&repo.path()))
.any(|dir| {
dir == *project
|| dir.join(super::worktree_registry::BASE_CLONE_DIRNAME) == *project
})
}
/// The canonical project root, when the scope names one.
fn project_echo(&self) -> Option<String> {
self.project
.as_ref()
.map(|p| p.to_string_lossy().into_owned())
}
}
/// One prune request's scope: the same project bound for both passes, and a
/// separate allowlist for each (#8782).
///
/// Why: a `--force` run hands back what its preview listed. One shared
/// allowlist let the merged-PR pass remove a path the preview listed only as an
/// orphan, and the reverse; one per pass bounds each pass by its own rows.
/// What: `orphan` bounds the orphan sweep, `merged` the merged-PR pass.
/// Test: `a_per_pass_allowlist_bounds_each_pass_by_its_own_rows`.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct PruneScope {
/// The orphan sweep's bounds.
pub orphan: WorktreeScope,
/// The merged-PR pass's bounds.
pub merged: WorktreeScope,
}
impl PruneScope {
/// Build both scopes from the route's request fields; see
/// [`WorktreeScope::from_request`] for the refusal rule. The discard
/// allowlist bounds the orphan pass only: the merged-PR pass never
/// discards unsaved work.
///
/// Test: `a_project_root_that_does_not_resolve_is_refused`.
pub fn from_request(
project_root: Option<&str>,
only_orphan_paths: Option<&[String]>,
only_merged_paths: Option<&[String]>,
only_discard_paths: Option<&[String]>,
) -> Result<Self, String> {
Ok(Self {
orphan: WorktreeScope::from_request(project_root, only_orphan_paths)?
.with_discard_only(only_discard_paths),
merged: WorktreeScope::from_request(project_root, only_merged_paths)?,
})
}
/// The echo the prune route returns, so a client can tell a daemon that
/// honoured the scope from one that predates it (#8782).
///
/// What: `project_root` (path or null), `project_known` (see
/// [`WorktreeScope::project_known`]), and each allowlist's size or null —
/// always present as keys, so a client can refuse a reply that lacks one.
/// Test: `the_scope_echo_names_the_project_and_the_allowlist_size`.
pub fn echo(&self, project_known: bool) -> serde_json::Value {
serde_json::json!({
"project_root": self.orphan.project_echo(),
"project_known": project_known,
"only_orphan_paths": self.orphan.only.as_ref().map(BTreeSet::len),
"only_merged_paths": self.merged.only.as_ref().map(BTreeSet::len),
"only_discard_paths": self.orphan.discard.as_ref().map(BTreeSet::len),
})
}
}
/// The checkout that owns `dir`'s worktree registry — the project a CLI
/// invocation from `dir` is scoped to (#8782).
///
/// Why: the CLI must name the same root the daemon's scan attributes a
/// worktree to, so it asks the same function rather than a second `git` query.
/// What: [`super::worktree_registry::registry_root_for`]; `None` outside a
/// repository.
/// Test: `project_root_for_a_linked_worktree_is_its_main_checkout`.
pub fn project_root_for(dir: &Path) -> Option<PathBuf> {
super::worktree_registry::registry_root_for(dir)
}
#[cfg(test)]
#[path = "worktree_scope_tests.rs"]
mod worktree_scope_tests;