Skip to main content

git_policy_cli/worktree/
worktree_convert.rs

1//! What:
2//!  Convert one registered linked worktree that is an ordinary directory into a btrfs subvolume at the same path,
3//!  resumably: quiesce it, copy it with cloned data into `<path>.cli-git-converting`, prove the copy equals the original,
4//!  swap the two with one atomic exchange, refresh the index stat fields, and remove the old tree only after the swap is
5//!  verified.
6//! Why:
7//!  A snapshot of the main checkout shows a nested worktree that is a subvolume as an empty directory, and one that is an
8//!  ordinary directory as a full copy that must then be removed (0.5 s each). Converting each ordinary worktree once makes
9//!  every later snapshot skip it for nothing. The registration is untouched: the path and the bytes of the `.git` file do
10//!  not change, so the administrative directory's back-pointer stays valid. A journal in the administrative directory
11//!  records the phase, so a rerun completes the conversion in place; nothing is deleted that the journal does not prove
12//!  this command created.
13//!
14//! In TS you'd write (pseudocode):
15//! ```ts
16//! const verdict = await convertWorktree({ platform, path, options, interrupt, say });
17//! ```
18
19use crate::fs_beneath::{ObjectId, identity_at, open_root};
20use crate::process_use::{UseTarget, processes_using};
21use crate::subvolume_error::{Interrupt, MigrateError};
22use crate::subvolume_index_files::{IndexLock, RefreshOptions, RefreshStatus, refresh_index_file_report, repository_hash_length};
23use crate::subvolume_lock::{acquire, is_ours};
24use crate::subvolume_platform::Platform;
25use crate::subvolume_sync::{SyncOptions, SyncStats, sync_tree};
26use crate::worktree_clean::check_clean_tracked_by_content;
27use crate::single_owner_claim::{ClaimOutcome, OwnerClaim, live_owner_of, take_claim};
28use crate::worktree_convert_journal::{CLAIM_PREFIX, Journal, is_recorded_object, legacy_lock_is_held, read_plain_file, read_plain_text};
29pub use crate::worktree_convert_journal::{JOURNAL_NAME, Phase};
30use crate::worktree_convert_remove::{Removal, remove_recorded_old_tree};
31use std::os::unix::ffi::OsStrExt;
32use std::path::{Path, PathBuf};
33
34/// The most bytes of a `.git` pointer or a registration file that are read.
35const GIT_FILE_LIMIT: u64 = 1 << 16;
36
37/// The suffix of the sibling subvolume the copy is built in.
38pub const CONVERTING_SUFFIX: &str = ".cli-git-converting";
39
40/// What:
41///  What a run did with one worktree.
42/// Why:
43///   The command prints one line per worktree and maps the verdicts to its exit status.
44///
45/// In TS you'd write (pseudocode):
46/// ```ts
47/// type Verdict = 'converted' | 'resumed' | 'would-convert' | 'already-subvolume' | 'abandoned' | { skipped: string[] };
48/// ```
49#[derive(Clone, Debug, Eq, PartialEq)]
50pub enum Verdict {
51    /// The conversion ran from the start to the end.
52    Converted,
53    /// An unfinished conversion was completed.
54    Resumed,
55    /// A dry run found nothing in the way.
56    WouldConvert,
57    /// The path is a subvolume already.
58    AlreadySubvolume,
59    /// An unfinished conversion was undone before its swap.
60    Abandoned,
61    /// Something stands in the way; each reason is a sentence.
62    Skipped(Vec<String>),
63}
64
65/// What:
66///  What the command asks of one worktree.
67/// Why:
68///   The dry run and the abandon are modes of the same pass over the same facts.
69///
70/// In TS you'd write (pseudocode):
71/// ```ts
72/// type ConvertOptions = { dryRun: boolean; abandon: boolean; commandLineAttributes: boolean };
73/// ```
74#[derive(Clone, Copy, Debug, Eq, PartialEq)]
75pub struct ConvertOptions {
76    /// List what would happen and change nothing.
77    pub dry_run: bool,
78    /// Undo an unfinished conversion that has not reached its swap.
79    pub abandon: bool,
80    /// The invocation chose an attribute source on the command line (`--attr-source`, `-c attr.tree`), which the worktree
81    ///  files read here do not model.
82    pub command_line_attributes: bool,
83}
84
85/// What:
86///  The facts about a worktree path that every mode needs.
87/// Why:
88///   They are read once, from the `.git` file and the administrative directory, without starting Git.
89///
90/// In TS you'd write (pseudocode):
91/// ```ts
92/// type Candidate = { path: string; admin: string; common: string; gitFile: Uint8Array };
93/// ```
94#[derive(Clone, Debug, Eq, PartialEq)]
95pub struct Candidate {
96    /// The canonical worktree root.
97    pub path: PathBuf,
98    /// The worktree's administrative directory.
99    pub admin: PathBuf,
100    /// The common Git directory.
101    pub common: PathBuf,
102    /// The exact bytes of the worktree's `.git` file.
103    pub git_file: Vec<u8>,
104}
105
106/// The sibling path the copy is built at.
107pub fn converting_path(path: &Path) -> PathBuf {
108    let mut name: std::ffi::OsString = path.as_os_str().to_os_string();
109    name.push(CONVERTING_SUFFIX);
110    return PathBuf::from(name);
111}
112
113/// What:
114///  Read the registration of a worktree path: its `.git` file, its administrative directory, and the back-pointer.
115/// Why:
116///   Only a linked worktree whose administrative directory names this very path back is converted; anything else (the main
117///  checkout, a submodule, a stale registration) is refused with the reason.
118///
119/// In TS you'd write (pseudocode):
120/// ```ts
121/// function inspect(path: string): Candidate; // throws the reason
122/// ```
123pub fn inspect(path: &Path) -> Result<Candidate, String> {
124    let real: PathBuf = std::fs::canonicalize(path).map_err(|error: std::io::Error| { return format!("{} cannot be resolved ({error})", path.display()); })?;
125    let git_path: PathBuf = real.join(".git");
126    let metadata: std::fs::Metadata = std::fs::symlink_metadata(&git_path).map_err(|_| { return format!("{} has no .git entry, so it is not a worktree", real.display()); })?;
127    if metadata.is_dir() {
128        return Err(format!("{} holds a .git directory: it is a main checkout or a repository, not a linked worktree", real.display()));
129    }
130    let git_file: Vec<u8> = read_plain_file(&git_path, GIT_FILE_LIMIT).map_err(|error: std::io::Error| { return format!("{} is unreadable ({error})", git_path.display()); })?;
131    let text: &str = std::str::from_utf8(git_file.as_slice()).map_err(|_| { return format!("{} is not text", git_path.display()); })?;
132    let Some(named) = text.trim().strip_prefix("gitdir:") else {
133        return Err(format!("{} has no gitdir line", git_path.display()));
134    };
135    let admin: PathBuf = std::fs::canonicalize(real.join(named.trim())).map_err(|error: std::io::Error| { return format!("the administrative directory {} cannot be resolved ({error})", named.trim()); })?;
136    let recorded: Vec<u8> = read_plain_file(&admin.join("gitdir"), GIT_FILE_LIMIT).map_err(|error: std::io::Error| { return format!("{} has no gitdir file ({error})", admin.display()); })?;
137    if Path::new(std::ffi::OsStr::from_bytes(recorded.trim_ascii())) != git_path.as_path() {
138        return Err(format!("{} does not name {} back, so the registration is stale or points elsewhere", admin.display(), git_path.display()));
139    }
140    let common_text: String = read_plain_text(&admin.join("commondir")).map_err(|error: std::io::Error| { return format!("{} has no commondir ({error})", admin.display()); })?;
141    let common: PathBuf = std::fs::canonicalize(admin.join(common_text.trim())).map_err(|error: std::io::Error| { return format!("the common directory cannot be resolved ({error})"); })?;
142    return Ok(Candidate { path: real, admin, common, git_file });
143}
144
145/// What kind of reason stands in the way, so a caller exempts what it owns by kind and identity, never by the words.
146#[derive(Clone, Copy, Debug, Eq, PartialEq)]
147pub enum RefusalKind {
148    /// The worktree is locked with `git worktree lock`.
149    Locked,
150    /// An `index.lock` exists.
151    IndexLock,
152    /// Something exists at the converting name.
153    ConvertingName,
154    /// The filesystem is not btrfs or cannot be examined.
155    Filesystem,
156    /// A worktree is registered inside this one.
157    Nested,
158    /// A tracked file or the state of `HEAD` is not provably clean.
159    NotQuiet,
160    /// A process uses the worktree, or the process table cannot be read.
161    Process,
162    /// Another command of this program owns the worktree or the checkout around it.
163    OtherCommand,
164}
165
166/// What an interrupted conversion of this command owns at this moment, established by identity before the refusals are made.
167#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
168pub struct Owned {
169    /// The `index.lock` holds this conversion's token.
170    pub index_lock: bool,
171    /// The object at the converting name is the subvolume whose identity the journal recorded.
172    pub stage: bool,
173}
174
175/// One reason a worktree cannot be converted now: its kind and the sentence for the person.
176#[derive(Clone, Debug, Eq, PartialEq)]
177pub struct Refusal {
178    /// What kind of reason it is.
179    pub kind: RefusalKind,
180    /// The sentence.
181    pub text: String,
182}
183
184/// What:
185///  The directories of worktrees registered in the common directory `common` that lie inside `inside`, not `inside` itself.
186/// Why:
187///   A listing or a registration that cannot be read is an error and not the absence of a nested worktree; only a registration
188///  with no `gitdir` file, or one whose directory is gone, is skipped, because Git would prune it.
189///
190/// In TS you'd write (pseudocode):
191/// ```ts
192/// function registeredInside(common: string, inside: string): string[]; // throws the reason a registration cannot be read
193/// ```
194fn registered_inside(common: &Path, inside: &Path) -> Result<Vec<PathBuf>, String> {
195    let directory: PathBuf = common.join("worktrees");
196    let names = match std::fs::read_dir(&directory) {
197        Ok(listing) => listing,
198        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
199        Err(error) => return Err(format!("the registered worktrees of {} cannot be listed ({error}), so a worktree inside this one cannot be ruled out", common.display())),
200    };
201    let mut found: Vec<PathBuf> = Vec::new();
202    for item in names {
203        let name: std::fs::DirEntry = item.map_err(|error: std::io::Error| { return format!("the registered worktrees of {} cannot be listed ({error}), so a worktree inside this one cannot be ruled out", common.display()); })?;
204        let recorded: Vec<u8> = match read_plain_file(&name.path().join("gitdir"), GIT_FILE_LIMIT) {
205            Ok(bytes) => bytes,
206            Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue,
207            Err(error) => return Err(format!("the registration {} cannot be read ({error}), so a worktree inside this one cannot be ruled out", name.path().display())),
208        };
209        // The recorded path is raw bytes: a directory named with bytes that are not UTF-8 is a valid path on Linux.
210        let Some(worktree) = Path::new(std::ffi::OsStr::from_bytes(recorded.trim_ascii())).parent() else { continue };
211        if worktree.starts_with(inside) && worktree != inside && worktree.is_dir() {
212            found.push(worktree.to_path_buf());
213        }
214    }
215    found.sort();
216    return Ok(found);
217}
218
219/// The sentence that refuses a conversion while a migration of the main checkout that holds the worktree is running, or
220///  nothing when none is (or the common directory is not a main checkout's `.git`).
221fn migration_of_main_checkout(common: &Path) -> Option<String> {
222    let root: &Path = common.parent().filter(|_parent: &&Path| { return common.file_name() == Some(std::ffi::OsStr::new(".git")); })?;
223    let layout = crate::subvolume_preflight::layout_of(root)?;
224    return match live_owner_of(layout.parent.as_path(), layout.claim_prefix.as_os_str()) {
225        Ok(None) => None,
226        Ok(Some(owner)) => Some(format!("process {} is migrating the main checkout {} to a subvolume (it owns {}); wait for it to finish, then run this command again", owner.pid, root.display(), owner.path.display())),
227        Err(error) => Some(format!("whether the main checkout {} is being migrated cannot be decided ({error})", root.display())),
228    };
229}
230
231/// What:
232///  Every reason a worktree cannot be converted now, with its kind; empty means it can.
233/// Why:
234///   The dry run prints them all, and a real run refuses on the first call with the whole list. The registrations are read
235///  from the common directory itself, so a bare layout is checked like any other.
236///
237/// In TS you'd write (pseudocode):
238/// ```ts
239/// function refusals(platform, candidate): Refusal[];
240/// ```
241pub fn refusals(platform: &dyn Platform, candidate: &Candidate, owned: Owned, command_line_attributes: bool) -> Vec<Refusal> {
242    let mut reasons: Vec<Refusal> = Vec::new();
243    let mut refuse = |kind: RefusalKind, text: String| { reasons.push(Refusal { kind, text }); };
244    if candidate.admin.join("locked").exists() {
245        refuse(RefusalKind::Locked, format!("the worktree is locked ({} exists); unlock it with `git worktree unlock` first", candidate.admin.join("locked").display()));
246    }
247    if !owned.index_lock && candidate.admin.join("index.lock").exists() {
248        refuse(RefusalKind::IndexLock, format!("{} exists: a Git command is writing the index", candidate.admin.join("index.lock").display()));
249    }
250    if let Some(text) = migration_of_main_checkout(candidate.common.as_path()) {
251        refuse(RefusalKind::OtherCommand, text);
252    }
253    let converting: PathBuf = converting_path(candidate.path.as_path());
254    if !owned.stage && std::fs::symlink_metadata(&converting).is_ok() {
255        refuse(RefusalKind::ConvertingName, format!("{} exists and no journal of this command owns it, so it is left alone; remove it yourself once you know what it is", converting.display()));
256    }
257    match platform.filesystem_facts(candidate.path.as_path()) {
258        Ok((kind, _device)) if kind == crate::fs_kind::FilesystemKind::Btrfs => {}
259        Ok(_) => refuse(RefusalKind::Filesystem, format!("{} is not on btrfs", candidate.path.display())),
260        Err(error) => refuse(RefusalKind::Filesystem, format!("the filesystem of {} cannot be examined ({error})", candidate.path.display())),
261    }
262    match registered_inside(candidate.common.as_path(), candidate.path.as_path()) {
263        Ok(nested_worktrees) => {
264            for nested in nested_worktrees {
265                refuse(RefusalKind::Nested, format!("the worktree {} is registered inside it; convert or move that one first", nested.display()));
266            }
267        }
268        Err(reason) => refuse(RefusalKind::Nested, reason),
269    }
270    let mut environment = platform.conversion_environment();
271    if command_line_attributes && environment.alternate_attribute_source.is_none() {
272        environment.alternate_attribute_source = Some(String::from("--attr-source or -c attr.tree was given on the command line"));
273    }
274    if let Err(reason) = check_clean_tracked_by_content(candidate.path.as_path(), candidate.admin.as_path(), candidate.common.as_path(), &environment, owned.index_lock) {
275        refuse(RefusalKind::NotQuiet, format!("it is not quiet and unmodified: {reason}"));
276    }
277    let scan = processes_using(&UseTarget::Inside(candidate.path.as_path()));
278    if let Some(why) = scan.unavailable.as_ref() {
279        refuse(RefusalKind::Process, format!("the process table cannot be read ({why}), so no process can be ruled out"));
280    }
281    for user in &scan.users {
282        refuse(RefusalKind::Process, format!("process {} ({}) has its working directory or an open file inside it", user.pid, user.command.as_deref().unwrap_or("unknown")));
283    }
284    return reasons;
285}
286
287/// A token unique to this run, written to the index lock so that only this command removes it.
288fn new_token() -> String {
289    let nanos: u128 = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map_or(0, |elapsed: std::time::Duration| { return elapsed.as_nanos(); });
290    return format!("cli-git convert-worktree-to-subvolume pid={} at={nanos}", std::process::id());
291}
292
293/// What:
294///  Everything one conversion needs besides the platform.
295/// Why:
296///   The phases share the same facts, so they travel together.
297///
298/// In TS you'd write (pseudocode):
299/// ```ts
300/// type Job = { candidate: Candidate; converting: string; journal: Journal };
301/// ```
302struct Job<'a> {
303    /// The worktree.
304    candidate: &'a Candidate,
305    /// The sibling the copy is built at.
306    converting: PathBuf,
307    /// The journal, updated in step with the phases.
308    journal: Journal,
309}
310
311impl Job<'_> {
312    /// Record a phase durably.
313    fn reach(&mut self, phase: Phase) -> Result<(), MigrateError> {
314        self.journal.phase = phase;
315        return self.journal.save(self.candidate.admin.as_path());
316    }
317
318    /// The directory that holds the worktree and its converting sibling, held open.
319    fn parent(&self) -> Result<std::fs::File, MigrateError> {
320        return held_parent(self.candidate.path.as_path());
321    }
322
323    /// The name of the converting sibling in the parent directory.
324    fn converting_name(&self) -> &std::ffi::OsStr {
325        return self.converting.file_name().unwrap_or_default();
326    }
327
328    /// The identity of the object at the converting name, if there is one.
329    fn stage_identity(&self) -> Result<Option<ObjectId>, MigrateError> {
330        return stage_identity_of(self.candidate.path.as_path());
331    }
332}
333
334/// The identity of the object at `path`, a link not followed, or nothing when it cannot be examined.
335fn identity_of_path(path: &Path) -> Option<ObjectId> {
336    let parent: std::fs::File = held_parent(path).ok()?;
337    return identity_at(&parent, path.file_name()?).ok();
338}
339
340/// The identity, with the birth time, of the directory at `path`.
341fn stage_free_identity(path: &Path) -> Result<ObjectId, MigrateError> {
342    let parent: std::fs::File = held_parent(path)?;
343    return identity_at(&parent, path.file_name().unwrap_or_default()).map_err(|error: std::io::Error| { return MigrateError::io("examine", path, &error); });
344}
345
346/// The directory that holds `path`, held open so that names below it mean the same objects for as long as it is held.
347fn held_parent(path: &Path) -> Result<std::fs::File, MigrateError> {
348    let parent: &Path = path.parent().ok_or_else(|| { return MigrateError::Journal(format!("{} has no parent directory", path.display())); })?;
349    return open_root(parent).map_err(|error: std::io::Error| { return MigrateError::io("hold the directory of the worktree", parent, &error); });
350}
351
352/// The identity of the object at the converting name of `path`, or nothing when the name is free.
353fn stage_identity_of(path: &Path) -> Result<Option<ObjectId>, MigrateError> {
354    let converting: PathBuf = converting_path(path);
355    let parent: std::fs::File = held_parent(path)?;
356    return match identity_at(&parent, converting.file_name().unwrap_or_default()) {
357        Ok(found) => Ok(Some(found)),
358        Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
359        Err(error) => Err(MigrateError::io("examine", converting.as_path(), &error)),
360    };
361}
362
363/// What:
364///  Make sure the converting subvolume exists and is the one this command made: create it and record its identity, or find
365///  the recorded identity at the name.
366/// Why:
367///   A directory at the converting name that the journal has no identity for is foreign, whether someone made it or this
368///  command was killed between creating it and recording it; it is never synced into, exchanged or destroyed.
369///
370/// In TS you'd write (pseudocode):
371/// ```ts
372/// await ensureStage(platform, job); // throws when the name holds anything the journal did not record creating
373/// ```
374fn ensure_stage(platform: &dyn Platform, job: &mut Job<'_>, interrupt: &Interrupt) -> Result<(), MigrateError> {
375    match (job.stage_identity()?, job.journal.new_identity) {
376        (Some(found), Some(recorded)) if found == recorded => return Ok(()),
377        (Some(_), _) => {
378            return Err(MigrateError::Journal(format!(
379                "{} exists but the journal does not record this conversion creating it, so it is left alone; remove it yourself once you know what it is, or run the command with --abandon",
380                job.converting.display()
381            )));
382        }
383        (None, Some(_)) => {
384            return Err(MigrateError::Journal(format!(
385                "{} was created by this conversion and is gone; run the command with --abandon and start again",
386                job.converting.display()
387            )));
388        }
389        (None, None) => {}
390    }
391    platform.create_subvolume(job.converting.as_path())?;
392    interrupt.check("stage-unrecorded")?;
393    let made: ObjectId = job.stage_identity()?.ok_or_else(|| { return MigrateError::Journal(format!("{} was not there after it was created", job.converting.display())); })?;
394    job.journal.new_identity = Some(made);
395    return job.journal.save(job.candidate.admin.as_path());
396}
397
398/// Whether a sync pass changed anything: it copied, deleted, recreated or lost a file or link.
399fn pass_changed(stats: &SyncStats) -> bool {
400    return stats.files_copied > 0 || stats.deleted > 0 || stats.vanished > 0 || stats.symlinks > 0 || stats.fifos > 0;
401}
402
403/// Fail with the unreadable files a pass could not copy, because a copy that leaves them out would lose them at the swap.
404fn require_complete(stats: &SyncStats, path: &Path) -> Result<(), MigrateError> {
405    if let Some(first) = stats.unreadable.first() {
406        return Err(MigrateError::Journal(format!(
407            "{} holds {} file(s) this user cannot read, the first being {}; they would be lost at the swap, so nothing was swapped",
408            path.display(),
409            stats.unreadable.len(),
410            first.display()
411        )));
412    }
413    return Ok(());
414}
415
416/// Take Git's `index.lock` of the worktree with this conversion's token, or accept the one an earlier run of it took.
417fn hold_index_lock(job: &Job<'_>) -> Result<(), MigrateError> {
418    return acquire(job.candidate.admin.join("index.lock").as_path(), job.journal.token.as_str());
419}
420
421/// Remove Git's `index.lock` of the worktree when it holds this conversion's token; a lock that holds other text is a Git
422///  command's and stays.
423fn release_index_lock(job: &Job<'_>) -> Result<(), MigrateError> {
424    let lock: PathBuf = job.candidate.admin.join("index.lock");
425    if !is_ours(lock.as_path(), job.journal.token.as_str()) {
426        return Ok(());
427    }
428    return match std::fs::remove_file(&lock) {
429        Ok(()) => Ok(()),
430        Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
431        Err(error) => Err(MigrateError::io("release the index lock", lock.as_path(), &error)),
432    };
433}
434
435/// What:
436///  The copy phase: create the subvolume if needed, copy with cloned data, and prove the copy equals the original.
437/// Why:
438///   It runs again on every resume before the swap, whatever the journal says: `Synced` only records that the copy once
439///  equalled the original, and a writer may have acted since.
440///
441/// In TS you'd write (pseudocode):
442/// ```ts
443/// await copyPhase(platform, job, interrupt, say);
444/// ```
445fn copy_phase(platform: &dyn Platform, job: &mut Job<'_>, interrupt: &Interrupt, say: &mut dyn FnMut(&str)) -> Result<(), MigrateError> {
446    if job.journal.phase < Phase::Creating {
447        job.reach(Phase::Creating)?;
448    }
449    interrupt.check("journaled")?;
450    ensure_stage(platform, job, interrupt)?;
451    interrupt.check("created")?;
452    let first: SyncStats = sync_tree(platform, job.candidate.path.as_path(), job.converting.as_path(), SyncOptions { delete: false, update_only: false, excluded: &[], created_since: None }, interrupt)?;
453    require_complete(&first, job.candidate.path.as_path())?;
454    say(format!("  copied {} entries ({} files, {} cloned)", first.entries, first.files_copied, first.cloned).as_str());
455    // Git's `index.lock` is taken only now: the long first copy ran without it, and the lock covers the span in which a Git
456    // writer could invalidate the proof, the swap or the index rewrite.
457    hold_index_lock(job)?;
458    // The proof: a second pass that must find nothing to copy or delete, so the copy equals the original at this instant.
459    let second: SyncStats = sync_tree(platform, job.candidate.path.as_path(), job.converting.as_path(), SyncOptions { delete: true, update_only: false, excluded: &[], created_since: None }, interrupt)?;
460    if let Err(error) = require_complete(&second, job.candidate.path.as_path()) {
461        release_index_lock(job)?;
462        return Err(error);
463    }
464    if pass_changed(&second) {
465        // Nothing is swapped, so nothing needs the lock any more; Git commands may write the index again.
466        release_index_lock(job)?;
467        return Err(MigrateError::Journal(format!(
468            "{} changed while it was copied ({} file(s) copied and {} removed by the proving pass); nothing was swapped, run the command again",
469            job.candidate.path.display(),
470            second.files_copied,
471            second.deleted
472        )));
473    }
474    interrupt.check("verified")?;
475    job.reach(Phase::Synced)?;
476    interrupt.check("synced")?;
477    return Ok(());
478}
479
480/// What:
481///  Which of the two directories holds the old tree: the worktree path (the swap has not happened) or the converting name
482///  (it has).
483/// Why:
484///   The swap is one atomic exchange and the journal is written after it, so a kill between the two leaves the journal one phase
485///  behind; the identity the journal recorded for the old root says where the old tree is now, so a rerun neither exchanges back
486///  nor loses track.
487///
488/// In TS you'd write (pseudocode):
489/// ```ts
490/// function whereIsTheOldTree(journal, path, converting): 'at-path' | 'at-converting' | 'nowhere';
491/// ```
492fn old_tree_is_at(journal: &Journal, path: &Path, converting: &Path) -> Where {
493    let is_old = |candidate: &Path| -> bool {
494        return identity_of_path(candidate).is_some_and(|found: ObjectId| { return is_recorded_object(&journal.old_identity, &found); });
495    };
496    if is_old(path) {
497        return Where::AtPath;
498    }
499    if is_old(converting) {
500        return Where::AtConverting;
501    }
502    return Where::Nowhere;
503}
504
505/// Where the old tree is, relative to the swap.
506#[derive(Clone, Copy, Debug, Eq, PartialEq)]
507enum Where {
508    /// At the worktree path: the swap has not happened.
509    AtPath,
510    /// At the converting name: the swap has happened.
511    AtConverting,
512    /// At neither: something else replaced it.
513    Nowhere,
514}
515
516/// The swap phase: exchange the two directories (unless a killed run already did), then record it.
517fn swap_phase(platform: &dyn Platform, job: &mut Job<'_>, interrupt: &Interrupt) -> Result<(), MigrateError> {
518    match old_tree_is_at(&job.journal, job.candidate.path.as_path(), job.converting.as_path()) {
519        Where::AtPath => {
520            ensure_stage(platform, job, interrupt)?;
521            let refused: Vec<String> = refusals_after_lock(platform, job.candidate);
522            if !refused.is_empty() {
523                return Err(MigrateError::Journal(format!("it can no longer be swapped: {}", refused.join("; "))));
524            }
525            platform.exchange(job.candidate.path.as_path(), job.converting.as_path())?;
526        }
527        Where::AtConverting => {}
528        Where::Nowhere => {
529            return Err(MigrateError::Journal(format!(
530                "neither {} nor {} is the directory this conversion recorded, so nothing is swapped or removed",
531                job.candidate.path.display(),
532                job.converting.display()
533            )));
534        }
535    }
536    interrupt.check("exchanged")?;
537    job.reach(Phase::Exchanged)?;
538    return Ok(());
539}
540
541/// The reasons that still bind just before the swap: a process that appeared, a lock that was taken, a file that changed.
542fn refusals_after_lock(_platform: &dyn Platform, candidate: &Candidate) -> Vec<String> {
543    let mut reasons: Vec<String> = Vec::new();
544    let scan = processes_using(&UseTarget::Inside(candidate.path.as_path()));
545    for user in &scan.users {
546        reasons.push(format!("process {} ({}) is using the worktree", user.pid, user.command.as_deref().unwrap_or("unknown")));
547    }
548    if let Some(why) = scan.unavailable.as_ref() {
549        reasons.push(format!("the process table cannot be read ({why})"));
550    }
551    return reasons;
552}
553
554/// The checks after the swap: the path is the subvolume this command created and its `.git` file holds the bytes it held before.
555fn verify_swap(platform: &dyn Platform, job: &Job<'_>) -> Result<(), MigrateError> {
556    let path: &Path = job.candidate.path.as_path();
557    if !platform.is_subvolume_root(path)? {
558        return Err(MigrateError::Journal(format!("{} is not a subvolume root after the swap; the old tree is kept at {}", path.display(), job.converting.display())));
559    }
560    let parent: std::fs::File = job.parent()?;
561    let installed: ObjectId = identity_at(&parent, path.file_name().unwrap_or_default()).map_err(|error: std::io::Error| { return MigrateError::io("examine", path, &error); })?;
562    if job.journal.new_identity != Some(installed) {
563        return Err(MigrateError::Journal(format!("{} is not the subvolume this conversion created; the old tree is kept at {}", path.display(), job.converting.display())));
564    }
565    let now: Vec<u8> = read_plain_file(&path.join(".git"), GIT_FILE_LIMIT).map_err(|error: std::io::Error| { return MigrateError::io("read", path.join(".git").as_path(), &error); })?;
566    if now != job.journal.git_file {
567        return Err(MigrateError::Journal(format!("the .git file of {} differs after the swap; the old tree is kept at {}", path.display(), job.converting.display())));
568    }
569    return Ok(());
570}
571
572/// The index phase: rewrite the stat fields of the index to the files of the subvolume, so the first status stays fast.
573/// A refresh that did not install the new index stops the conversion with the old tree kept, and the journal stays at the swap.
574fn index_phase(job: &mut Job<'_>, interrupt: &Interrupt, say: &mut dyn FnMut(&str)) -> Result<(), MigrateError> {
575    let references: Vec<PathBuf> = vec![job.converting.clone()];
576    let report = refresh_index_file_report(
577        job.candidate.admin.join("index").as_path(),
578        job.candidate.path.as_path(),
579        &RefreshOptions { lock: IndexLock::Held(job.journal.token.as_str()), references: references.as_slice(), hash_length: repository_hash_length(job.candidate.common.as_path()) },
580    );
581    say(format!("  index: {}", report.line).as_str());
582    if report.status == RefreshStatus::Skipped {
583        return Err(MigrateError::Journal(format!(
584            "the index of {} was not refreshed ({}); the old tree is kept at {}; fix that and run the command again",
585            job.candidate.path.display(),
586            report.line,
587            job.converting.display()
588        )));
589    }
590    interrupt.check("refreshed")?;
591    job.reach(Phase::Refreshed)?;
592    // The rewrite is the last thing that needs the lock; the removal of the old tree can take long and writes no index.
593    return release_index_lock(job);
594}
595
596/// What:
597///  The removal phase: keep the old tree when a process still holds it or when the installed tree does not hold all of it, and
598///  otherwise delete it, but only when it is the very directory the journal recorded.
599/// Why:
600///   The old tree sits at the converting name from the exchange on, so nothing opens it by a name that anything uses; a process
601///  that already held it open is found in the process table. The comparison with the installed tree is read-only and copies
602///  nothing, so an edit made to the installed worktree is never overwritten; a difference keeps the old tree and is reported,
603///  and a person decides which version is wanted. Anything that leaves the old tree in place is an incomplete conversion: the
604///  journal stays and the run says so.
605///
606/// In TS you'd write (pseudocode):
607/// ```ts
608/// await removalPhase(platform, job, interrupt, say);
609/// ```
610fn removal_phase(platform: &dyn Platform, job: &mut Job<'_>, interrupt: &Interrupt) -> Result<(), MigrateError> {
611    let parent: std::fs::File = job.parent()?;
612    let scan = processes_using(&UseTarget::Inside(job.converting.as_path()));
613    let mut holders: Vec<String> = scan.users.iter().map(|user| { return format!("process {} ({})", user.pid, user.command.as_deref().unwrap_or("unknown")); }).collect();
614    if let Some(why) = scan.unavailable.as_ref() {
615        holders.push(format!("the process table cannot be read ({why})"));
616    }
617    if !holders.is_empty() {
618        return Err(MigrateError::Journal(format!("the old tree at {} is kept because {}; stop that and run the command again", job.converting.display(), holders.join("; "))));
619    }
620    match remove_recorded_old_tree(platform, &parent, job.converting_name(), job.journal.old_identity, job.candidate.path.as_path())? {
621        Removal::Removed | Removal::Absent => {}
622        Removal::Kept(why) => {
623            return Err(MigrateError::Journal(format!("the old tree was not removed: {why}; the journal is kept, so put things right and run the command again")));
624        }
625        Removal::Differs(why) => {
626            return Err(MigrateError::Journal(format!(
627                "the old tree at {} is kept, and nothing was copied from it: {why}; settle which version is wanted by hand, then run the command again to remove it",
628                job.converting.display()
629            )));
630        }
631    }
632    interrupt.check("removed")?;
633    job.reach(Phase::Removed)?;
634    return Ok(());
635}
636
637/// Release the lock and drop the journal, the last step of a finished conversion.
638fn finish(job: &Job<'_>) -> Result<(), MigrateError> {
639    release_index_lock(job)?;
640    let journal: PathBuf = job.candidate.admin.join(JOURNAL_NAME);
641    return std::fs::remove_file(&journal).map_err(|error: std::io::Error| { return MigrateError::io("remove the conversion journal", journal.as_path(), &error); });
642}
643
644/// Undo an unfinished conversion that has not reached its swap: destroy the subvolume this command created (the one whose
645///  identity the journal recorded; anything else at the name is left where it is), release the lock and drop the journal.
646///  Past the swap there is nothing to undo, and the command says to run it again.
647fn abandon(platform: &dyn Platform, candidate: &Candidate, journal: Journal, say: &mut dyn FnMut(&str)) -> Result<Verdict, MigrateError> {
648    let converting: PathBuf = converting_path(candidate.path.as_path());
649    if journal.phase >= Phase::Exchanged {
650        say("  the swap already happened; run the command again to finish it (there is nothing to undo)");
651        return Ok(Verdict::Skipped(vec![String::from("past the swap")]));
652    }
653    if let Some(found) = stage_identity_of(candidate.path.as_path())? {
654        if journal.new_identity == Some(found) {
655            if !platform.is_subvolume_root(converting.as_path())? {
656                return Err(MigrateError::Journal(format!("{} is not a subvolume root, so it is not what this conversion created and is left alone", converting.display())));
657            }
658            platform.destroy_subvolume(converting.as_path())?;
659        } else {
660            say(format!("  {} is not recorded as made by this conversion, so it was left alone", converting.display()).as_str());
661        }
662    }
663    let job: Job<'_> = Job { candidate, converting, journal };
664    finish(&job)?;
665    return Ok(Verdict::Abandoned);
666}
667
668/// What:
669///  Convert, resume, abandon or preview one worktree.
670/// Why:
671///   One function holds the phase order, so the controls exercise the real sequence; the platform and the interrupt are
672///  injected so the controls run it on any filesystem and kill it at every phase. A real run holds the conversion lock from
673///  before it reads the journal until it ends.
674///
675/// In TS you'd write (pseudocode):
676/// ```ts
677/// const verdict = await convertWorktree({ platform, path, options, interrupt, say });
678/// ```
679pub fn convert_worktree(platform: &dyn Platform, path: &Path, options: ConvertOptions, interrupt: &Interrupt, say: &mut dyn FnMut(&str)) -> Result<Verdict, MigrateError> {
680    let candidate: Candidate = match inspect(path) {
681        Ok(found) => found,
682        Err(reason) => return Ok(Verdict::Skipped(vec![reason])),
683    };
684    // A dry run changes nothing, so it needs no claim; every other mode owns the conversion to the end. The claim is taken
685    // before the journal is read, so two commands never read, write or resume one journal.
686    let _claim: Option<OwnerClaim> = if options.dry_run {
687        None
688    } else {
689        match take_claim(candidate.admin.as_path(), std::ffi::OsStr::new(CLAIM_PREFIX))? {
690            ClaimOutcome::Held(held) => {
691                if legacy_lock_is_held(candidate.admin.as_path())? {
692                    return Ok(Verdict::Skipped(vec![format!(
693                        "another conversion of {} is running (a command of release 0.5.20 or older holds {}), so this one did not start",
694                        candidate.path.display(),
695                        candidate.admin.join(crate::worktree_convert_journal::LOCK_NAME).display()
696                    )]));
697                }
698                Some(held)
699            }
700            ClaimOutcome::Busy(owner) => {
701                return Ok(Verdict::Skipped(vec![format!(
702                    "another conversion of {} is running (process {} owns it through {}), so this one did not start",
703                    candidate.path.display(),
704                    owner.pid,
705                    owner.path.display()
706                )]));
707            }
708        }
709    };
710    let mut journal: Option<Journal> = Journal::load(candidate.admin.as_path())?;
711    // A journal belongs to the path the conversion began at; a worktree moved since then is refused before anything changes.
712    if let Some(found) = journal.as_ref().filter(|found: &&Journal| { return found.path.as_slice() != candidate.path.as_os_str().as_bytes(); }) {
713        return Err(MigrateError::Journal(format!(
714            "an unfinished conversion began at {}, but the worktree is at {} now; it was moved while the conversion was unfinished, so nothing was changed; move it back and run the command again",
715            String::from_utf8_lossy(found.path.as_slice()),
716            candidate.path.display()
717        )));
718    }
719    // A kill between the exchange and its journal line leaves the journal one phase behind; bring it up to date in memory in
720    // every mode, and on disk when the run changes anything.
721    if let Some(found) = journal.as_mut().filter(|found: &&mut Journal| { return found.phase == Phase::Synced; }) {
722        let converting: PathBuf = converting_path(candidate.path.as_path());
723        if old_tree_is_at(found, candidate.path.as_path(), converting.as_path()) == Where::AtConverting {
724            found.phase = Phase::Exchanged;
725            if !options.dry_run {
726                found.save(candidate.admin.as_path())?;
727            }
728        }
729    }
730    if options.abandon {
731        return match journal {
732            Some(found) => abandon(platform, &candidate, found, say),
733            None => Ok(Verdict::Skipped(vec![String::from("no conversion is in progress here, so there is nothing to abandon")])),
734        };
735    }
736    if journal.is_none() && platform.is_subvolume_root(candidate.path.as_path())? {
737        return Ok(Verdict::AlreadySubvolume);
738    }
739    let resuming: bool = journal.is_some();
740    // A conversion that has not reached its swap is checked again like a fresh one; past the swap the old path is gone.
741    let must_be_quiet: bool = journal.as_ref().is_none_or(|found: &Journal| { return found.phase < Phase::Exchanged; });
742    if must_be_quiet {
743        // Only what this command owns is exempt: the index lock that holds its token, and the stage whose identity it recorded.
744        let owned: Owned = match journal.as_ref() {
745            Some(found) => Owned {
746                index_lock: is_ours(candidate.admin.join("index.lock").as_path(), found.token.as_str()),
747                stage: found.new_identity.is_some() && stage_identity_of(candidate.path.as_path())? == found.new_identity,
748            },
749            None => Owned::default(),
750        };
751        let reasons: Vec<Refusal> = refusals(platform, &candidate, owned, options.command_line_attributes);
752        if !reasons.is_empty() {
753            return Ok(Verdict::Skipped(reasons.into_iter().map(|reason: Refusal| { return reason.text; }).collect()));
754        }
755    }
756    if options.dry_run {
757        return Ok(if resuming { Verdict::Resumed } else { Verdict::WouldConvert });
758    }
759    let old: ObjectId = stage_free_identity(candidate.path.as_path())?;
760    let token: String = journal.as_ref().map_or_else(new_token, |found: &Journal| { return found.token.clone(); });
761    let started: Journal = journal.unwrap_or(Journal {
762        phase: Phase::Started,
763        token,
764        path: candidate.path.as_os_str().as_bytes().to_vec(),
765        old_identity: old,
766        git_file: candidate.git_file.clone(),
767        new_identity: None,
768    });
769    let converting: PathBuf = converting_path(candidate.path.as_path());
770    let mut job: Job<'_> = Job { candidate: &candidate, converting, journal: started };
771    if !resuming {
772        // The journal first, made exclusively: the lock and the subvolume exist only after the journal names them.
773        job.journal.create_new(candidate.admin.as_path())?;
774    }
775    if job.journal.phase < Phase::Exchanged {
776        copy_phase(platform, &mut job, interrupt, say)?;
777        swap_phase(platform, &mut job, interrupt)?;
778    }
779    verify_swap(platform, &job)?;
780    if job.journal.phase < Phase::Refreshed {
781        // A run that stopped after the swap still owes the index rewrite, which needs the lock; a release up to 0.5.20 left it
782        // in place from the start of the conversion, and taking it again is accepted when it holds this token.
783        hold_index_lock(&job)?;
784        index_phase(&mut job, interrupt, say)?;
785    }
786    if job.journal.phase < Phase::Removed {
787        removal_phase(platform, &mut job, interrupt)?;
788    }
789    finish(&job)?;
790    return Ok(if resuming { Verdict::Resumed } else { Verdict::Converted });
791}
792
793/// Conversion controls stay out of the release executable.
794#[cfg(test)]
795#[path = "worktree_convert_tests.rs"]
796mod tests;
797
798/// The cleanliness proof of the conversion by content stays out of the release executable.
799#[cfg(test)]
800#[path = "worktree_convert_content_tests.rs"]
801mod content_tests;
802
803/// The controls for who owns a conversion and how long the index lock is held stay out of the release executable.
804#[cfg(test)]
805#[path = "worktree_convert_ownership_tests.rs"]
806mod ownership_tests;
807
808/// The controls for what keeps a foreign or late change from being lost: identities, the lock, the journal's files, the pinned
809///  removal and the refusals by kind.
810#[cfg(test)]
811#[path = "worktree_convert_safety_tests.rs"]
812mod safety_tests;