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
//! The one-shot marker migration over every registered project (#8511).
//!
//! Why: readers never move a marker (a read-only or dry-run path must not
//! write), so a legacy in-tree marker stays until an explicit pass moves it.
//! This pass visits every tree of every registered project, and adds the
//! shared `info/exclude` entries while it is there.
//! What: [`registered_checkouts`] — the checkouts the sweeps already
//! interrogate (the repos-root walk plus adopted anchors);
//! [`prepare_checkout`] — one checkout: exclude entries, then every tree's
//! migration; [`migrate_registered_projects`] — all of them, run once at daemon
//! start; [`repair_worktree_markers`] — the same work as `tm doctor --fix`
//! steps, previewed unless applied.
//! Test: `the_fleet_pass_migrates_every_tree_and_excludes_once`,
//! `the_doctor_repair_previews_then_applies`.
use std::collections::BTreeSet;
use std::path::{Path, PathBuf};
use super::worktree_ownership_location::{
MarkerMigration, legacy_sentinel_path, migrate_legacy_sentinel, preview_migration,
};
use super::worktree_registry::{list_registered_worktrees, scan_registered_worktrees};
use crate::core::doctor_repair::{RepairMode, RepairStep, StepStatus};
/// The doctor-repair check name these steps report under.
const CHECK: &str = "worktree_markers";
/// Every checkout whose `git worktree list` the sweeps consult (#8511).
///
/// Why: "all registered projects" must mean the same set the reclaim and
/// reconcile paths already walk, not a second enumeration that can drift.
/// What: the distinct `registry_root` of every [`scan_registered_worktrees`]
/// row, sorted.
pub(crate) fn registered_checkouts(repos_root: &Path, adopted: &[PathBuf]) -> Vec<PathBuf> {
let roots: BTreeSet<PathBuf> = scan_registered_worktrees(repos_root, adopted)
.into_iter()
.map(|s| s.registry_root)
.collect();
roots.into_iter().collect()
}
/// Every tree `checkout`'s registry lists that has a directory on disk.
///
/// A listing failure is an `Err`, never an empty list: the startup pass logs
/// it and the doctor reports it as a failed step (round 2 finding 4).
fn trees_of(checkout: &Path) -> Result<Vec<PathBuf>, String> {
let listed = list_registered_worktrees(checkout)
.ok_or_else(|| "could not list this checkout's worktrees".to_string())?;
Ok(listed
.into_iter()
.filter(|w| !w.bare && !w.prunable)
.map(|w| w.path)
.collect())
}
/// What one checkout's pass did.
#[derive(Debug, Default, Clone, PartialEq, Eq)]
pub(crate) struct CheckoutTally {
/// Legacy markers moved to the admin dir (including removed duplicates).
pub(crate) migrated: usize,
/// Legacy markers kept because a step failed or the two copies conflict.
pub(crate) kept: usize,
}
/// Exclude entries, then migrate every tree of `checkout` (#8511).
///
/// Why: the registration paths call this so a newly registered project is
/// clean from its first `git status`.
/// What: [`crate::core::harness_exclude::ensure_and_log`], then
/// [`migrate_legacy_sentinel`] per tree. Best-effort: logs, never fails.
/// Test: `the_fleet_pass_migrates_every_tree_and_excludes_once`.
pub(crate) fn prepare_checkout(checkout: &Path) -> CheckoutTally {
crate::core::harness_exclude::ensure_and_log(checkout);
let mut tally = CheckoutTally::default();
let trees = trees_of(checkout).unwrap_or_else(|reason| {
tracing::warn!(
checkout = %checkout.display(),
"ownership markers: {reason}; skipped (#8511)"
);
Vec::new()
});
for tree in trees {
match migrate_legacy_sentinel(&tree) {
MarkerMigration::Migrated | MarkerMigration::DuplicateRemoved => tally.migrated += 1,
MarkerMigration::Conflict | MarkerMigration::Tracked | MarkerMigration::Failed(_) => {
tally.kept += 1
}
MarkerMigration::NoLegacy | MarkerMigration::NoAdminDir => {}
}
}
if tally != CheckoutTally::default() {
tracing::info!(
checkout = %checkout.display(),
migrated = tally.migrated,
kept = tally.kept,
"ownership markers moved into the git admin dir (#8511)"
);
}
tally
}
/// [`prepare_checkout`] over every [`registered_checkouts`] entry — the
/// daemon's one-shot startup pass (#8511).
/// Test: `the_fleet_pass_migrates_every_tree_and_excludes_once`.
pub(crate) fn migrate_registered_projects(repos_root: &Path, adopted: &[PathBuf]) -> CheckoutTally {
let mut total = CheckoutTally::default();
for checkout in registered_checkouts(repos_root, adopted) {
let t = prepare_checkout(&checkout);
total.migrated += t.migrated;
total.kept += t.kept;
}
total
}
/// The `tm doctor --fix` steps for every registered project (#8511).
///
/// Why: the operator-facing form of the one-shot migration, previewed by
/// default like every other doctor repair.
/// What: per checkout, one step for its pending exclude entries, and one
/// failed step when its worktrees cannot be listed; per tree, one step for a
/// legacy marker the migration would act on. A dry run reports
/// [`preview_migration`]'s answer, apply runs [`migrate_legacy_sentinel`] —
/// the same decision table, so both modes show the same refusals and failures
/// (round 2 finding 3). A committed marker (#8368) and a tree with nothing to
/// move produce no step, so a clean fleet produces none.
/// Test: `the_doctor_repair_previews_then_applies`,
/// `the_doctor_dry_run_reports_what_apply_will_do`,
/// `a_listing_failure_is_a_failed_doctor_step`.
pub fn repair_worktree_markers(
repos_root: &Path,
adopted: &[PathBuf],
mode: RepairMode,
) -> Vec<RepairStep> {
registered_checkouts(repos_root, adopted)
.iter()
.flat_map(|checkout| checkout_steps(checkout, mode))
.collect()
}
/// One checkout's doctor steps; see [`repair_worktree_markers`].
fn checkout_steps(checkout: &Path, mode: RepairMode) -> Vec<RepairStep> {
let mut steps: Vec<RepairStep> = exclude_step(checkout, mode).into_iter().collect();
let trees = match trees_of(checkout) {
Ok(trees) => trees,
Err(reason) => {
steps.push(RepairStep {
check: CHECK,
path: checkout.to_path_buf(),
what: "move the ownership markers into the git admin dir".to_string(),
status: StepStatus::Failed(reason),
});
return steps;
}
};
for tree in trees {
let outcome = match mode {
RepairMode::DryRun => preview_migration(&tree),
RepairMode::Apply => migrate_legacy_sentinel(&tree),
};
let status = match outcome {
MarkerMigration::Migrated | MarkerMigration::DuplicateRemoved => match mode {
RepairMode::DryRun => StepStatus::Planned,
RepairMode::Apply => StepStatus::Applied { backup: None },
},
MarkerMigration::Conflict => StepStatus::Refused(
"the admin-dir marker differs; it wins and the in-tree one is kept".to_string(),
),
MarkerMigration::Failed(reason) => StepStatus::Failed(reason),
// A committed marker (#8368) is never moved; see the location module.
MarkerMigration::Tracked | MarkerMigration::NoLegacy | MarkerMigration::NoAdminDir => {
continue;
}
};
steps.push(RepairStep {
check: CHECK,
path: legacy_sentinel_path(&tree),
what: "move the ownership marker into the git admin dir".to_string(),
status,
});
}
steps
}
/// One checkout's exclude step, or nothing when no entry is pending.
fn exclude_step(checkout: &Path, mode: RepairMode) -> Option<RepairStep> {
let (path, pending) = match crate::core::harness_exclude::pending_excludes(checkout) {
Ok(found) => found,
Err(e) => {
return Some(RepairStep {
check: CHECK,
path: checkout.to_path_buf(),
what: "exclude the harness files from git status".to_string(),
status: StepStatus::Failed(e),
});
}
};
if pending.is_empty() {
return None;
}
let what = format!("add {} to the shared info/exclude", pending.join(", "));
let status = match mode {
RepairMode::DryRun => StepStatus::Planned,
RepairMode::Apply => {
match crate::core::harness_exclude::ensure_harness_files_excluded(checkout) {
Ok(_) => StepStatus::Applied { backup: None },
Err(e) => StepStatus::Failed(e),
}
}
};
Some(RepairStep {
check: CHECK,
path,
what,
status,
})
}
#[cfg(test)]
#[path = "worktree_marker_migration_tests.rs"]
mod worktree_marker_migration_tests;