Skip to main content

git_policy_cli/subvolume/
subvolume_command.rs

1//! What:
2//!  `git cli-git migrate-to-subvolume`: find the repository's main checkout from the working directory, run the
3//!  requested mode (migrate, rollback, remove the old tree) and turn the result into an exit status and text.
4//! Why:
5//!  The command runs for minutes (the real checkout took about sixteen minutes of passes), so progress lines go to
6//!  standard error as they happen; refusals and failures end with the cause and every way forward. Exit status 0 means
7//!  done, already done or previewed, 1 means a condition refused it before anything changed, and 2 means a step
8//!  failed after the journal began, with the instruction to run the command again or to roll back. The main checkout is
9//!  found by reading `.git` in process (a directory, or a gitfile naming the common directory), never by asking Git.
10//!
11//! In TS you'd write (pseudocode):
12//! ```ts
13//! const code = await execute({ platform, args, root, interrupt, say });
14//! ```
15
16use crate::action::{Action, ENGINE_FAILURE_EXIT_CODE};
17use crate::subvolume_arguments::{MIGRATE_HELP, MigrateArguments, MigrateMode};
18use crate::single_owner_claim::{ClaimOutcome, OwnerClaim, take_claim};
19use crate::subvolume_error::{Interrupt, MigrateError};
20use crate::subvolume_migrate::{MigrateOptions, Verdict, migrate};
21use crate::subvolume_platform::{LinuxPlatform, Platform};
22use crate::subvolume_preflight::{Refusal, layout_of};
23use crate::subvolume_rollback::{remove_old_tree, rollback};
24use crate::subvolume_run::Run;
25use std::io::Write;
26use std::path::{Path, PathBuf};
27
28/// The exit status of a refusal: a condition stood in the way and nothing was changed.
29pub const REFUSED_EXIT_CODE: i32 = 1;
30
31/// What:
32///  The main checkout of the repository that contains `start`, found without starting Git.
33/// Why:
34///   Walk up to the first `.git`. A directory there marks the main checkout. A file there is a linked worktree's
35///  gitfile (`gitdir: <admin directory>`); the administrative directory's `commondir` names the common directory,
36///  whose parent is the main checkout when the common directory is called `.git`. Anything else (a bare repository, a
37///  submodule, an unreadable gitfile) is reported, because converting the wrong directory would be harmful.
38///
39/// In TS you'd write (pseudocode):
40/// ```ts
41/// function mainCheckoutRoot(start: string): string; // throws with the reason
42/// ```
43pub fn main_checkout_root(start: &Path) -> Result<PathBuf, String> {
44    let real: PathBuf = std::fs::canonicalize(start).map_err(|error: std::io::Error| { return format!("cannot resolve {}: {error}", start.display()); })?;
45    let mut directory: PathBuf = real.clone();
46    loop {
47        let dot_git: PathBuf = directory.join(".git");
48        if let Ok(metadata) = std::fs::symlink_metadata(&dot_git) {
49            if metadata.is_dir() {
50                return Ok(directory);
51            }
52            return common_root_of_gitfile(dot_git.as_path());
53        }
54        let Some(parent) = directory.parent() else {
55            return Err(format!("{} is not inside a Git working tree (no .git above it)", real.display()));
56        };
57        directory = parent.to_path_buf();
58    }
59}
60
61/// Read a linked worktree's gitfile and return the main checkout it belongs to.
62fn common_root_of_gitfile(gitfile: &Path) -> Result<PathBuf, String> {
63    let text: String = std::fs::read_to_string(gitfile).map_err(|error: std::io::Error| { return format!("cannot read {}: {error}", gitfile.display()); })?;
64    let Some(named) = text.trim().strip_prefix("gitdir:") else {
65        return Err(format!("{} is not a gitfile (no `gitdir:` line)", gitfile.display()));
66    };
67    let base: &Path = gitfile.parent().unwrap_or(Path::new("/"));
68    let admin: PathBuf = base.join(named.trim());
69    let common_text: String = std::fs::read_to_string(admin.join("commondir"))
70        .map_err(|_| { return format!("{} is not a linked worktree's gitfile ({} has no commondir), so this is not a main checkout or a linked worktree of one", gitfile.display(), admin.display()); })?;
71    let common: PathBuf = std::fs::canonicalize(admin.join(common_text.trim())).map_err(|error: std::io::Error| { return format!("cannot resolve the common directory: {error}"); })?;
72    if common.file_name().is_none_or(|name: &std::ffi::OsStr| { return name != ".git"; }) {
73        return Err(format!("the common directory {} is not named .git, so the repository has no main checkout directory to convert", common.display()));
74    }
75    return common.parent().map(Path::to_path_buf).ok_or_else(|| { return String::from("the common directory has no parent"); });
76}
77
78/// Write the refusals in plain words.
79fn say_refusals(say: &mut dyn FnMut(&str), refusals: &[Refusal]) {
80    for refusal in refusals {
81        say(format!("git cli-git migrate-to-subvolume: refused: {}", refusal.cause).as_str());
82        say(format!("  What to do: {}", refusal.remedy).as_str());
83    }
84    say("Nothing was changed.");
85}
86
87/// What:
88///  Run the requested mode against a root and return the exit status.
89///  `say` receives every line; the platform and interrupt are injected so controls run it on any filesystem.
90/// Why:
91///   One function holds the status mapping, so the controls check the real command's behavior.
92///
93/// In TS you'd write (pseudocode):
94/// ```ts
95/// const code = await execute(platform, args, root, interrupt, say);
96/// ```
97pub fn execute(platform: &dyn Platform, arguments: &MigrateArguments, root: &Path, interrupt: &Interrupt, say: &mut dyn FnMut(&str)) -> i32 {
98    let outcome: Result<i32, MigrateError> = run_mode(platform, arguments, root, interrupt, &mut *say);
99    match outcome {
100        Ok(code) => return code,
101        Err(error) => {
102            let journal: String = layout_of(root).map(|layout| { return layout.journal_path.display().to_string(); }).unwrap_or_default();
103            say(format!("git cli-git migrate-to-subvolume: failed: {error}").as_str());
104            say(format!("The journal {journal} records the steps that finished. Run the command again to resume from there, or add --rollback to restore the original directory.").as_str());
105            return ENGINE_FAILURE_EXIT_CODE;
106        }
107    }
108}
109
110/// The three modes, as a result so `execute` maps every error the same way.
111fn run_mode(platform: &dyn Platform, arguments: &MigrateArguments, root: &Path, interrupt: &Interrupt, say: &mut dyn FnMut(&str)) -> Result<i32, MigrateError> {
112    let Some(layout) = layout_of(root) else {
113        return Err(MigrateError::Journal(format!("{} has no parent directory", root.display())));
114    };
115    // One command at a time works on a checkout, whatever its mode: a preview changes nothing and needs no claim. The claim
116    // is taken before the journal is read and given back when this function returns, however it returns.
117    let _claim: Option<OwnerClaim> = if arguments.dry_run {
118        None
119    } else {
120        match take_claim(layout.parent.as_path(), layout.claim_prefix.as_os_str())? {
121            ClaimOutcome::Held(held) => Some(held),
122            ClaimOutcome::Busy(owner) => {
123                let busy: Refusal = Refusal {
124                    cause: format!("another command (process {}) is working on {} right now (it owns {})", owner.pid, root.display(), owner.path.display()),
125                    remedy: String::from("Wait for it to finish, or stop it, then run this command again; a command that was killed owns nothing, so a leftover claim file needs no cleanup."),
126                };
127                say_refusals(say, std::slice::from_ref(&busy));
128                return Ok(REFUSED_EXIT_CODE);
129            }
130        }
131    };
132    if arguments.mode == MigrateMode::Migrate {
133        let options: MigrateOptions = MigrateOptions { dry_run: arguments.dry_run, nested_worktrees: arguments.nested_worktrees, refresh_indexes: arguments.refresh_indexes };
134        let verdict: Verdict = migrate(platform, root, options, interrupt, &mut *say)?;
135        if let Verdict::Refused(refusals) = verdict {
136            say_refusals(say, refusals.as_slice());
137            return Ok(REFUSED_EXIT_CODE);
138        }
139        return Ok(0);
140    }
141    let mut run: Run<'_> = Run::open(platform, &layout, interrupt, say)?;
142    if arguments.dry_run {
143        let what: &str = if arguments.mode == MigrateMode::Rollback { "restore the original directory" } else { "delete the original tree" };
144        run.line(format!("dry run: the command would {what} of {}; nothing was changed.", layout.root.display()).as_str());
145        return Ok(0);
146    }
147    let refused: Option<Vec<Refusal>> = if arguments.mode == MigrateMode::Rollback { rollback(&mut run, arguments.discard_unreadable)? } else { remove_old_tree(&mut run, arguments.discard_unreadable)? };
148    if let Some(refusals) = refused {
149        say_refusals(run.say, refusals.as_slice());
150        return Ok(REFUSED_EXIT_CODE);
151    }
152    return Ok(0);
153}
154
155/// What:
156///  The action of `git cli-git migrate-to-subvolume`: help, or the run from the working directory's repository.
157/// Why:
158///   Progress goes to standard error live; the action carries only the exit status.
159///
160/// In TS you'd write (pseudocode):
161/// ```ts
162/// function planMigrate(args: MigrateArguments, cwd: string): Action;
163/// ```
164pub fn migrate_action(arguments: &MigrateArguments, cwd: &Path) -> Action {
165    if arguments.help {
166        return Action::Exit { code: 0, stdout: String::from(MIGRATE_HELP), stderr: String::new() };
167    }
168    let root: PathBuf = match main_checkout_root(cwd) {
169        Ok(found) => found,
170        Err(message) => {
171            return Action::Exit {
172                code: REFUSED_EXIT_CODE,
173                stdout: String::new(),
174                stderr: format!("git cli-git migrate-to-subvolume: refused: {message}\n  What to do: run the command inside the repository whose main checkout you want to convert.\nNothing was changed.\n"),
175            };
176        }
177    };
178    let mut say = |text: &str| {
179        let mut stderr = std::io::stderr().lock();
180        let _written = writeln!(stderr, "{text}");
181    };
182    let code: i32 = execute(&LinuxPlatform::default(), arguments, root.as_path(), &Interrupt::never(), &mut say);
183    return Action::Exit { code, stdout: String::new(), stderr: String::new() };
184}
185
186/// Command controls stay out of the release executable.
187#[cfg(test)]
188#[path = "subvolume_command_tests.rs"]
189mod tests;
190
191/// The controls for who owns a migration stay out of the release executable.
192#[cfg(test)]
193#[path = "subvolume_command_ownership_tests.rs"]
194mod ownership_tests;