git_policy_cli/subvolume/
subvolume_command.rs1use 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
28pub const REFUSED_EXIT_CODE: i32 = 1;
30
31pub 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
61fn 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
78fn 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
87pub 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
110fn 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 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
155pub 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#[cfg(test)]
188#[path = "subvolume_command_tests.rs"]
189mod tests;
190
191#[cfg(test)]
193#[path = "subvolume_command_ownership_tests.rs"]
194mod ownership_tests;