1use 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
34const GIT_FILE_LIMIT: u64 = 1 << 16;
36
37pub const CONVERTING_SUFFIX: &str = ".cli-git-converting";
39
40#[derive(Clone, Debug, Eq, PartialEq)]
50pub enum Verdict {
51 Converted,
53 Resumed,
55 WouldConvert,
57 AlreadySubvolume,
59 Abandoned,
61 Skipped(Vec<String>),
63}
64
65#[derive(Clone, Copy, Debug, Eq, PartialEq)]
75pub struct ConvertOptions {
76 pub dry_run: bool,
78 pub abandon: bool,
80 pub command_line_attributes: bool,
83}
84
85#[derive(Clone, Debug, Eq, PartialEq)]
95pub struct Candidate {
96 pub path: PathBuf,
98 pub admin: PathBuf,
100 pub common: PathBuf,
102 pub git_file: Vec<u8>,
104}
105
106pub 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
113pub 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#[derive(Clone, Copy, Debug, Eq, PartialEq)]
147pub enum RefusalKind {
148 Locked,
150 IndexLock,
152 ConvertingName,
154 Filesystem,
156 Nested,
158 NotQuiet,
160 Process,
162 OtherCommand,
164}
165
166#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
168pub struct Owned {
169 pub index_lock: bool,
171 pub stage: bool,
173}
174
175#[derive(Clone, Debug, Eq, PartialEq)]
177pub struct Refusal {
178 pub kind: RefusalKind,
180 pub text: String,
182}
183
184fn 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 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
219fn 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
231pub 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
287fn 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
293struct Job<'a> {
303 candidate: &'a Candidate,
305 converting: PathBuf,
307 journal: Journal,
309}
310
311impl Job<'_> {
312 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 fn parent(&self) -> Result<std::fs::File, MigrateError> {
320 return held_parent(self.candidate.path.as_path());
321 }
322
323 fn converting_name(&self) -> &std::ffi::OsStr {
325 return self.converting.file_name().unwrap_or_default();
326 }
327
328 fn stage_identity(&self) -> Result<Option<ObjectId>, MigrateError> {
330 return stage_identity_of(self.candidate.path.as_path());
331 }
332}
333
334fn 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
340fn 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
346fn 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
352fn 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
363fn 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
398fn 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
403fn 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
416fn 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
421fn 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
435fn 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 hold_index_lock(job)?;
458 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 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
480fn 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#[derive(Clone, Copy, Debug, Eq, PartialEq)]
507enum Where {
508 AtPath,
510 AtConverting,
512 Nowhere,
514}
515
516fn 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
541fn 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
554fn 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
572fn 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 return release_index_lock(job);
594}
595
596fn 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
637fn 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
644fn 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
668pub 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 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 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 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 let must_be_quiet: bool = journal.as_ref().is_none_or(|found: &Journal| { return found.phase < Phase::Exchanged; });
742 if must_be_quiet {
743 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 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 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#[cfg(test)]
795#[path = "worktree_convert_tests.rs"]
796mod tests;
797
798#[cfg(test)]
800#[path = "worktree_convert_content_tests.rs"]
801mod content_tests;
802
803#[cfg(test)]
805#[path = "worktree_convert_ownership_tests.rs"]
806mod ownership_tests;
807
808#[cfg(test)]
811#[path = "worktree_convert_safety_tests.rs"]
812mod safety_tests;