Skip to main content

grit_lib/porcelain/
paths.rs

1//! Remove and rename paths in the working tree and index (`git rm` / `git mv` semantics).
2
3use 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/// Options for [`remove_paths`].
22#[derive(Debug, Clone, Default)]
23pub struct RemoveOptions {
24    /// Resolved pathspec patterns (repository-relative).
25    pub pathspecs: Vec<String>,
26    /// Original pathspec argv fragments for errors (defaults to `pathspecs` when empty).
27    pub pathspec_sources: Vec<String>,
28    /// Remove from the index only; leave the working tree file in place.
29    pub cached: bool,
30    /// Remove even when the path has staged or unstaged local modifications.
31    pub force: bool,
32    /// Allow removing directories (all tracked paths under a directory prefix).
33    pub recursive: bool,
34    /// Run every check and report [`RemoveOutcome::removed`] without writing the index or work tree.
35    pub dry_run: bool,
36    /// Treat non-matching pathspecs as success with an empty removal list.
37    pub ignore_unmatch: bool,
38}
39
40impl RemoveOptions {
41    /// When `recursive` is false, infer directory removal from trailing slashes or existing directories.
42    ///
43    /// # Errors
44    ///
45    /// Returns [`Error::RequiresWorkTree`] when the repository has no work tree.
46    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/// Result of [`remove_paths`].
56#[derive(Debug, Clone, Default, PartialEq, Eq)]
57pub struct RemoveOutcome {
58    /// Repository-relative paths removed from the index (and usually the work tree).
59    pub removed: Vec<String>,
60}
61
62/// Why [`remove_paths`] refused to remove a path without [`RemoveOptions::force`].
63#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
64pub enum RemoveBlockReason {
65    /// The index entry differs from `HEAD` (or there is no `HEAD` commit yet) while the work
66    /// tree file matches the index. Deleting the file would lose the staged content;
67    /// [`RemoveOptions::cached`] keeps it on disk.
68    StagedChanges,
69    /// The work tree file differs from the index while the index matches `HEAD`. Deleting
70    /// the file would lose the edits; [`RemoveOptions::cached`] keeps it on disk.
71    LocalModifications,
72    /// The index entry differs from both `HEAD` and the work tree file, so the staged
73    /// content exists nowhere else. Refused even with [`RemoveOptions::cached`].
74    StagedAndLocal,
75}
76
77impl RemoveBlockReason {
78    /// Decide whether a path is refused, given how its index entry compares.
79    ///
80    /// # Parameters
81    ///
82    /// - `staged`: the index entry differs from `HEAD`, or there is no `HEAD` commit.
83    /// - `local`: the work tree file differs from the index entry.
84    /// - `cached`: the removal keeps the work tree file ([`RemoveOptions::cached`]).
85    ///
86    /// # Returns
87    ///
88    /// `None` when the path may be removed without `force`.
89    #[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    /// Whether retrying with [`RemoveOptions::cached`] would remove the path.
100    #[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/// A path [`remove_paths`] refused to remove, carried by [`Error::RemoveBlocked`].
117#[derive(Debug, Clone, PartialEq, Eq)]
118pub struct BlockedRemoval {
119    /// Repository-relative path that was not removed.
120    pub path: String,
121    /// Why removing it would lose content.
122    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/// One tracked path rename performed by [`move_paths`].
132#[derive(Debug, Clone, PartialEq, Eq)]
133pub struct PathMove {
134    /// Source path before the move.
135    pub from: String,
136    /// Destination path after the move.
137    pub to: String,
138}
139
140/// Options for [`move_paths`].
141#[derive(Debug, Clone)]
142pub struct MoveOptions {
143    /// Tracked source paths to move (repository-relative).
144    pub sources: Vec<String>,
145    /// Destination path or existing directory.
146    pub destination: String,
147    /// Replace an existing destination in the work tree and index.
148    pub force: bool,
149    /// Validate and report [`MoveOutcome::moves`] without writing the index or work tree.
150    pub dry_run: bool,
151}
152
153/// Result of [`move_paths`] and [`move_path`].
154#[derive(Debug, Clone, PartialEq, Eq)]
155pub struct MoveOutcome {
156    /// Individual renames applied (or that would be applied when `dry_run`).
157    pub moves: Vec<PathMove>,
158}
159
160/// Remove paths matching `opts.pathspecs` from the index and (unless `cached`) the work tree.
161///
162/// Unless `force` is set, refuses paths whose removal would lose content that exists
163/// nowhere else (see [`RemoveBlockReason`]):
164///
165/// - Without `cached`, a path is refused when its index entry differs from `HEAD` (every
166///   entry counts as staged before the first commit) or its work tree file differs from
167///   the index.
168/// - With `cached`, a path is refused only when its index entry differs from both `HEAD`
169///   and the work tree file, so a committed file edited since (or a freshly added file)
170///   can always be untracked while keeping the file on disk.
171/// - A path whose work tree file is already gone (or replaced by a directory) is never
172///   refused.
173///
174/// Directory prefixes require `recursive`. Empty parent directories are removed after
175/// deleting files. Cache-tree entries for touched paths are invalidated before the index
176/// is written atomically after the index lock is acquired ([`IndexHandle::begin_update`](crate::index::IndexHandle::begin_update)).
177///
178/// # Errors
179///
180/// Returns [`Error::PathspecNoMatch`], [`Error::RemoveBlocked`],
181/// [`Error::NotRemovingRecursively`], or I/O / object-store failures.
182pub 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
240/// Rename `src` to `dst` in the working tree and index, preserving stat data and object ids.
241///
242/// Thin wrapper around [`move_paths`] for a single source path.
243///
244/// # Errors
245///
246/// Same as [`move_paths`].
247pub 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
259/// Move one or more tracked sources into `opts.destination`, updating the index once.
260///
261/// When multiple sources are given, `destination` must name an existing directory; each
262/// source is moved under that directory using its final path component. With `dry_run`,
263/// every validation runs but the index and work tree are left unchanged.
264///
265/// # Errors
266///
267/// Returns [`Error::InvalidPath`], [`Error::MoveSourceNotTracked`], [`Error::MoveDestinationExists`],
268/// and related move errors, or I/O failures.
269pub 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    /// Destination paths whose move was successfully reversed during rollback.
400    rolled_back_destinations: HashSet<String>,
401    /// Reverse-rename failures encountered while rolling back (newest first).
402    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
450/// Undo completed worktree renames, then restore force-cleared destination backups.
451///
452/// Backups for destinations whose reverse rename failed are skipped so moved source bytes
453/// are not overwritten by pre-move destination snapshots.
454fn 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
640/// Stage-0 paths plus any path that appears in the index at any merge stage.
641fn 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
735/// Classify every path in `to_remove` and return the ones removal must refuse.
736///
737/// Only paths that could still be blocked are compared against the work tree: all of
738/// `to_remove` normally, but just the staged ones with `cached`.
739fn 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
780/// A tracked file that has vanished from the work tree, or turned into a directory, has
781/// nothing left on disk to lose, so its removal is never refused.
782fn 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
789/// Index paths in `paths` whose work tree file differs from the index entry.
790fn 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
967/// Normalize a repository-relative path for rm/mv, rejecting escapes and absolutes.
968///
969/// Absolute paths (including paths inside the work tree) and any `..` segment
970/// are rejected before normalization so callers cannot bypass prefix checks.
971///
972/// # Errors
973///
974/// Returns [`Error::InvalidPath`] when `path` is empty, absolute, or contains `..`.
975fn 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}