Skip to main content

git_policy_cli/
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 super::action::{Action, ENGINE_FAILURE_EXIT_CODE};
17use super::subvolume_arguments::{MIGRATE_HELP, MigrateArguments, MigrateMode};
18use super::subvolume_error::{Interrupt, MigrateError};
19use super::subvolume_migrate::{MigrateOptions, Verdict, migrate};
20use super::subvolume_platform::{LinuxPlatform, Platform};
21use super::subvolume_preflight::{Refusal, layout_of};
22use super::subvolume_rollback::{remove_old_tree, rollback};
23use super::subvolume_run::Run;
24use std::io::Write;
25use std::path::{Path, PathBuf};
26
27/// The exit status of a refusal: a condition stood in the way and nothing was changed.
28pub const REFUSED_EXIT_CODE: i32 = 1;
29
30/// What:
31///  The main checkout of the repository that contains `start`, found without starting Git.
32/// Why:
33///   Walk up to the first `.git`. A directory there marks the main checkout. A file there is a linked worktree's
34///  gitfile (`gitdir: <admin directory>`); the administrative directory's `commondir` names the common directory,
35///  whose parent is the main checkout when the common directory is called `.git`. Anything else (a bare repository, a
36///  submodule, an unreadable gitfile) is reported, because converting the wrong directory would be harmful.
37///
38/// In TS you'd write (pseudocode):
39/// ```ts
40/// function mainCheckoutRoot(start: string): string; // throws with the reason
41/// ```
42pub fn main_checkout_root(start: &Path) -> Result<PathBuf, String> {
43    let real: PathBuf = std::fs::canonicalize(start).map_err(|error: std::io::Error| { return format!("cannot resolve {}: {error}", start.display()); })?;
44    let mut directory: PathBuf = real.clone();
45    loop {
46        let dot_git: PathBuf = directory.join(".git");
47        if let Ok(metadata) = std::fs::symlink_metadata(&dot_git) {
48            if metadata.is_dir() {
49                return Ok(directory);
50            }
51            return common_root_of_gitfile(dot_git.as_path());
52        }
53        let Some(parent) = directory.parent() else {
54            return Err(format!("{} is not inside a Git working tree (no .git above it)", real.display()));
55        };
56        directory = parent.to_path_buf();
57    }
58}
59
60/// Read a linked worktree's gitfile and return the main checkout it belongs to.
61fn common_root_of_gitfile(gitfile: &Path) -> Result<PathBuf, String> {
62    let text: String = std::fs::read_to_string(gitfile).map_err(|error: std::io::Error| { return format!("cannot read {}: {error}", gitfile.display()); })?;
63    let Some(named) = text.trim().strip_prefix("gitdir:") else {
64        return Err(format!("{} is not a gitfile (no `gitdir:` line)", gitfile.display()));
65    };
66    let base: &Path = gitfile.parent().unwrap_or(Path::new("/"));
67    let admin: PathBuf = base.join(named.trim());
68    let common_text: String = std::fs::read_to_string(admin.join("commondir"))
69        .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()); })?;
70    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}"); })?;
71    if common.file_name().is_none_or(|name: &std::ffi::OsStr| { return name != ".git"; }) {
72        return Err(format!("the common directory {} is not named .git, so the repository has no main checkout directory to convert", common.display()));
73    }
74    return common.parent().map(Path::to_path_buf).ok_or_else(|| { return String::from("the common directory has no parent"); });
75}
76
77/// Write the refusals in plain words.
78fn say_refusals(say: &mut dyn FnMut(&str), refusals: &[Refusal]) {
79    for refusal in refusals {
80        say(format!("git cli-git migrate-to-subvolume: refused: {}", refusal.cause).as_str());
81        say(format!("  What to do: {}", refusal.remedy).as_str());
82    }
83    say("Nothing was changed.");
84}
85
86/// What:
87///  Run the requested mode against a root and return the exit status.
88///  `say` receives every line; the platform and interrupt are injected so controls run it on any filesystem.
89/// Why:
90///   One function holds the status mapping, so the controls check the real command's behavior.
91///
92/// In TS you'd write (pseudocode):
93/// ```ts
94/// const code = await execute(platform, args, root, interrupt, say);
95/// ```
96pub fn execute(platform: &dyn Platform, arguments: &MigrateArguments, root: &Path, interrupt: &Interrupt, say: &mut dyn FnMut(&str)) -> i32 {
97    let outcome: Result<i32, MigrateError> = run_mode(platform, arguments, root, interrupt, &mut *say);
98    match outcome {
99        Ok(code) => return code,
100        Err(error) => {
101            let journal: String = layout_of(root).map(|layout| { return layout.journal_path.display().to_string(); }).unwrap_or_default();
102            say(format!("git cli-git migrate-to-subvolume: failed: {error}").as_str());
103            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());
104            return ENGINE_FAILURE_EXIT_CODE;
105        }
106    }
107}
108
109/// The three modes, as a result so `execute` maps every error the same way.
110fn run_mode(platform: &dyn Platform, arguments: &MigrateArguments, root: &Path, interrupt: &Interrupt, say: &mut dyn FnMut(&str)) -> Result<i32, MigrateError> {
111    if arguments.mode == MigrateMode::Migrate {
112        let options: MigrateOptions = MigrateOptions { dry_run: arguments.dry_run, nested_worktrees: arguments.nested_worktrees, refresh_indexes: arguments.refresh_indexes };
113        let verdict: Verdict = migrate(platform, root, options, interrupt, &mut *say)?;
114        if let Verdict::Refused(refusals) = verdict {
115            say_refusals(say, refusals.as_slice());
116            return Ok(REFUSED_EXIT_CODE);
117        }
118        return Ok(0);
119    }
120    let Some(layout) = layout_of(root) else {
121        return Err(MigrateError::Journal(format!("{} has no parent directory", root.display())));
122    };
123    let mut run: Run<'_> = Run::open(platform, &layout, interrupt, say)?;
124    if arguments.dry_run {
125        let what: &str = if arguments.mode == MigrateMode::Rollback { "restore the original directory" } else { "delete the original tree" };
126        run.line(format!("dry run: the command would {what} of {}; nothing was changed.", layout.root.display()).as_str());
127        return Ok(0);
128    }
129    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)? };
130    if let Some(refusals) = refused {
131        say_refusals(run.say, refusals.as_slice());
132        return Ok(REFUSED_EXIT_CODE);
133    }
134    return Ok(0);
135}
136
137/// What:
138///  The action of `git cli-git migrate-to-subvolume`: help, or the run from the working directory's repository.
139/// Why:
140///   Progress goes to standard error live; the action carries only the exit status.
141///
142/// In TS you'd write (pseudocode):
143/// ```ts
144/// function planMigrate(args: MigrateArguments, cwd: string): Action;
145/// ```
146pub fn migrate_action(arguments: &MigrateArguments, cwd: &Path) -> Action {
147    if arguments.help {
148        return Action::Exit { code: 0, stdout: String::from(MIGRATE_HELP), stderr: String::new() };
149    }
150    let root: PathBuf = match main_checkout_root(cwd) {
151        Ok(found) => found,
152        Err(message) => {
153            return Action::Exit {
154                code: REFUSED_EXIT_CODE,
155                stdout: String::new(),
156                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"),
157            };
158        }
159    };
160    let mut say = |text: &str| {
161        let mut stderr = std::io::stderr().lock();
162        let _written = writeln!(stderr, "{text}");
163    };
164    let code: i32 = execute(&LinuxPlatform, arguments, root.as_path(), &Interrupt::never(), &mut say);
165    return Action::Exit { code, stdout: String::new(), stderr: String::new() };
166}
167
168/// Command controls stay out of the release executable.
169#[cfg(test)]
170#[path = "subvolume_command_tests.rs"]
171mod tests;