1use std::collections::{BTreeSet, HashSet};
4use std::fs;
5use std::path::{Component, Path, PathBuf};
6use std::sync::{Arc, Mutex};
7
8use crate::diff::{
9 diff_index_to_tree, diff_index_to_worktree_with_options, DiffIndexToWorktreeOptions, DiffStatus,
10};
11use crate::error::{Error, Result};
12use crate::index::{index_file_mtime, Index, IndexEntry, MODE_GITLINK, MODE_TREE};
13use crate::objects::{parse_commit, ObjectId};
14use crate::path_icase::paths_equal;
15use crate::path_validate::{validate_path, EntryMode, PathValidationKind, ProtectOptions};
16use crate::pathspec::pathspec_is_exclude;
17use crate::porcelain::checkout::remove_empty_parent_dirs;
18use crate::repo::Repository;
19use crate::state::{resolve_head_for_repository, HeadState};
20
21#[derive(Debug, Clone, Default)]
23pub struct RemoveOptions {
24 pub pathspecs: Vec<String>,
26 pub pathspec_sources: Vec<String>,
28 pub cached: bool,
30 pub force: bool,
32 pub recursive: bool,
34 pub dry_run: bool,
36 pub ignore_unmatch: bool,
38}
39
40impl RemoveOptions {
41 pub fn resolve_recursive(&mut self, repo: &Repository) -> Result<()> {
47 if self.recursive {
48 return Ok(());
49 }
50 self.recursive = infer_remove_recursive(repo, &self.pathspec_sources, &self.pathspecs)?;
51 Ok(())
52 }
53}
54
55#[derive(Debug, Clone, Default, PartialEq, Eq)]
57pub struct RemoveOutcome {
58 pub removed: Vec<String>,
60}
61
62#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
64pub enum RemoveBlockReason {
65 StagedChanges,
69 LocalModifications,
72 StagedAndLocal,
75}
76
77impl RemoveBlockReason {
78 #[must_use]
90 pub fn classify(staged: bool, local: bool, cached: bool) -> Option<Self> {
91 match (staged, local) {
92 (true, true) => Some(Self::StagedAndLocal),
93 (true, false) if !cached => Some(Self::StagedChanges),
94 (false, true) if !cached => Some(Self::LocalModifications),
95 _ => None,
96 }
97 }
98
99 #[must_use]
101 pub fn allowed_with_cached(self) -> bool {
102 !matches!(self, Self::StagedAndLocal)
103 }
104}
105
106impl std::fmt::Display for RemoveBlockReason {
107 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
108 f.write_str(match self {
109 Self::StagedChanges => "staged changes",
110 Self::LocalModifications => "local modifications",
111 Self::StagedAndLocal => "staged content differs from both HEAD and the working tree",
112 })
113 }
114}
115
116#[derive(Debug, Clone, PartialEq, Eq)]
118pub struct BlockedRemoval {
119 pub path: String,
121 pub reason: RemoveBlockReason,
123}
124
125impl std::fmt::Display for BlockedRemoval {
126 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
127 write!(f, "{} ({})", self.path, self.reason)
128 }
129}
130
131#[derive(Debug, Clone, PartialEq, Eq)]
133pub struct PathMove {
134 pub from: String,
136 pub to: String,
138}
139
140#[derive(Debug, Clone)]
142pub struct MoveOptions {
143 pub sources: Vec<String>,
145 pub destination: String,
147 pub force: bool,
149 pub dry_run: bool,
151}
152
153#[derive(Debug, Clone, PartialEq, Eq)]
155pub struct MoveOutcome {
156 pub moves: Vec<PathMove>,
158}
159
160pub fn remove_paths(repo: &Repository, opts: &RemoveOptions) -> Result<RemoveOutcome> {
183 let work_tree = repo.work_tree().ok_or(Error::RequiresWorkTree)?;
184
185 if opts.pathspecs.is_empty() {
186 return Err(Error::RemovePathsRequired);
187 }
188
189 let mut opts = opts.clone();
190 opts.resolve_recursive(repo)?;
191
192 let mut index = repo.load_index()?;
193 let head_tree = resolve_head_tree(repo)?;
194 let tracked_paths = tracked_index_paths(&index);
195
196 let to_remove = collect_removal_paths(repo, &tracked_paths, work_tree, &opts)?;
197
198 if !opts.force {
199 let blocked = blocked_removals(
200 repo,
201 &mut index,
202 head_tree.as_ref(),
203 work_tree,
204 &to_remove,
205 opts.cached,
206 )?;
207 if !blocked.is_empty() {
208 return Err(Error::RemoveBlocked { paths: blocked });
209 }
210 }
211
212 if to_remove.is_empty() || opts.dry_run {
213 return Ok(RemoveOutcome { removed: to_remove });
214 }
215
216 let mut lock = repo.begin_index_update()?;
217 index = repo.load_index()?;
218 let path_bytes: Vec<&[u8]> = to_remove.iter().map(|p| p.as_bytes()).collect();
219 index.remove_paths(path_bytes);
220 repo.commit_index_update(&mut lock, &mut index, !opts.cached)?;
221
222 if !opts.cached {
223 let mut removed_worktree: Vec<PathBuf> = Vec::new();
224 for path in &to_remove {
225 let abs = work_tree.join(path);
226 if abs.is_file() || abs.is_symlink() {
227 fs::remove_file(&abs)
228 .map_err(|e| Error::PathError(format!("removing '{}': {e}", path)))?;
229 removed_worktree.push(abs);
230 }
231 }
232 for abs in removed_worktree {
233 remove_empty_parent_dirs(work_tree, &abs);
234 }
235 }
236
237 Ok(RemoveOutcome { removed: to_remove })
238}
239
240pub fn move_path(repo: &Repository, src: &str, dst: &str, force: bool) -> Result<MoveOutcome> {
248 move_paths(
249 repo,
250 &MoveOptions {
251 sources: vec![src.to_owned()],
252 destination: dst.to_owned(),
253 force,
254 dry_run: false,
255 },
256 )
257}
258
259pub fn move_paths(repo: &Repository, opts: &MoveOptions) -> Result<MoveOutcome> {
270 let work_tree = repo.work_tree().ok_or(Error::RequiresWorkTree)?;
271 if opts.sources.is_empty() {
272 return Err(Error::MoveNothingToMove {
273 src: opts.destination.clone(),
274 });
275 }
276
277 let ignorecase = repo
278 .config()
279 .ok()
280 .and_then(|cfg| cfg.get_bool("core.ignorecase").and_then(|r| r.ok()))
281 .unwrap_or(false);
282
283 let mut index = repo.load_index()?;
284 let dst = normalize_and_validate_repo_relative(repo, work_tree, &opts.destination)?;
285
286 let mut planned: Vec<SingleMovePlan> = Vec::new();
287 let mut simulated = index.clone();
288
289 for src_raw in &opts.sources {
290 let src = normalize_and_validate_repo_relative(repo, work_tree, src_raw)?;
291 let plan = plan_single_move(PlanSingleMoveParams {
292 repo,
293 work_tree,
294 index: &simulated,
295 src: &src,
296 dst: &dst,
297 multi_source: opts.sources.len() > 1,
298 force: opts.force,
299 ignorecase,
300 })?;
301 apply_renames_to_index(&mut simulated, &plan.renames)?;
302 planned.push(plan);
303 }
304
305 let moves: Vec<PathMove> = planned
306 .iter()
307 .flat_map(|plan| {
308 plan.renames.iter().map(|(from, to)| PathMove {
309 from: from.clone(),
310 to: to.clone(),
311 })
312 })
313 .collect();
314
315 validate_unique_move_destinations(&moves)?;
316
317 if opts.dry_run {
318 return Ok(MoveOutcome { moves });
319 }
320
321 let mut lock = repo.begin_index_update()?;
322 index = repo.load_index()?;
323
324 let mut force_clear_worktree: Vec<String> = Vec::new();
325 if opts.force {
326 for plan in &planned {
327 force_clear_worktree.extend(worktree_paths_to_clear(
328 work_tree,
329 &index,
330 &plan.final_dst,
331 ));
332 }
333 force_clear_worktree.sort();
334 force_clear_worktree.dedup();
335 }
336
337 let cleared_backup = if !force_clear_worktree.is_empty() {
338 backup_and_remove_worktree_paths(work_tree, &force_clear_worktree)?
339 } else {
340 Vec::new()
341 };
342
343 let mut completed_worktree_renames: Vec<(String, String)> = Vec::new();
344 for plan in &planned {
345 if opts.force {
346 clear_destination_index(&mut index, &plan.final_dst);
347 }
348 if let Err(err) = rename_worktree_paths(
349 work_tree,
350 &plan.src,
351 &plan.final_dst,
352 &plan.renames,
353 ignorecase,
354 ) {
355 return Err(abort_failed_move_worktree(
356 work_tree,
357 &completed_worktree_renames,
358 &cleared_backup,
359 ignorecase,
360 err,
361 ));
362 }
363 completed_worktree_renames.extend(plan.renames.clone());
364 }
365
366 let all_renames: Vec<(String, String)> = planned
367 .iter()
368 .flat_map(|plan| plan.renames.clone())
369 .collect();
370 if let Err(err) = apply_renames_to_index(&mut index, &all_renames) {
371 return Err(abort_failed_move_worktree(
372 work_tree,
373 &completed_worktree_renames,
374 &cleared_backup,
375 ignorecase,
376 err,
377 ));
378 }
379
380 repo.flush_index_update(&mut lock, &mut index)?;
381 repo.finish_index_update(&mut lock, true)?;
382
383 Ok(MoveOutcome { moves })
384}
385
386fn validate_unique_move_destinations(moves: &[PathMove]) -> Result<()> {
387 let mut seen = HashSet::new();
388 for mv in moves {
389 if !seen.insert(mv.to.clone()) {
390 return Err(Error::MoveDuplicateDestination {
391 destination: mv.to.clone(),
392 });
393 }
394 }
395 Ok(())
396}
397
398struct WorktreeRenameRollback {
399 rolled_back_destinations: HashSet<String>,
401 failures: Vec<(String, String, Error)>,
403}
404
405fn rollback_worktree_renames(
406 work_tree: &Path,
407 applied: &[(String, String)],
408 ignorecase: bool,
409) -> WorktreeRenameRollback {
410 let mut rolled_back_destinations = HashSet::new();
411 let mut failures = Vec::new();
412 for (from, to) in applied.iter().rev() {
413 match reverse_worktree_rename(work_tree, from, to, ignorecase) {
414 Ok(()) => {
415 rolled_back_destinations.insert(to.clone());
416 }
417 Err(err) => failures.push((from.clone(), to.clone(), err)),
418 }
419 }
420 WorktreeRenameRollback {
421 rolled_back_destinations,
422 failures,
423 }
424}
425
426fn reverse_worktree_rename(work_tree: &Path, from: &str, to: &str, ignorecase: bool) -> Result<()> {
427 let from_abs = work_tree.join(from);
428 let to_abs = work_tree.join(to);
429 if !to_abs.exists() {
430 return Err(Error::PathError(format!(
431 "rollback skipped missing destination '{to}'"
432 )));
433 }
434 if let Some(parent) = from_abs.parent() {
435 fs::create_dir_all(parent)
436 .map_err(|e| Error::PathError(format!("creating parent for rollback '{from}': {e}")))?;
437 }
438 rename_worktree_entry(&to_abs, &from_abs, ignorecase)
439 .map_err(|e| Error::PathError(format!("rolling back rename '{to}' -> '{from}': {e}")))?;
440 remove_empty_parent_dirs(work_tree, &to_abs);
441 Ok(())
442}
443
444fn backup_path_preserves_moved_content(backup_rel: &str, preserve_roots: &HashSet<String>) -> bool {
445 preserve_roots
446 .iter()
447 .any(|root| backup_rel == root || backup_rel.starts_with(&format!("{root}/")))
448}
449
450fn abort_failed_move_worktree(
455 work_tree: &Path,
456 completed_renames: &[(String, String)],
457 force_backups: &[WorktreePathBackup],
458 ignorecase: bool,
459 original: Error,
460) -> Error {
461 let rollback = rollback_worktree_renames(work_tree, completed_renames, ignorecase);
462 let moved_destinations: HashSet<String> =
463 completed_renames.iter().map(|(_, to)| to.clone()).collect();
464 let preserve_destination_content: HashSet<String> = moved_destinations
465 .difference(&rollback.rolled_back_destinations)
466 .cloned()
467 .collect();
468 let backups_to_restore: Vec<WorktreePathBackup> = force_backups
469 .iter()
470 .filter(|backup| {
471 !backup_path_preserves_moved_content(backup.rel(), &preserve_destination_content)
472 })
473 .cloned()
474 .collect();
475 let restore_err = restore_worktree_backups(work_tree, &backups_to_restore).err();
476
477 if rollback.failures.is_empty() && restore_err.is_none() {
478 return original;
479 }
480
481 let mut parts = vec![original.to_string()];
482 if !rollback.failures.is_empty() {
483 parts.push(format!(
484 "incomplete worktree rollback ({} rename(s) could not be reversed)",
485 rollback.failures.len()
486 ));
487 }
488 if let Some(err) = restore_err {
489 parts.push(format!(
490 "restoring forced-destination backups failed: {err}"
491 ));
492 }
493 Error::Message(parts.join("; "))
494}
495
496struct SingleMovePlan {
497 src: String,
498 final_dst: String,
499 renames: Vec<(String, String)>,
500}
501
502struct PlanSingleMoveParams<'a> {
503 repo: &'a Repository,
504 work_tree: &'a Path,
505 index: &'a Index,
506 src: &'a str,
507 dst: &'a str,
508 multi_source: bool,
509 force: bool,
510 ignorecase: bool,
511}
512
513fn plan_single_move(params: PlanSingleMoveParams<'_>) -> Result<SingleMovePlan> {
514 let PlanSingleMoveParams {
515 repo,
516 work_tree,
517 index,
518 src,
519 dst,
520 multi_source,
521 force,
522 ignorecase,
523 } = params;
524 let src_entries = index_paths_under(index, src, true);
525 if src_entries.is_empty() {
526 return Err(Error::MoveSourceNotTracked {
527 src: src.to_owned(),
528 });
529 }
530
531 let (final_dst, dst_is_dir_target) = resolve_move_destination(work_tree, index, src, dst)?;
532 let final_dst = normalize_and_validate_repo_relative(repo, work_tree, &final_dst)?;
533
534 if multi_source && !dst_is_dir_target {
535 let dest_exists =
536 work_tree.join(&final_dst).exists() || index.get(final_dst.as_bytes(), 0).is_some();
537 if dest_exists {
538 return Err(Error::MoveDestinationExists {
539 src: src.to_owned(),
540 destination: final_dst,
541 });
542 }
543 return Err(Error::Message(format!(
544 "destination '{final_dst}' is not a directory"
545 )));
546 }
547
548 if is_subdirectory(&final_dst, src) {
549 return Err(Error::MoveIntoSubdirectory {
550 src: src.to_owned(),
551 destination: final_dst,
552 });
553 }
554
555 if final_dst == *src {
556 return Err(Error::MoveIntoSelf {
557 src: src.to_owned(),
558 destination: final_dst,
559 });
560 }
561
562 let renames = plan_index_renames(index, src, &final_dst)?;
563 if renames.is_empty() {
564 return Err(Error::MoveNothingToMove {
565 src: src.to_owned(),
566 });
567 }
568
569 ensure_worktree_move_sources_exist(work_tree, &renames)?;
570
571 if !force
572 && destination_exists(
573 work_tree,
574 index,
575 &final_dst,
576 dst_is_dir_target,
577 ignorecase,
578 &renames,
579 )
580 {
581 return Err(Error::MoveDestinationExists {
582 src: src.to_owned(),
583 destination: final_dst,
584 });
585 }
586
587 Ok(SingleMovePlan {
588 src: src.to_owned(),
589 final_dst,
590 renames,
591 })
592}
593
594fn apply_renames_to_index(index: &mut Index, renames: &[(String, String)]) -> Result<()> {
595 if renames.is_empty() {
596 return Ok(());
597 }
598 let old_paths: Vec<Vec<u8>> = renames
599 .iter()
600 .map(|(from, _)| from.as_bytes().to_vec())
601 .collect();
602 let mut new_entries = Vec::with_capacity(renames.len());
603 for (from, to) in renames {
604 let old = index
605 .get(from.as_bytes(), 0)
606 .ok_or_else(|| Error::MoveMissingIndexEntry { path: from.clone() })?;
607 new_entries.push(entry_with_relpath(old.clone(), to));
608 }
609 let old_refs: Vec<&[u8]> = old_paths.iter().map(|p| p.as_slice()).collect();
610 index.remove_paths_and_insert(old_refs, new_entries);
611 Ok(())
612}
613
614fn infer_remove_recursive(repo: &Repository, sources: &[String], specs: &[String]) -> Result<bool> {
615 let work_tree = repo.work_tree().ok_or(Error::RequiresWorkTree)?;
616 let source_labels = if sources.is_empty() { specs } else { sources };
617 for (sel, spec) in source_labels.iter().zip(specs.iter()) {
618 if sel.ends_with('/') || sel.ends_with('\\') {
619 return Ok(true);
620 }
621 if work_tree.join(spec).is_dir() {
622 return Ok(true);
623 }
624 }
625 Ok(false)
626}
627
628fn resolve_head_tree(repo: &Repository) -> Result<Option<ObjectId>> {
629 match resolve_head_for_repository(repo)? {
630 HeadState::Branch { oid: Some(oid), .. } | HeadState::Detached { oid } => {
631 let obj = repo.odb().read(&oid)?;
632 let commit = parse_commit(&obj.data)?;
633 Ok(Some(commit.tree))
634 }
635 HeadState::Branch { .. } => Ok(None),
636 HeadState::Invalid => Err(Error::Message("HEAD is in an unknown state".into())),
637 }
638}
639
640fn tracked_index_paths(index: &Index) -> BTreeSet<String> {
642 index
643 .entries
644 .iter()
645 .map(|e| String::from_utf8_lossy(&e.path).into_owned())
646 .collect()
647}
648
649fn collect_removal_paths(
650 repo: &Repository,
651 tracked: &BTreeSet<String>,
652 _work_tree: &Path,
653 opts: &RemoveOptions,
654) -> Result<Vec<String>> {
655 let pathspec_ctx = repo.pathspec_match_context();
656 let sources = if opts.pathspec_sources.is_empty() {
657 &opts.pathspecs
658 } else {
659 &opts.pathspec_sources
660 };
661
662 let mut selected: BTreeSet<String> = BTreeSet::new();
663 for (label, resolved) in sources.iter().zip(opts.pathspecs.iter()) {
664 if pathspec_is_exclude(label) {
665 continue;
666 }
667 let spec = resolved.trim_end_matches('/');
668 let matches: Vec<String> = tracked
669 .iter()
670 .filter(|path| {
671 crate::pathspec::matches_pathspec_list_with_context(
672 path,
673 std::slice::from_ref(resolved),
674 pathspec_ctx,
675 )
676 })
677 .cloned()
678 .collect();
679
680 if matches.is_empty() {
681 let prefix = format!("{spec}/");
682 let under: Vec<String> = tracked
683 .iter()
684 .filter(|path| path.starts_with(&prefix))
685 .cloned()
686 .collect();
687 if under.is_empty() {
688 if opts.ignore_unmatch {
689 continue;
690 }
691 return Err(Error::PathspecNoMatch {
692 spec: label.clone(),
693 });
694 }
695 if !opts.recursive {
696 return Err(Error::NotRemovingRecursively(resolved.clone()));
697 }
698 selected.extend(under);
699 continue;
700 }
701
702 if !opts.recursive && has_child_paths(spec, tracked) {
703 let prefix = format!("{spec}/");
704 if matches.iter().any(|path| path.starts_with(&prefix)) {
705 return Err(Error::NotRemovingRecursively(resolved.clone()));
706 }
707 }
708
709 for path in matches {
710 if has_child_paths(&path, tracked) && !opts.recursive {
711 return Err(Error::NotRemovingRecursively(path.clone()));
712 }
713 selected.insert(path.clone());
714 if opts.recursive {
715 let prefix = format!("{path}/");
716 for child in tracked.iter().filter(|p| p.starts_with(&prefix)) {
717 selected.insert((*child).clone());
718 }
719 }
720 }
721 }
722
723 let mut out: Vec<String> = selected.into_iter().collect();
724 out.sort();
725 Ok(out)
726}
727
728fn has_child_paths(prefix: &str, tracked: &BTreeSet<String>) -> bool {
729 let plen = prefix.len();
730 tracked
731 .iter()
732 .any(|p| p.len() > plen && p.starts_with(prefix) && p.as_bytes()[plen] == b'/')
733}
734
735fn blocked_removals(
740 repo: &Repository,
741 index: &mut Index,
742 head_tree: Option<&ObjectId>,
743 work_tree: &Path,
744 to_remove: &[String],
745 cached: bool,
746) -> Result<Vec<BlockedRemoval>> {
747 let staged: HashSet<String> = diff_index_to_tree(repo.odb(), index, head_tree, false)?
748 .into_iter()
749 .filter(|entry| entry.status != DiffStatus::Unmerged)
750 .map(|entry| entry.path().to_string())
751 .collect();
752 let compare: BTreeSet<String> = to_remove
753 .iter()
754 .filter(|path| !cached || staged.contains(*path))
755 .cloned()
756 .collect();
757 let local = if compare.is_empty() {
758 HashSet::new()
759 } else {
760 paths_differing_from_worktree(repo, index, work_tree, compare)?
761 };
762
763 Ok(to_remove
764 .iter()
765 .filter_map(|path| {
766 let reason =
767 RemoveBlockReason::classify(staged.contains(path), local.contains(path), cached)?;
768 let entry = index.get(path.as_bytes(), 0)?;
769 if cached && entry.intent_to_add() {
770 return None;
771 }
772 worktree_still_has_file(work_tree, path, entry.mode).then(|| BlockedRemoval {
773 path: path.clone(),
774 reason,
775 })
776 })
777 .collect())
778}
779
780fn worktree_still_has_file(work_tree: &Path, path: &str, mode: u32) -> bool {
783 match fs::symlink_metadata(work_tree.join(path)) {
784 Ok(meta) => !meta.is_dir() || mode == MODE_GITLINK,
785 Err(_) => false,
786 }
787}
788
789fn paths_differing_from_worktree(
791 repo: &Repository,
792 index: &mut Index,
793 work_tree: &Path,
794 paths: BTreeSet<String>,
795) -> Result<HashSet<String>> {
796 let index_mtime = index_file_mtime(&repo.index_path());
797 let worktree_rules = crate::worktree_rules::WorktreeRules::from_repository(repo, index)?;
798 let rules_arc = Arc::new(Mutex::new(worktree_rules));
799 let diff_opts = DiffIndexToWorktreeOptions {
800 index_mtime,
801 repository_git_dir: Some(repo.git_dir().to_path_buf()),
802 refresh_index_stat_in_pass: true,
803 config: repo.config().ok(),
804 worktree_rules: Some(Arc::clone(&rules_arc)),
805 command_runner: Some(repo.command_runner()),
806 restrict_to_paths: Some(Arc::new(paths)),
807 ..Default::default()
808 };
809 let (unstaged, _) =
810 diff_index_to_worktree_with_options(repo.odb(), index, work_tree, diff_opts)?;
811 Ok(unstaged
812 .into_iter()
813 .filter(|entry| entry.status != DiffStatus::Unmerged)
814 .map(|entry| entry.path().to_string())
815 .collect())
816}
817
818#[derive(Debug, Clone)]
819enum WorktreePathBackup {
820 File {
821 rel: String,
822 bytes: Vec<u8>,
823 #[cfg(unix)]
824 mode: u32,
825 },
826 Symlink {
827 rel: String,
828 target: PathBuf,
829 },
830}
831
832impl WorktreePathBackup {
833 fn rel(&self) -> &str {
834 match self {
835 Self::File { rel, .. } | Self::Symlink { rel, .. } => rel,
836 }
837 }
838}
839
840fn snapshot_worktree_path(work_tree: &Path, path: &str) -> Result<Option<WorktreePathBackup>> {
841 let abs = work_tree.join(path);
842 if abs.is_symlink() {
843 let target = fs::read_link(&abs)
844 .map_err(|e| Error::PathError(format!("reading symlink '{path}': {e}")))?;
845 return Ok(Some(WorktreePathBackup::Symlink {
846 rel: path.to_string(),
847 target,
848 }));
849 }
850 if abs.is_file() {
851 let bytes =
852 fs::read(&abs).map_err(|e| Error::PathError(format!("reading '{path}': {e}")))?;
853 #[cfg(unix)]
854 {
855 use std::os::unix::fs::PermissionsExt;
856 let mode = fs::metadata(&abs)
857 .map_err(|e| Error::PathError(format!("reading metadata for '{path}': {e}")))?
858 .permissions()
859 .mode()
860 & 0o7777;
861 return Ok(Some(WorktreePathBackup::File {
862 rel: path.to_string(),
863 bytes,
864 mode,
865 }));
866 }
867 #[cfg(not(unix))]
868 {
869 return Ok(Some(WorktreePathBackup::File {
870 rel: path.to_string(),
871 bytes,
872 }));
873 }
874 }
875 Ok(None)
876}
877
878fn remove_backed_up_worktree_path(work_tree: &Path, backup: &WorktreePathBackup) -> Result<()> {
879 let path = backup.rel();
880 let abs = work_tree.join(path);
881 fs::remove_file(&abs).map_err(|e| Error::PathError(format!("removing '{path}': {e}")))?;
882 remove_empty_parent_dirs(work_tree, &abs);
883 Ok(())
884}
885
886fn backup_and_remove_worktree_paths(
887 work_tree: &Path,
888 paths: &[String],
889) -> Result<Vec<WorktreePathBackup>> {
890 let mut snapshots = Vec::with_capacity(paths.len());
891 for path in paths {
892 if let Some(backup) = snapshot_worktree_path(work_tree, path)? {
893 snapshots.push(backup);
894 }
895 }
896
897 let mut removed: Vec<WorktreePathBackup> = Vec::new();
898 for backup in &snapshots {
899 match remove_backed_up_worktree_path(work_tree, backup) {
900 Ok(()) => removed.push(backup.clone()),
901 Err(err) => {
902 let _ = restore_worktree_backups(work_tree, &removed);
903 return Err(err);
904 }
905 }
906 }
907 Ok(snapshots)
908}
909
910fn restore_worktree_backups(work_tree: &Path, backups: &[WorktreePathBackup]) -> Result<()> {
911 for backup in backups {
912 match backup {
913 WorktreePathBackup::File {
914 rel,
915 bytes,
916 #[cfg(unix)]
917 mode,
918 } => {
919 let abs = work_tree.join(rel);
920 if let Some(parent) = abs.parent() {
921 fs::create_dir_all(parent).map_err(|e| {
922 Error::PathError(format!("creating parent for '{rel}': {e}"))
923 })?;
924 }
925 fs::write(&abs, bytes)
926 .map_err(|e| Error::PathError(format!("restoring '{rel}': {e}")))?;
927 #[cfg(unix)]
928 {
929 use std::os::unix::fs::PermissionsExt;
930 let mut perms = fs::metadata(&abs)
931 .map_err(|e| {
932 Error::PathError(format!("reading metadata for '{rel}': {e}"))
933 })?
934 .permissions();
935 perms.set_mode(*mode);
936 fs::set_permissions(&abs, perms).map_err(|e| {
937 Error::PathError(format!("restoring mode for '{rel}': {e}"))
938 })?;
939 }
940 }
941 WorktreePathBackup::Symlink { rel, target } => {
942 let abs = work_tree.join(rel);
943 if let Some(parent) = abs.parent() {
944 fs::create_dir_all(parent).map_err(|e| {
945 Error::PathError(format!("creating parent for '{rel}': {e}"))
946 })?;
947 }
948 #[cfg(unix)]
949 {
950 use std::os::unix::fs::symlink;
951 symlink(target, &abs)
952 .map_err(|e| Error::PathError(format!("restoring symlink '{rel}': {e}")))?;
953 }
954 #[cfg(not(unix))]
955 {
956 let _ = target;
957 return Err(Error::Message(format!(
958 "cannot restore symlink '{rel}' on this platform"
959 )));
960 }
961 }
962 }
963 }
964 Ok(())
965}
966
967fn normalize_and_validate_repo_relative(
976 repo: &Repository,
977 _work_tree: &Path,
978 path: &str,
979) -> Result<String> {
980 if path.is_empty() {
981 return Err(Error::InvalidPath("empty path".into()));
982 }
983 if Path::new(path).is_absolute() {
984 return Err(Error::InvalidPath(format!(
985 "'{path}' is outside repository"
986 )));
987 }
988 let with_slashes = path.replace('\\', "/");
989 if with_slashes.starts_with('/') {
990 return Err(Error::InvalidPath(format!(
991 "'{path}' is outside repository"
992 )));
993 }
994 for component in Path::new(&with_slashes).components() {
995 if matches!(component, Component::ParentDir) {
996 return Err(Error::InvalidPath(format!(
997 "'{path}' is outside repository"
998 )));
999 }
1000 }
1001 let rel = with_slashes.trim_start_matches("./");
1002 if rel.is_empty() {
1003 return Err(Error::InvalidPath("empty path".into()));
1004 }
1005 let opts = ProtectOptions::from_config(&*repo.config()?);
1006 validate_path(
1007 rel.as_bytes(),
1008 EntryMode::new(0o100644),
1009 opts,
1010 PathValidationKind::Worktree,
1011 )
1012 .map_err(|reason| Error::invalid_path_component(rel, reason))?;
1013 Ok(rel.to_string())
1014}
1015
1016fn index_paths_under(index: &Index, prefix: &str, include_exact: bool) -> Vec<String> {
1017 let plen = prefix.len();
1018 let mut paths: Vec<String> = index
1019 .entries
1020 .iter()
1021 .filter(|e| e.stage() == 0)
1022 .filter_map(|e| {
1023 let p = String::from_utf8_lossy(&e.path);
1024 if p == prefix {
1025 return include_exact.then(|| p.into_owned());
1026 }
1027 if p.len() > plen && p.starts_with(prefix) && p.as_bytes()[plen] == b'/' {
1028 return Some(p.into_owned());
1029 }
1030 None
1031 })
1032 .collect();
1033 paths.sort();
1034 paths.dedup();
1035 paths
1036}
1037
1038fn resolve_move_destination(
1039 work_tree: &Path,
1040 index: &Index,
1041 src: &str,
1042 dst: &str,
1043) -> Result<(String, bool)> {
1044 let dst_abs = work_tree.join(dst);
1045 let dst_is_dir = dst_abs.is_dir()
1046 || index_paths_under(index, dst, false)
1047 .iter()
1048 .any(|p| p.starts_with(dst))
1049 || index
1050 .get(dst.as_bytes(), 0)
1051 .is_some_and(|e| e.mode == MODE_TREE);
1052
1053 if dst_is_dir {
1054 let base = Path::new(src)
1055 .file_name()
1056 .and_then(|s| s.to_str())
1057 .unwrap_or(src);
1058 Ok((format!("{dst}/{base}"), true))
1059 } else {
1060 Ok((dst.to_string(), false))
1061 }
1062}
1063
1064fn destination_exists(
1065 work_tree: &Path,
1066 index: &Index,
1067 dst: &str,
1068 _dst_was_dir: bool,
1069 ignorecase: bool,
1070 renames: &[(String, String)],
1071) -> bool {
1072 if ignorecase && case_only_rename_to(renames, dst) {
1073 return index_has_unrelated_entries(index, dst, ignorecase, renames);
1074 }
1075
1076 let abs = work_tree.join(dst);
1077 if abs.exists() {
1078 if ignorecase && rename_sources_cover_path(renames, dst) {
1079 return index_has_unrelated_entries(index, dst, ignorecase, renames);
1080 }
1081 return true;
1082 }
1083
1084 index_has_unrelated_entries(index, dst, ignorecase, renames)
1085}
1086
1087fn case_only_rename_to(renames: &[(String, String)], dst: &str) -> bool {
1088 renames.iter().any(|(from, to)| {
1089 to == dst && from != to && paths_equal(from.as_bytes(), to.as_bytes(), true)
1090 })
1091}
1092
1093fn rename_sources_cover_path(renames: &[(String, String)], path: &str) -> bool {
1094 renames
1095 .iter()
1096 .any(|(from, _)| paths_equal(from.as_bytes(), path.as_bytes(), true))
1097}
1098
1099fn index_has_unrelated_entries(
1100 index: &Index,
1101 dst: &str,
1102 ignorecase: bool,
1103 renames: &[(String, String)],
1104) -> bool {
1105 let rename_from: HashSet<&str> = renames.iter().map(|(from, _)| from.as_str()).collect();
1106
1107 if ignorecase {
1108 return index.entries.iter().filter(|e| e.stage() == 0).any(|e| {
1109 let path = String::from_utf8_lossy(&e.path);
1110 if !index_path_at_destination(&path, dst) {
1111 return false;
1112 }
1113 !rename_from.contains(path.as_ref())
1114 });
1115 }
1116
1117 let mut paths = index_paths_under(index, dst, true);
1118 if paths.is_empty() && index.get(dst.as_bytes(), 0).is_some() {
1119 paths.push(dst.to_string());
1120 }
1121 paths.iter().any(|p| !rename_from.contains(p.as_str()))
1122}
1123
1124fn index_path_at_destination(path: &str, dst: &str) -> bool {
1125 if paths_equal(path.as_bytes(), dst.as_bytes(), true) {
1126 return true;
1127 }
1128 if path == dst {
1129 return true;
1130 }
1131 let plen = dst.len();
1132 path.len() > plen && path.starts_with(dst) && path.as_bytes()[plen] == b'/'
1133}
1134
1135fn worktree_paths_to_clear(work_tree: &Path, index: &Index, dst: &str) -> Vec<String> {
1136 let mut paths = index_paths_under(index, dst, true);
1137 if paths.is_empty() {
1138 let abs = work_tree.join(dst);
1139 if abs.is_file() || abs.is_symlink() {
1140 paths.push(dst.to_string());
1141 }
1142 }
1143 paths
1144}
1145
1146fn clear_destination_index(index: &mut Index, dst: &str) {
1147 let paths = index_paths_under(index, dst, true);
1148 if paths.is_empty() {
1149 index.remove(dst.as_bytes());
1150 } else {
1151 index.remove_paths(paths.iter().map(|p| p.as_bytes()));
1152 }
1153}
1154
1155fn is_subdirectory(candidate: &str, parent_dir: &str) -> bool {
1156 if candidate == parent_dir {
1157 return false;
1158 }
1159 let plen = parent_dir.len();
1160 candidate.starts_with(parent_dir) && candidate.as_bytes().get(plen) == Some(&b'/')
1161}
1162
1163fn ensure_worktree_move_sources_exist(
1164 work_tree: &Path,
1165 renames: &[(String, String)],
1166) -> Result<()> {
1167 for (from, to) in renames {
1168 let abs = work_tree.join(from);
1169 if abs.is_file() || abs.is_symlink() {
1170 continue;
1171 }
1172 if abs.is_dir() {
1173 continue;
1174 }
1175 return Err(Error::MoveBadSource {
1176 src: from.clone(),
1177 destination: to.clone(),
1178 });
1179 }
1180 Ok(())
1181}
1182
1183fn rename_worktree_entry(from: &Path, to: &Path, ignorecase: bool) -> std::io::Result<()> {
1184 if from == to {
1185 return Ok(());
1186 }
1187 let from_name = from.to_string_lossy();
1188 let to_name = to.to_string_lossy();
1189 if ignorecase && from_name.eq_ignore_ascii_case(to_name.as_ref()) && from != to {
1190 case_only_worktree_rename(from, to)
1191 } else {
1192 fs::rename(from, to)
1193 }
1194}
1195
1196fn case_only_worktree_rename(from: &Path, to: &Path) -> std::io::Result<()> {
1197 use std::sync::atomic::{AtomicU64, Ordering};
1198
1199 static CASE_MV_TEMP_COUNTER: AtomicU64 = AtomicU64::new(0);
1200 let parent = to.parent().unwrap_or_else(|| Path::new("."));
1201 let salt = CASE_MV_TEMP_COUNTER.fetch_add(1, Ordering::Relaxed);
1202 for attempt in 0..64 {
1203 let temp_path = parent.join(format!(
1204 ".grit-mv-{}-{}-{}",
1205 std::process::id(),
1206 salt,
1207 attempt
1208 ));
1209 if temp_path.exists() {
1210 continue;
1211 }
1212 match fs::rename(from, &temp_path) {
1213 Ok(()) => match fs::rename(&temp_path, to) {
1214 Ok(()) => return Ok(()),
1215 Err(e) => {
1216 if fs::rename(&temp_path, from).is_err() {
1217 return Err(std::io::Error::new(
1218 e.kind(),
1219 format!(
1220 "renaming back to '{}' after failed final case-only step: \
1221 source may be at '{}'",
1222 from.display(),
1223 temp_path.display()
1224 ),
1225 ));
1226 }
1227 return Err(e);
1228 }
1229 },
1230 Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => continue,
1231 Err(e) => return Err(e),
1232 }
1233 }
1234 Err(std::io::Error::other(
1235 "could not allocate a unique temporary path for case-only rename",
1236 ))
1237}
1238
1239fn plan_index_renames(index: &Index, src: &str, final_dst: &str) -> Result<Vec<(String, String)>> {
1240 let src_paths = index_paths_under(index, src, true);
1241 let mut renames = Vec::with_capacity(src_paths.len());
1242 for from in src_paths {
1243 let suffix = from.strip_prefix(src).unwrap_or("");
1244 let to = if suffix.is_empty() {
1245 final_dst.to_string()
1246 } else if let Some(stripped) = suffix.strip_prefix('/') {
1247 format!("{final_dst}/{stripped}")
1248 } else {
1249 format!("{final_dst}{suffix}")
1250 };
1251 renames.push((from, to));
1252 }
1253 Ok(renames)
1254}
1255
1256fn rename_worktree_paths(
1257 work_tree: &Path,
1258 src: &str,
1259 final_dst: &str,
1260 renames: &[(String, String)],
1261 ignorecase: bool,
1262) -> Result<()> {
1263 if renames.len() == 1 && renames[0].0 == src {
1264 let from = work_tree.join(&renames[0].0);
1265 let to = work_tree.join(&renames[0].1);
1266 if let Some(parent) = to.parent() {
1267 fs::create_dir_all(parent).map_err(|e| {
1268 Error::PathError(format!("creating parent for '{}': {e}", renames[0].1))
1269 })?;
1270 }
1271 rename_worktree_entry(&from, &to, ignorecase).map_err(|e| {
1272 Error::PathError(format!(
1273 "renaming '{}' -> '{}': {e}",
1274 renames[0].0, renames[0].1
1275 ))
1276 })?;
1277 remove_empty_parent_dirs(work_tree, &from);
1278 return Ok(());
1279 }
1280
1281 let src_abs = work_tree.join(src);
1282 let dst_abs = work_tree.join(final_dst);
1283 if src_abs.is_dir() {
1284 if let Some(parent) = dst_abs.parent() {
1285 fs::create_dir_all(parent)
1286 .map_err(|e| Error::PathError(format!("creating parent for '{final_dst}': {e}")))?;
1287 }
1288 rename_worktree_entry(&src_abs, &dst_abs, ignorecase).map_err(|e| {
1289 Error::PathError(format!("renaming directory '{src}' -> '{final_dst}': {e}"))
1290 })?;
1291 remove_empty_parent_dirs(work_tree, &src_abs);
1292 return Ok(());
1293 }
1294
1295 for (from, to) in renames {
1296 let from_abs = work_tree.join(from);
1297 let to_abs = work_tree.join(to);
1298 if let Some(parent) = to_abs.parent() {
1299 fs::create_dir_all(parent)
1300 .map_err(|e| Error::PathError(format!("creating parent for '{to}': {e}")))?;
1301 }
1302 if from_abs.exists() {
1303 rename_worktree_entry(&from_abs, &to_abs, ignorecase)
1304 .map_err(|e| Error::PathError(format!("renaming '{from}' -> '{to}': {e}")))?;
1305 remove_empty_parent_dirs(work_tree, &from_abs);
1306 }
1307 }
1308 Ok(())
1309}
1310
1311fn entry_with_relpath(mut entry: IndexEntry, path: &str) -> IndexEntry {
1312 entry.path = path.as_bytes().to_vec();
1313 let path_len = entry.path.len().min(0xFFF) as u16;
1314 entry.flags = (entry.flags & !0x0FFF) | path_len;
1315 entry
1316}
1317
1318#[cfg(test)]
1319mod remove_block_reason_tests {
1320 use super::{worktree_still_has_file, BlockedRemoval, RemoveBlockReason};
1321 use crate::index::{MODE_GITLINK, MODE_REGULAR};
1322
1323 #[test]
1324 fn classify_without_cached_refuses_any_difference() {
1325 assert_eq!(RemoveBlockReason::classify(false, false, false), None);
1326 assert_eq!(
1327 RemoveBlockReason::classify(true, false, false),
1328 Some(RemoveBlockReason::StagedChanges)
1329 );
1330 assert_eq!(
1331 RemoveBlockReason::classify(false, true, false),
1332 Some(RemoveBlockReason::LocalModifications)
1333 );
1334 assert_eq!(
1335 RemoveBlockReason::classify(true, true, false),
1336 Some(RemoveBlockReason::StagedAndLocal)
1337 );
1338 }
1339
1340 #[test]
1341 fn classify_with_cached_refuses_only_staged_and_local() {
1342 assert_eq!(RemoveBlockReason::classify(false, false, true), None);
1343 assert_eq!(RemoveBlockReason::classify(true, false, true), None);
1344 assert_eq!(RemoveBlockReason::classify(false, true, true), None);
1345 assert_eq!(
1346 RemoveBlockReason::classify(true, true, true),
1347 Some(RemoveBlockReason::StagedAndLocal)
1348 );
1349 }
1350
1351 #[test]
1352 fn allowed_with_cached_matches_classify() {
1353 for reason in [
1354 RemoveBlockReason::StagedChanges,
1355 RemoveBlockReason::LocalModifications,
1356 RemoveBlockReason::StagedAndLocal,
1357 ] {
1358 let (staged, local) = match reason {
1359 RemoveBlockReason::StagedChanges => (true, false),
1360 RemoveBlockReason::LocalModifications => (false, true),
1361 RemoveBlockReason::StagedAndLocal => (true, true),
1362 };
1363 assert_eq!(
1364 reason.allowed_with_cached(),
1365 RemoveBlockReason::classify(staged, local, true).is_none()
1366 );
1367 }
1368 }
1369
1370 #[test]
1371 fn blocked_removal_display_names_path_and_reason() {
1372 let blocked = BlockedRemoval {
1373 path: "dir/a.txt".into(),
1374 reason: RemoveBlockReason::LocalModifications,
1375 };
1376 assert_eq!(blocked.to_string(), "dir/a.txt (local modifications)");
1377 }
1378
1379 #[test]
1380 fn worktree_presence_ignores_missing_files_and_directories() {
1381 let tmp = tempfile::tempdir().expect("tempdir");
1382 let wt = tmp.path();
1383 std::fs::write(wt.join("file"), b"x\n").unwrap();
1384 std::fs::create_dir(wt.join("dir")).unwrap();
1385 assert!(worktree_still_has_file(wt, "file", MODE_REGULAR));
1386 assert!(!worktree_still_has_file(wt, "missing", MODE_REGULAR));
1387 assert!(!worktree_still_has_file(wt, "dir", MODE_REGULAR));
1388 assert!(worktree_still_has_file(wt, "dir", MODE_GITLINK));
1389 }
1390}
1391
1392#[cfg(test)]
1393mod path_validation_tests {
1394 use super::normalize_and_validate_repo_relative;
1395 use crate::error::Error;
1396 use crate::repo::{InitOptions, Repository};
1397
1398 fn test_repo() -> (tempfile::TempDir, Repository) {
1399 let dir = tempfile::tempdir().expect("tempdir");
1400 let repo = Repository::init(dir.path(), &InitOptions::default()).expect("init");
1401 (dir, repo)
1402 }
1403
1404 #[test]
1405 fn rejects_absolute_paths() {
1406 let (_dir, repo) = test_repo();
1407 let wt = repo.work_tree().expect("worktree");
1408 let err = normalize_and_validate_repo_relative(&repo, wt, "/tmp/repo/destination")
1409 .expect_err("abs");
1410 assert!(matches!(err, Error::InvalidPath(_)));
1411 }
1412
1413 #[test]
1414 fn rejects_internal_parent_components() {
1415 let (_dir, repo) = test_repo();
1416 let wt = repo.work_tree().expect("worktree");
1417 let err = normalize_and_validate_repo_relative(&repo, wt, "dir/../source").expect_err("..");
1418 assert!(matches!(err, Error::InvalidPath(_)));
1419 }
1420
1421 #[test]
1422 fn accepts_simple_relative_paths() {
1423 let (_dir, repo) = test_repo();
1424 let wt = repo.work_tree().expect("worktree");
1425 assert_eq!(
1426 normalize_and_validate_repo_relative(&repo, wt, "./nested/file.txt").expect("ok"),
1427 "nested/file.txt"
1428 );
1429 }
1430}
1431
1432#[cfg(test)]
1433mod destination_exists_tests {
1434 use super::{
1435 case_only_rename_to, destination_exists, index_has_unrelated_entries,
1436 rename_sources_cover_path,
1437 };
1438 use crate::index::Index;
1439 use crate::index::IndexEntry;
1440 use crate::objects::ObjectId;
1441
1442 fn dummy_entry(path: &str) -> IndexEntry {
1443 IndexEntry {
1444 ctime_sec: 0,
1445 ctime_nsec: 0,
1446 mtime_sec: 0,
1447 mtime_nsec: 0,
1448 dev: 0,
1449 ino: 0,
1450 mode: 0o100644,
1451 uid: 0,
1452 gid: 0,
1453 size: 0,
1454 oid: ObjectId::from_bytes(&[0u8; 20]).unwrap(),
1455 flags: 0,
1456 flags_extended: None,
1457 path: path.as_bytes().to_vec(),
1458 base_index_pos: 0,
1459 }
1460 }
1461
1462 #[test]
1463 fn case_only_rename_helpers() {
1464 let renames = vec![
1465 ("README.md".into(), "readme.md".into()),
1466 ("src/A.rs".into(), "src/a.rs".into()),
1467 ];
1468 assert!(case_only_rename_to(&renames, "readme.md"));
1469 assert!(!case_only_rename_to(&renames, "other.md"));
1470 assert!(rename_sources_cover_path(&renames, "README.md"));
1471 assert!(!rename_sources_cover_path(&renames, "missing.md"));
1472 }
1473
1474 #[test]
1475 fn ignorecase_index_collision_allows_case_only_target() {
1476 let tmp = tempfile::tempdir().expect("tempdir");
1477 let wt = tmp.path();
1478 std::fs::write(wt.join("README.md"), b"x\n").unwrap();
1479 let mut index = Index::new();
1480 index.push_entry_unsorted(dummy_entry("README.md"));
1481 let renames = vec![("README.md".into(), "readme.md".into())];
1482 assert!(
1483 !destination_exists(wt, &index, "readme.md", false, true, &renames),
1484 "case-only rename must not treat the source path as a blocking destination"
1485 );
1486 }
1487
1488 #[test]
1489 fn ignorecase_index_blocks_unrelated_destination() {
1490 let mut index = Index::new();
1491 index.push_entry_unsorted(dummy_entry("README.md"));
1492 index.push_entry_unsorted(dummy_entry("readme.md"));
1493 let renames = vec![("README.md".into(), "readme.md".into())];
1494 assert!(
1495 index_has_unrelated_entries(&index, "readme.md", true, &renames),
1496 "distinct index spellings at the same icase path must block"
1497 );
1498 }
1499}
1500
1501#[cfg(test)]
1502mod move_rollback_tests {
1503 use std::fs;
1504
1505 use crate::error::Error;
1506
1507 use super::{
1508 abort_failed_move_worktree, restore_worktree_backups, rollback_worktree_renames,
1509 snapshot_worktree_path,
1510 };
1511
1512 #[test]
1513 fn abort_failed_move_rolls_back_before_restoring_force_backups() {
1514 let tmp = tempfile::tempdir().expect("tempdir");
1515 let wt = tmp.path();
1516 fs::create_dir_all(wt.join("src")).expect("src dir");
1517 fs::create_dir_all(wt.join("dest")).expect("dest dir");
1518 fs::write(wt.join("dest/a.txt"), b"old-dest\n").expect("dest file");
1519 let backup = snapshot_worktree_path(wt, "dest/a.txt")
1520 .expect("snapshot")
1521 .expect("backup bytes");
1522 fs::write(wt.join("dest/a.txt"), b"from-source\n").expect("simulated completed rename");
1523 let completed = vec![("src/a.txt".to_string(), "dest/a.txt".to_string())];
1524 let original = crate::error::Error::PathError("simulated rename failure".into());
1525
1526 let _ = abort_failed_move_worktree(
1527 wt,
1528 &completed,
1529 std::slice::from_ref(&backup),
1530 false,
1531 original,
1532 );
1533
1534 assert_eq!(
1535 fs::read(wt.join("src/a.txt")).expect("source restored"),
1536 b"from-source\n",
1537 "source bytes must survive rollback before backup restore"
1538 );
1539 assert_eq!(
1540 fs::read(wt.join("dest/a.txt")).expect("dest restored"),
1541 b"old-dest\n",
1542 "force backup must restore pre-move destination content"
1543 );
1544
1545 fs::write(wt.join("dest/a.txt"), b"from-source\n").expect("post-move dest");
1546 restore_worktree_backups(wt, std::slice::from_ref(&backup)).expect("restore first");
1547 let _rollback = rollback_worktree_renames(wt, &completed, false);
1548 let wrong_source = fs::read(wt.join("src/a.txt")).expect("wrong-order source");
1549 assert_ne!(
1550 wrong_source, b"from-source\n",
1551 "restoring backups before rollback must not preserve moved source bytes"
1552 );
1553 }
1554
1555 #[test]
1556 fn abort_failed_move_restores_multi_source_force_batch() {
1557 let tmp = tempfile::tempdir().expect("tempdir");
1558 let wt = tmp.path();
1559 fs::create_dir_all(wt.join("src")).expect("src");
1560 fs::create_dir_all(wt.join("dest")).expect("dest");
1561 fs::write(wt.join("dest/f0.txt"), b"old0\n").expect("old dest0");
1562 fs::write(wt.join("dest/f1.txt"), b"old1\n").expect("old dest1");
1563 let backup0 = snapshot_worktree_path(wt, "dest/f0.txt")
1564 .expect("snap0")
1565 .expect("b0");
1566 let backup1 = snapshot_worktree_path(wt, "dest/f1.txt")
1567 .expect("snap1")
1568 .expect("b1");
1569 fs::write(wt.join("dest/f0.txt"), b"source0\n").expect("moved0");
1570 fs::write(wt.join("dest/f1.txt"), b"source1\n").expect("moved1");
1571 let completed = vec![
1572 ("src/f0.txt".to_string(), "dest/f0.txt".to_string()),
1573 ("src/f1.txt".to_string(), "dest/f1.txt".to_string()),
1574 ];
1575 let backups = vec![backup0, backup1];
1576 let _ = abort_failed_move_worktree(
1577 wt,
1578 &completed,
1579 &backups,
1580 false,
1581 crate::error::Error::PathError("simulated later rename failure".into()),
1582 );
1583 assert_eq!(fs::read(wt.join("src/f0.txt")).unwrap(), b"source0\n");
1584 assert_eq!(fs::read(wt.join("src/f1.txt")).unwrap(), b"source1\n");
1585 assert_eq!(fs::read(wt.join("dest/f0.txt")).unwrap(), b"old0\n");
1586 assert_eq!(fs::read(wt.join("dest/f1.txt")).unwrap(), b"old1\n");
1587 }
1588
1589 #[test]
1590 fn abort_failed_move_preserves_destination_when_reverse_rename_fails() {
1591 use super::backup_and_remove_worktree_paths;
1592
1593 let tmp = tempfile::tempdir().expect("tempdir");
1594 let wt = tmp.path();
1595 fs::create_dir_all(wt.join("src")).expect("src dir");
1596 fs::create_dir_all(wt.join("dest")).expect("dest dir");
1597 fs::write(wt.join("src/f0000"), b"SOURCE-0\n").expect("source 0");
1598 fs::write(wt.join("src/f0001"), b"SOURCE-1\n").expect("source 1");
1599 fs::write(wt.join("dest/f0000"), b"OLDDEST-0\n").expect("old dest 0");
1600 fs::write(wt.join("dest/f0001"), b"OLDDEST-1\n").expect("old dest 1");
1601
1602 let cleared = backup_and_remove_worktree_paths(
1603 wt,
1604 &["dest/f0000".to_owned(), "dest/f0001".to_owned()],
1605 )
1606 .expect("backup dest");
1607 fs::create_dir_all(wt.join("dest")).expect("dest dir after clear");
1608
1609 fs::rename(wt.join("src/f0000"), wt.join("dest/f0000")).expect("move 0");
1610 fs::rename(wt.join("src/f0001"), wt.join("dest/f0001")).expect("move 1");
1611 fs::create_dir_all(wt.join("src/f0000")).expect("block rollback for f0000");
1612
1613 let applied = vec![
1614 ("src/f0000".to_owned(), "dest/f0000".to_owned()),
1615 ("src/f0001".to_owned(), "dest/f0001".to_owned()),
1616 ];
1617 let err = abort_failed_move_worktree(
1618 wt,
1619 &applied,
1620 &cleared,
1621 false,
1622 Error::MoveBadSource {
1623 src: "src/f0001".to_owned(),
1624 destination: "dest/f0001".to_owned(),
1625 },
1626 );
1627 assert!(
1628 matches!(err, Error::Message(_)),
1629 "expected aggregated incomplete rollback error, got {err:?}"
1630 );
1631 assert_eq!(
1632 fs::read_to_string(wt.join("dest/f0000")).expect("dest f0000"),
1633 "SOURCE-0\n",
1634 "must not overwrite moved bytes with forced-destination backup when rollback failed"
1635 );
1636 assert_eq!(
1637 fs::read_to_string(wt.join("dest/f0001")).expect("dest f0001"),
1638 "OLDDEST-1\n",
1639 "successful rollback should restore cleared destination backup"
1640 );
1641 }
1642
1643 #[test]
1644 fn abort_failed_move_preserves_directory_destination_descendants() {
1645 use super::WorktreePathBackup;
1646
1647 let tmp = tempfile::tempdir().expect("tempdir");
1648 let wt = tmp.path();
1649 fs::create_dir_all(wt.join("dest/srcdir")).expect("dest tree");
1650 fs::write(wt.join("dest/srcdir/file"), b"SOURCE-DIR\n").expect("moved file");
1651 fs::create_dir_all(wt.join("srcdir")).expect("srcdir");
1652 fs::write(wt.join("srcdir/blocker"), b"x\n").expect("block directory rollback");
1653
1654 let cleared = vec![WorktreePathBackup::File {
1655 rel: "dest/srcdir/file".to_owned(),
1656 bytes: b"OLDDEST-DIR\n".to_vec(),
1657 #[cfg(unix)]
1658 mode: 0o100644,
1659 }];
1660
1661 let applied = vec![("srcdir".to_owned(), "dest/srcdir".to_owned())];
1662 let err = abort_failed_move_worktree(
1663 wt,
1664 &applied,
1665 &cleared,
1666 false,
1667 Error::MoveBadSource {
1668 src: "srcdir/missing".to_owned(),
1669 destination: "dest/srcdir/missing".to_owned(),
1670 },
1671 );
1672 assert!(
1673 matches!(err, Error::Message(_)),
1674 "unexpected error: {err:?}"
1675 );
1676 assert_eq!(
1677 fs::read_to_string(wt.join("dest/srcdir/file")).expect("dest file"),
1678 "SOURCE-DIR\n",
1679 "must not restore descendant backup when directory rollback failed"
1680 );
1681 }
1682}
1683
1684#[cfg(test)]
1685mod index_lock_transaction_tests {
1686 use std::fs;
1687
1688 use crate::porcelain::add::{stage, StageOptions};
1689 use crate::progress::NullProgress;
1690 use crate::repo::{init_repository, set_test_inject_index_write_fail, Repository};
1691
1692 use super::{move_path, remove_paths, RemoveOptions};
1693
1694 fn init_repo(root: &std::path::Path) -> Repository {
1695 init_repository(root, false, "main", None, Default::default()).expect("init")
1696 }
1697
1698 #[test]
1699 fn remove_paths_injected_index_write_failure_leaves_worktree() {
1700 let tmp = tempfile::tempdir().expect("tempdir");
1701 let root = tmp.path();
1702 let repo = init_repo(root);
1703 fs::write(root.join("a.txt"), b"1\n").unwrap();
1704 stage(
1705 &repo,
1706 &StageOptions::default(),
1707 &mut NullProgress,
1708 &crate::progress::NeverCancel,
1709 )
1710 .unwrap();
1711
1712 set_test_inject_index_write_fail(true);
1713 let err = remove_paths(
1714 &repo,
1715 &RemoveOptions {
1716 pathspecs: vec!["a.txt".into()],
1717 pathspec_sources: vec!["a.txt".into()],
1718 force: true,
1719 ..Default::default()
1720 },
1721 )
1722 .expect_err("injected write failure");
1723 set_test_inject_index_write_fail(false);
1724 assert!(matches!(err, crate::error::Error::Io(_)));
1725 assert!(root.join("a.txt").is_file());
1726 }
1727
1728 #[test]
1729 fn move_path_injected_index_write_failure_leaves_worktree() {
1730 let tmp = tempfile::tempdir().expect("tempdir");
1731 let root = tmp.path();
1732 let repo = init_repo(root);
1733 fs::write(root.join("a.txt"), b"1\n").unwrap();
1734 stage(
1735 &repo,
1736 &StageOptions::default(),
1737 &mut NullProgress,
1738 &crate::progress::NeverCancel,
1739 )
1740 .unwrap();
1741
1742 set_test_inject_index_write_fail(true);
1743 let err = move_path(&repo, "a.txt", "b.txt", false).expect_err("injected write failure");
1744 set_test_inject_index_write_fail(false);
1745 assert!(matches!(err, crate::error::Error::Io(_)));
1746 assert!(root.join("a.txt").is_file());
1747 assert!(!root.join("b.txt").exists());
1748 }
1749}