Skip to main content

grit_lib/worktree/
move_repair.rs

1//! Linked worktree move and repair (Git `worktree move` / `worktree repair`).
2
3use std::fs;
4use std::io::Write;
5use std::path::{Path, PathBuf};
6
7use crate::error::{Error, Result};
8use crate::git_path::{absolute_path_for_gitfile_stored_path, path_for_disk_compare};
9use crate::index::{Index, MODE_GITLINK};
10use crate::lockfile::{LockError, LockFile, LockWait};
11use crate::repo::Repository;
12
13use super::core::{common_git_dir, find_linked_worktree, list_worktrees, WorktreeEntry};
14use super::error::{WorktreeError, WorktreeOperation};
15
16/// Options for [`move_linked_worktree`].
17#[derive(Debug, Clone, Default)]
18pub struct MoveWorktreeOptions {
19    /// Allow move when the worktree is locked (Git requires `-f` twice; grit uses one `--force`).
20    pub force: bool,
21    /// Report the move without changing disk state.
22    pub dry_run: bool,
23}
24
25/// What [`repair_worktrees`] fixed for one worktree path.
26#[derive(Debug, Clone, Copy, PartialEq, Eq)]
27pub enum WorktreeRepairKind {
28    /// The worktree `.git` gitfile pointed at the wrong admin directory.
29    GitFileAdminDir,
30    /// `<admin>/gitdir` did not point at this worktree's `.git` file.
31    GitdirWorktreePath,
32    /// `.git` used a relative/absolute path where the other form was required.
33    GitFilePathStyle,
34    /// `gitdir` used a relative/absolute path where the other form was required.
35    GitdirPathStyle,
36}
37
38/// One repair action recorded by [`repair_worktrees`].
39#[derive(Debug, Clone, PartialEq, Eq)]
40pub struct WorktreeRepair {
41    /// Working tree root that was repaired.
42    pub path: PathBuf,
43    /// Which link was rewritten.
44    pub kind: WorktreeRepairKind,
45}
46
47/// Move a linked worktree to a new path and rewrite `gitdir` / `.git` links.
48///
49/// # Errors
50///
51/// Returns [`WorktreeError::NotRegistered`] for an unknown `from`,
52/// [`WorktreeError::MainWorktree`] for the main worktree, [`WorktreeError::Locked`] unless
53/// [`MoveWorktreeOptions::force`] is set, [`WorktreeError::PathExists`] for an existing
54/// destination, [`WorktreeError::HasSubmodules`] for populated submodules, and I/O failures.
55pub fn move_linked_worktree(
56    repo: &Repository,
57    from: &Path,
58    to: &Path,
59    options: &MoveWorktreeOptions,
60) -> Result<()> {
61    let entry = find_linked_worktree(repo, from, WorktreeOperation::Move)?;
62    if entry.is_locked && !options.force {
63        return Err(WorktreeError::Locked {
64            reason: entry.lock_reason,
65        }
66        .into());
67    }
68
69    let dest = resolve_move_destination(&entry.path, to)?;
70    if dest.exists() {
71        return Err(WorktreeError::PathExists { path: dest }.into());
72    }
73
74    if worktree_has_blocking_submodules(&entry)? {
75        return Err(WorktreeError::HasSubmodules.into());
76    }
77
78    if options.dry_run {
79        return Ok(());
80    }
81
82    rename_worktree_directory(&entry.path, &dest)?;
83
84    let dotgit = dest.join(".git");
85    let gitdir_file = entry.admin_dir.join("gitdir");
86    write_worktree_linking_files(&dotgit, &gitdir_file, &dest, &entry.admin_dir)?;
87    Ok(())
88}
89
90/// Repair `.git` / `gitdir` links for linked worktrees.
91///
92/// When `paths` is empty, repairs every registered linked worktree from the open repository.
93/// Otherwise each path must be a linked worktree root (not the main worktree).
94///
95/// # Errors
96///
97/// Returns [`WorktreeError::PathNotFound`] or [`WorktreeError::NotADirectory`] for an invalid
98/// target, [`WorktreeError::DotGitIsDirectory`] or [`WorktreeError::BrokenGitFile`] when a
99/// target's `.git` cannot be traced to its registration, and I/O failures.
100pub fn repair_worktrees(
101    repo: &Repository,
102    paths: &[&Path],
103    dry_run: bool,
104) -> Result<Vec<WorktreeRepair>> {
105    let common = common_git_dir(repo.git_dir());
106    let mut repairs = Vec::new();
107    if paths.is_empty() {
108        for entry in list_worktrees(repo)? {
109            if entry.admin_dir == common {
110                continue;
111            }
112            let Some(wt_path) = locate_worktree_root_for_repair(&entry)? else {
113                continue;
114            };
115            repair_worktree_links(&wt_path, &entry.admin_dir, dry_run, &mut repairs)?;
116        }
117        return Ok(repairs);
118    }
119    for path in paths {
120        let wt_path = path_for_disk_compare(path);
121        if !wt_path.exists() {
122            return Err(WorktreeError::PathNotFound { path: wt_path }.into());
123        }
124        if !wt_path.is_dir() {
125            return Err(WorktreeError::NotADirectory { path: wt_path }.into());
126        }
127        repair_worktree_at_path(repo, &wt_path, dry_run, &mut repairs)?;
128    }
129    Ok(repairs)
130}
131
132/// Resolve the destination directory for [`move_linked_worktree`].
133///
134/// When `to` is an existing directory, the worktree basename is appended (Git behavior).
135///
136/// # Errors
137///
138/// Returns [`WorktreeError::NoFileName`] when `from` has no final path component and
139/// [`WorktreeError::NotADirectory`] when the destination's parent is not a directory.
140pub fn resolve_move_destination(from: &Path, to: &Path) -> Result<PathBuf> {
141    let dest = if to.is_dir() {
142        let base = from.file_name().ok_or_else(|| WorktreeError::NoFileName {
143            path: from.to_path_buf(),
144        })?;
145        path_for_disk_compare(&to.join(base))
146    } else {
147        path_for_disk_compare(to)
148    };
149    if let Some(parent) = dest.parent() {
150        if parent.exists() && !parent.is_dir() {
151            return Err(WorktreeError::NotADirectory {
152                path: parent.to_path_buf(),
153            }
154            .into());
155        }
156    }
157    Ok(dest)
158}
159
160fn rename_worktree_directory(from: &Path, to: &Path) -> Result<()> {
161    if let Some(parent) = to.parent() {
162        fs::create_dir_all(parent).map_err(Error::Io)?;
163    }
164    match fs::rename(from, to) {
165        Ok(()) => Ok(()),
166        Err(err) if err.kind() == std::io::ErrorKind::CrossesDevices => {
167            match copy_dir_all(from, to) {
168                Ok(()) => {
169                    fs::remove_dir_all(from).map_err(Error::Io)?;
170                    Ok(())
171                }
172                Err(copy_err) => {
173                    let _ = fs::remove_dir_all(to);
174                    Err(copy_err)
175                }
176            }
177        }
178        Err(err) => Err(Error::Io(err)),
179    }
180}
181
182fn copy_dir_all(from: &Path, to: &Path) -> Result<()> {
183    fs::create_dir_all(to).map_err(Error::Io)?;
184    for entry in fs::read_dir(from).map_err(Error::Io)? {
185        let entry = entry.map_err(Error::Io)?;
186        let src = entry.path();
187        let dest = to.join(entry.file_name());
188        copy_tree_entry(&src, &dest)?;
189    }
190    Ok(())
191}
192
193fn copy_tree_entry(from: &Path, to: &Path) -> Result<()> {
194    let meta = fs::symlink_metadata(from).map_err(Error::Io)?;
195    let file_type = meta.file_type();
196    if file_type.is_symlink() || windows_mount_reparse_point(&meta) {
197        let target = fs::read_link(from).map_err(Error::Io)?;
198        create_symlink(&meta, &target, to)?;
199    } else if file_type.is_dir() {
200        copy_dir_all(from, to)?;
201    } else {
202        if let Some(parent) = to.parent() {
203            fs::create_dir_all(parent).map_err(Error::Io)?;
204        }
205        fs::copy(from, to).map_err(Error::Io)?;
206    }
207    Ok(())
208}
209
210/// Windows directory junctions (and other mount-point reparse points) look like directories
211/// to [`std::fs::FileType::is_dir`] but must not be descended into when copying.
212#[cfg(windows)]
213fn windows_mount_reparse_point(meta: &fs::Metadata) -> bool {
214    use std::os::windows::fs::MetadataExt;
215    const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x0400;
216    meta.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0
217}
218
219#[cfg(not(windows))]
220fn windows_mount_reparse_point(_meta: &fs::Metadata) -> bool {
221    false
222}
223
224fn create_symlink(source_meta: &fs::Metadata, target: &Path, link_path: &Path) -> Result<()> {
225    if let Some(parent) = link_path.parent() {
226        fs::create_dir_all(parent).map_err(Error::Io)?;
227    }
228    #[cfg(unix)]
229    {
230        let _ = source_meta;
231        use std::os::unix::fs::symlink;
232        symlink(target, link_path).map_err(Error::Io)?;
233    }
234    #[cfg(windows)]
235    {
236        use std::os::windows::fs::{symlink_dir, symlink_file, FileTypeExt};
237        // Preserve the source reparse-point kind. `Metadata::is_dir()` is false for every symlink
238        // on Windows; `FileTypeExt::is_symlink_dir()` reads the reparse-point kind directly.
239        // Junctions are directory reparse points but not symlinks.
240        if source_meta.file_type().is_symlink_dir() || windows_mount_reparse_point(source_meta) {
241            symlink_dir(target, link_path).map_err(Error::Io)?;
242        } else {
243            symlink_file(target, link_path).map_err(Error::Io)?;
244        }
245    }
246    Ok(())
247}
248
249#[cfg(all(test, windows))]
250mod windows_symlink_copy_tests {
251    use super::*;
252    use std::os::windows::fs::{symlink_dir, FileTypeExt};
253
254    #[test]
255    fn copy_tree_entry_preserves_dangling_directory_symlink_kind() {
256        let tmp = tempfile::tempdir().expect("tempdir");
257        let src_root = tmp.path().join("src");
258        fs::create_dir_all(&src_root).expect("srcdir");
259        let src_link = src_root.join("dirlink");
260        symlink_dir("missing-directory", &src_link).expect("symlink_dir");
261
262        let dest_link = tmp.path().join("dst").join("dirlink");
263        copy_tree_entry(&src_link, &dest_link).expect("copy");
264
265        let meta = fs::symlink_metadata(&dest_link).expect("dest meta");
266        assert!(meta.file_type().is_symlink());
267        assert!(
268            meta.file_type().is_symlink_dir(),
269            "directory reparse point must stay a directory link"
270        );
271        assert_eq!(
272            fs::read_link(&dest_link).expect("readlink"),
273            PathBuf::from("missing-directory")
274        );
275    }
276
277    #[test]
278    fn copy_tree_entry_preserves_relative_directory_symlink_kind() {
279        let tmp = tempfile::tempdir().expect("tempdir");
280        let src_root = tmp.path().join("src");
281        fs::create_dir_all(&src_root).expect("srcdir");
282        let src_link = src_root.join("dirlink");
283        symlink_dir("relative\\child", &src_link).expect("symlink_dir");
284
285        let dest_link = tmp.path().join("dst").join("dirlink");
286        copy_tree_entry(&src_link, &dest_link).expect("copy");
287
288        let meta = fs::symlink_metadata(&dest_link).expect("dest meta");
289        assert!(meta.file_type().is_symlink());
290        assert!(
291            meta.file_type().is_symlink_dir(),
292            "relative directory link must not become a file link"
293        );
294        assert_eq!(
295            fs::read_link(&dest_link).expect("readlink"),
296            PathBuf::from("relative\\child")
297        );
298    }
299}
300
301fn worktree_has_blocking_submodules(entry: &WorktreeEntry) -> Result<bool> {
302    let modules = entry.admin_dir.join("modules");
303    if modules.is_dir() && fs::read_dir(&modules).map_err(Error::Io)?.next().is_some() {
304        return Ok(true);
305    }
306    let index_path = entry.admin_dir.join("index");
307    if !index_path.is_file() {
308        return Ok(false);
309    }
310    let index = Index::load(&index_path)?;
311    for idx_entry in index.entries() {
312        if idx_entry.mode != MODE_GITLINK {
313            continue;
314        }
315        let name = std::str::from_utf8(&idx_entry.path).map_err(|_| {
316            WorktreeError::NonUtf8SubmodulePath {
317                path: idx_entry.path.clone(),
318            }
319        })?;
320        let sub_path = entry.path.join(name);
321        if is_submodule_populated(&sub_path) {
322            return Ok(true);
323        }
324    }
325    Ok(false)
326}
327
328fn is_submodule_populated(sub_path: &Path) -> bool {
329    let dot_git = sub_path.join(".git");
330    if dot_git.is_dir() {
331        return true;
332    }
333    if dot_git.is_file() {
334        if let Ok(content) = fs::read_to_string(&dot_git) {
335            if let Some(target) = parse_gitfile_target(&content, sub_path) {
336                return target.exists();
337            }
338        }
339    }
340    false
341}
342
343fn parse_gitfile_target(content: &str, base: &Path) -> Option<PathBuf> {
344    for line in content.lines() {
345        if let Some(rest) = line.strip_prefix("gitdir:") {
346            let rel = rest.trim();
347            let path = if Path::new(rel).is_absolute() {
348                PathBuf::from(rel)
349            } else {
350                base.join(rel)
351            };
352            return Some(path_for_disk_compare(&path));
353        }
354    }
355    None
356}
357
358fn write_worktree_linking_files(
359    dotgit: &Path,
360    gitdir_file: &Path,
361    wt_path: &Path,
362    admin_dir: &Path,
363) -> Result<()> {
364    let admin_abs = absolute_path_for_gitfile_stored_path(admin_dir);
365    let dotgit_abs = absolute_path_for_gitfile_stored_path(dotgit);
366    atomic_write_text(gitdir_file, &format!("{dotgit_abs}\n"))?;
367    atomic_write_text(dotgit, &format!("gitdir: {admin_abs}\n"))?;
368    let _ = wt_path;
369    Ok(())
370}
371
372fn atomic_write_text(path: &Path, content: &str) -> Result<()> {
373    let lock_path = lock_path_for(path);
374    let mut lock = LockFile::acquire(&lock_path, LockWait::Immediately).map_err(map_lock_err)?;
375    lock.write_all(content.as_bytes()).map_err(Error::Io)?;
376    lock.commit_to(path).map_err(map_lock_err)?;
377    Ok(())
378}
379
380fn lock_path_for(path: &Path) -> PathBuf {
381    let name = path
382        .file_name()
383        .map(|n| format!("{}.lock", n.to_string_lossy()))
384        .unwrap_or_else(|| "file.lock".to_owned());
385    path.parent()
386        .map(|p| p.join(&name))
387        .unwrap_or_else(|| PathBuf::from(name))
388}
389
390fn map_lock_err(err: LockError) -> Error {
391    match err {
392        LockError::Io { source, .. } => Error::Io(source),
393        LockError::Held { path } => WorktreeError::LockHeld { path }.into(),
394    }
395}
396
397/// Resolve the on-disk worktree root for a linked registration, including after a manual move
398/// left the admin `gitdir` pointing at a stale path.
399fn locate_worktree_root_for_repair(entry: &WorktreeEntry) -> Result<Option<PathBuf>> {
400    if linked_worktree_gitfile_at(&entry.path) {
401        return Ok(Some(entry.path.clone()));
402    }
403    if let Some(wt) = worktree_root_from_admin_gitdir(&entry.admin_dir) {
404        if linked_worktree_gitfile_at(&wt) {
405            return Ok(Some(wt));
406        }
407    }
408    discover_moved_worktree_sibling(entry)
409}
410
411fn linked_worktree_gitfile_at(wt_root: &Path) -> bool {
412    wt_root.is_dir() && wt_root.join(".git").is_file()
413}
414
415fn worktree_root_from_admin_gitdir(admin: &Path) -> Option<PathBuf> {
416    let gitdir_path = admin.join("gitdir");
417    let raw = fs::read_to_string(&gitdir_path).ok()?;
418    let trimmed = raw.trim();
419    if trimmed.is_empty() {
420        return None;
421    }
422    let mut dotgit = PathBuf::from(trimmed);
423    if dotgit.is_relative() {
424        dotgit = admin.join(dotgit);
425    }
426    let dotgit = path_for_disk_compare(&dotgit);
427    if !dotgit.is_file() {
428        return None;
429    }
430    dotgit.parent().map(path_for_disk_compare)
431}
432
433fn discover_moved_worktree_sibling(entry: &WorktreeEntry) -> Result<Option<PathBuf>> {
434    let stale = &entry.path;
435    let Some(parent) = stale.parent() else {
436        return Ok(None);
437    };
438    if !parent.is_dir() {
439        return Ok(None);
440    }
441    let expected_admin = absolute_path_for_gitfile_stored_path(&entry.admin_dir);
442    for child in fs::read_dir(parent).map_err(Error::Io)? {
443        let child = child.map_err(Error::Io)?;
444        let path = path_for_disk_compare(&child.path());
445        if path == *stale || !path.is_dir() {
446            continue;
447        }
448        let dotgit = path.join(".git");
449        if gitfile_refers_to_admin(&dotgit, &path, &expected_admin) {
450            return Ok(Some(path));
451        }
452    }
453    Ok(None)
454}
455
456fn gitfile_refers_to_admin(dotgit: &Path, wt_path: &Path, expected_admin: &str) -> bool {
457    if !dotgit.is_file() {
458        return false;
459    }
460    let content = fs::read_to_string(dotgit).unwrap_or_default();
461    match parse_gitfile_target(&content, wt_path) {
462        Some(admin) => absolute_path_for_gitfile_stored_path(&admin) == expected_admin,
463        None => false,
464    }
465}
466
467fn gitfile_repair_kind(
468    content: &str,
469    wt_path: &Path,
470    expected_admin: &str,
471) -> Option<WorktreeRepairKind> {
472    if content_contains_relative_gitdir(content) {
473        return Some(WorktreeRepairKind::GitFilePathStyle);
474    }
475    match parse_gitfile_target(content, wt_path) {
476        Some(current) => {
477            let current_admin = absolute_path_for_gitfile_stored_path(&current);
478            if current_admin != expected_admin {
479                Some(WorktreeRepairKind::GitFileAdminDir)
480            } else {
481                None
482            }
483        }
484        None => Some(WorktreeRepairKind::GitFileAdminDir),
485    }
486}
487
488fn content_contains_relative_gitdir(content: &str) -> bool {
489    content.lines().any(|line| {
490        line.strip_prefix("gitdir:")
491            .map(|rest| !Path::new(rest.trim()).is_absolute())
492            .unwrap_or(false)
493    })
494}
495
496fn repair_worktree_at_path(
497    repo: &Repository,
498    wt_path: &Path,
499    dry_run: bool,
500    repairs: &mut Vec<WorktreeRepair>,
501) -> Result<()> {
502    let dotgit = wt_path.join(".git");
503    if dotgit.is_dir() {
504        return Err(WorktreeError::DotGitIsDirectory { path: dotgit }.into());
505    }
506    let dotgit_content = fs::read_to_string(&dotgit).map_err(Error::Io)?;
507    let admin_dir = resolve_admin_from_gitfile(repo, &dotgit_content, wt_path)
508        .ok_or(WorktreeError::BrokenGitFile { path: dotgit })?;
509    repair_worktree_links(wt_path, &admin_dir, dry_run, repairs)
510}
511
512/// Rewrite `.git` / `gitdir` for `wt_path` using `admin_dir` as the authoritative registration.
513fn repair_worktree_links(
514    wt_path: &Path,
515    admin_dir: &Path,
516    dry_run: bool,
517    repairs: &mut Vec<WorktreeRepair>,
518) -> Result<()> {
519    let dotgit = wt_path.join(".git");
520    if dotgit.is_dir() {
521        return Err(WorktreeError::DotGitIsDirectory { path: dotgit }.into());
522    }
523    let dotgit_content = fs::read_to_string(&dotgit).unwrap_or_default();
524    let expected_admin = absolute_path_for_gitfile_stored_path(admin_dir);
525    if let Some(kind) = gitfile_repair_kind(&dotgit_content, wt_path, &expected_admin) {
526        repairs.push(WorktreeRepair {
527            path: wt_path.to_path_buf(),
528            kind,
529        });
530        if !dry_run {
531            let gitdir_file = admin_dir.join("gitdir");
532            write_worktree_linking_files(&dotgit, &gitdir_file, wt_path, admin_dir)?;
533        }
534    }
535
536    let gitdir_file = admin_dir.join("gitdir");
537    let expected_dotgit = absolute_path_for_gitfile_stored_path(&dotgit);
538    let gitdir_raw = fs::read_to_string(&gitdir_file).unwrap_or_default();
539    if let Some(kind) = gitdir_repair_kind(&gitdir_raw, admin_dir, &expected_dotgit) {
540        repairs.push(WorktreeRepair {
541            path: wt_path.to_path_buf(),
542            kind,
543        });
544        if !dry_run {
545            write_worktree_linking_files(&dotgit, &gitdir_file, wt_path, admin_dir)?;
546        }
547    }
548    Ok(())
549}
550
551fn gitdir_repair_kind(
552    raw: &str,
553    admin: &Path,
554    expected_dotgit: &str,
555) -> Option<WorktreeRepairKind> {
556    let trimmed = raw.trim();
557    if trimmed.is_empty() {
558        return Some(WorktreeRepairKind::GitdirWorktreePath);
559    }
560    if Path::new(trimmed).is_relative() {
561        return Some(WorktreeRepairKind::GitdirPathStyle);
562    }
563    let resolved = resolve_gitdir_target(trimmed, admin)?;
564    if resolved != expected_dotgit {
565        Some(WorktreeRepairKind::GitdirWorktreePath)
566    } else {
567        None
568    }
569}
570
571fn resolve_admin_from_gitfile(repo: &Repository, content: &str, wt_path: &Path) -> Option<PathBuf> {
572    parse_gitfile_target(content, wt_path)
573        .filter(|admin| admin.join("commondir").is_file() || admin.join("HEAD").is_file())
574        .or_else(|| infer_admin_from_gitfile_content(repo, content))
575}
576
577fn infer_admin_from_gitfile_content(repo: &Repository, content: &str) -> Option<PathBuf> {
578    let line = content.lines().find_map(|l| l.strip_prefix("gitdir:"))?;
579    let trimmed = line.trim();
580    let id = Path::new(trimmed).file_name()?.to_str()?;
581    let common = common_git_dir(repo.git_dir());
582    let candidate = common.join("worktrees").join(id);
583    if candidate.is_dir() {
584        Some(path_for_disk_compare(&candidate))
585    } else {
586        None
587    }
588}
589
590fn resolve_gitdir_target(trimmed: &str, admin: &Path) -> Option<String> {
591    if trimmed.is_empty() {
592        return None;
593    }
594    let path = if Path::new(trimmed).is_absolute() {
595        PathBuf::from(trimmed)
596    } else {
597        admin.join(trimmed)
598    };
599    Some(absolute_path_for_gitfile_stored_path(&path))
600}