1use 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#[derive(Debug, Clone, Default)]
18pub struct MoveWorktreeOptions {
19 pub force: bool,
21 pub dry_run: bool,
23}
24
25#[derive(Debug, Clone, Copy, PartialEq, Eq)]
27pub enum WorktreeRepairKind {
28 GitFileAdminDir,
30 GitdirWorktreePath,
32 GitFilePathStyle,
34 GitdirPathStyle,
36}
37
38#[derive(Debug, Clone, PartialEq, Eq)]
40pub struct WorktreeRepair {
41 pub path: PathBuf,
43 pub kind: WorktreeRepairKind,
45}
46
47pub 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
90pub 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
132pub 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#[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 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
397fn 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(¤t);
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
512fn 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}