1use std::path::{Path, PathBuf};
18
19use schemars::JsonSchema;
20use serde::{Deserialize, Serialize};
21
22use super::direnv::{self, EnvrcApproval};
23use super::error::{Result, WorktreeError};
24use super::fingerprint::{self, WorktreeFingerprint};
25use super::git;
26use super::hooks::{self, HooksPath};
27use super::identity::ResolvedPath;
28use super::lock::WorktreeLock;
29use super::model::{
30 DiagnosticCode, DirtyState, RepositoryTopology, Severity, Standing, TopologyDiagnostic, SCHEMA,
31};
32use super::service::{display, Detail, WorktreeService};
33
34#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
36#[serde(rename_all = "snake_case")]
37pub enum MigrationAction {
38 Move,
40 MoveViaStaging,
42 CopyAndRepair,
44 None,
46}
47
48#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
50#[serde(rename_all = "snake_case")]
51pub enum StepOutcome {
52 Planned,
54 Blocked,
56 Moved,
58 Failed,
60}
61
62#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
64pub struct MigrationStep {
65 pub branch: String,
67 pub from: String,
69 pub to: String,
71 #[serde(default, skip_serializing_if = "Option::is_none")]
72 pub head: Option<String>,
74 pub action: MigrationAction,
76 pub dirty: DirtyState,
78 pub blockers: Vec<TopologyDiagnostic>,
80 pub outcome: StepOutcome,
82 #[serde(default, skip_serializing_if = "Option::is_none")]
83 pub message: Option<String>,
85 #[serde(default, skip_serializing_if = "Option::is_none")]
86 pub before: Option<WorktreeFingerprint>,
88 #[serde(default, skip_serializing_if = "Option::is_none")]
89 pub after: Option<WorktreeFingerprint>,
91 pub differences: Vec<String>,
93 #[serde(default, skip_serializing_if = "Option::is_none")]
94 pub envrc: Option<EnvrcApproval>,
97 #[serde(default, skip_serializing_if = "Option::is_none")]
98 pub hooks: Option<HooksPath>,
102}
103
104#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
106pub struct MigrationPlan {
107 pub schema: String,
109 pub container: String,
111 pub steps: Vec<MigrationStep>,
113 pub exceptions: Vec<TopologyDiagnostic>,
116 pub movable: usize,
118 pub blocked: usize,
120 pub moved: usize,
122 pub failed: usize,
124 pub applied: bool,
126 #[serde(default, skip_serializing_if = "Option::is_none")]
127 pub moved_current: Option<String>,
129}
130
131#[derive(Debug, Clone, Default)]
133pub struct MigrationOptions {
134 pub allow_copy: bool,
136 pub only: Vec<String>,
138 pub include_ephemeral: bool,
141}
142
143pub fn plan(service: &WorktreeService) -> Result<MigrationPlan> {
146 plan_with(service, false)
147}
148
149pub fn plan_with(service: &WorktreeService, include_ephemeral: bool) -> Result<MigrationPlan> {
159 let fast = service.topology(Detail::Fast)?;
160 let moves_something = fast
161 .worktrees
162 .iter()
163 .any(|w| is_step(w.standing, include_ephemeral));
164 let topology = if moves_something {
165 service.topology(Detail::Full)?
166 } else {
167 fast
168 };
169 Ok(plan_from(&topology, include_ephemeral))
170}
171
172fn is_step(standing: Standing, include_ephemeral: bool) -> bool {
174 match standing {
175 Standing::Misplaced => true,
176 Standing::Ephemeral => include_ephemeral,
177 _ => false,
178 }
179}
180
181pub fn plan_from(topology: &RepositoryTopology, include_ephemeral: bool) -> MigrationPlan {
188 let container = topology.container.path.clone();
189 let mut steps = Vec::new();
190 let mut exceptions = Vec::new();
191 for w in &topology.worktrees {
192 match w.standing {
193 Standing::Ephemeral if !include_ephemeral => {
194 exceptions.extend(w.diagnostics.iter().cloned());
195 }
196 Standing::Misplaced | Standing::Ephemeral => {
197 let (Some(branch), Some(to)) = (&w.branch, &w.expected_path) else {
198 exceptions.extend(w.diagnostics.iter().cloned());
199 continue;
200 };
201 let occupies_container = w
202 .diagnostics
203 .iter()
204 .any(|d| d.code == DiagnosticCode::ContainerOccupied);
205 let blockers: Vec<TopologyDiagnostic> = w
206 .diagnostics
207 .iter()
208 .filter(|d| {
209 matches!(
210 d.code,
211 DiagnosticCode::Locked
212 | DiagnosticCode::DestinationConflict
213 | DiagnosticCode::InvalidBranchName
214 | DiagnosticCode::PathEscape
215 )
216 })
217 .cloned()
218 .collect();
219 steps.push(MigrationStep {
220 branch: branch.clone(),
221 from: w.path.clone(),
222 to: to.clone(),
223 head: w.head.clone(),
224 action: if !blockers.is_empty() {
225 MigrationAction::None
226 } else if occupies_container {
227 MigrationAction::MoveViaStaging
228 } else {
229 MigrationAction::Move
230 },
231 dirty: w.dirty.clone().unwrap_or_default(),
232 outcome: if blockers.is_empty() {
233 StepOutcome::Planned
234 } else {
235 StepOutcome::Blocked
236 },
237 blockers,
238 message: None,
239 before: None,
240 after: None,
241 differences: Vec::new(),
242 envrc: None,
243 hooks: None,
244 });
245 }
246 Standing::Missing | Standing::Detached => {
247 exceptions.extend(w.diagnostics.iter().cloned());
248 }
249 Standing::Primary => {
250 exceptions.extend(
251 w.diagnostics
252 .iter()
253 .filter(|d| d.code == DiagnosticCode::PrimaryOnNonTrunk)
254 .cloned(),
255 );
256 }
257 Standing::Canonical => {}
258 }
259 }
260 steps.sort_by_key(|s| s.action != MigrationAction::MoveViaStaging);
264 let occupant_blocked = steps
265 .iter()
266 .any(|s| s.outcome == StepOutcome::Blocked && s.from == container);
267 if occupant_blocked {
268 for s in steps.iter_mut().filter(|s| s.from != container) {
269 s.blockers.push(TopologyDiagnostic {
270 code: DiagnosticCode::ContainerOccupied,
271 severity: Severity::Error,
272 path: Some(s.from.clone()),
273 branch: Some(s.branch.clone()),
274 expected: Some(s.to.clone()),
275 message: format!(
276 "the container {} is occupied by a worktree that cannot move yet",
277 container
278 ),
279 remedy: "unblock the container's occupant first".into(),
280 });
281 s.action = MigrationAction::None;
282 s.outcome = StepOutcome::Blocked;
283 }
284 }
285 let movable = steps
286 .iter()
287 .filter(|s| s.outcome == StepOutcome::Planned)
288 .count();
289 MigrationPlan {
290 schema: SCHEMA.to_string(),
291 container,
292 blocked: steps.len() - movable,
293 movable,
294 moved: 0,
295 failed: 0,
296 steps,
297 exceptions,
298 applied: false,
299 moved_current: None,
300 }
301}
302
303pub fn apply(service: &WorktreeService, options: &MigrationOptions) -> Result<MigrationPlan> {
306 let _lock = WorktreeLock::acquire(&service.identity().git_common_dir().path)?;
307 let mut plan = plan_with(service, options.include_ephemeral)?;
308 let current = service.identity().current_worktree().clone();
309 let primary = service.identity().primary_worktree().path.clone();
310 let container = service.container().path.clone();
311
312 for step in plan.steps.iter_mut() {
313 if step.outcome != StepOutcome::Planned {
314 continue;
315 }
316 if !options.only.is_empty() && !options.only.contains(&step.branch) {
317 step.outcome = StepOutcome::Blocked;
318 step.message = Some("not selected".into());
319 continue;
320 }
321 let to = PathBuf::from(&step.to);
324 if step.action != MigrationAction::MoveViaStaging && std::fs::symlink_metadata(&to).is_ok()
325 {
326 step.outcome = StepOutcome::Failed;
327 step.message = Some(format!(
328 "{} appeared before the move could be made",
329 step.to
330 ));
331 continue;
332 }
333 if !ResolvedPath::of(&to).is_inside(service.container()) {
334 step.outcome = StepOutcome::Failed;
335 step.message = Some(format!("{} is not inside {}", step.to, display(&container)));
336 continue;
337 }
338 let from = PathBuf::from(&step.from);
339 let was_current = ResolvedPath::of(&from).same_as(¤t);
340 match execute(service, &primary, &container, step, &from, &to, options) {
341 Ok(()) => {
342 step.hooks = (step.outcome == StepOutcome::Moved)
345 .then(|| hooks::carry_hooks_path(&primary, &to));
346 if was_current {
347 plan.moved_current = Some(step.to.clone());
348 }
349 }
350 Err(e) => {
351 step.outcome = StepOutcome::Failed;
352 step.message = Some(e.to_string());
353 }
354 }
355 }
356 plan.applied = true;
357 plan.moved = plan
358 .steps
359 .iter()
360 .filter(|s| s.outcome == StepOutcome::Moved)
361 .count();
362 plan.failed = plan
363 .steps
364 .iter()
365 .filter(|s| s.outcome == StepOutcome::Failed)
366 .count();
367 plan.blocked = plan
368 .steps
369 .iter()
370 .filter(|s| s.outcome == StepOutcome::Blocked)
371 .count();
372 plan.movable = 0;
373 Ok(plan)
374}
375
376fn execute(
377 service: &WorktreeService,
378 primary: &Path,
379 container: &Path,
380 step: &mut MigrationStep,
381 from: &Path,
382 to: &Path,
383 options: &MigrationOptions,
384) -> Result<()> {
385 let before = fingerprint::capture(from, false)?;
386 step.before = Some(before.clone());
387 match step.action {
388 MigrationAction::Move => match worktree_move(primary, from, to) {
389 Ok(()) => {}
390 Err(e) if is_cross_device(&e) => {
391 if !options.allow_copy {
392 return Err(WorktreeError::MigrationUnsafe {
393 path: from.to_path_buf(),
394 reason: format!(
395 "{e}; the destination is on another filesystem, and a copy was not \
396 permitted (majordomus worktree migrate --allow-copy)"
397 ),
398 });
399 }
400 step.action = MigrationAction::CopyAndRepair;
401 let before_tree = fingerprint::capture(from, true)?;
402 step.before = Some(before_tree.clone());
403 copy_and_repair(primary, from, to)?;
404 let after_tree = fingerprint::capture(to, true)?;
405 let differences = fingerprint::differences(&before_tree, &after_tree);
406 step.after = Some(after_tree);
407 step.differences = differences.clone();
408 if !differences.is_empty() {
409 step.outcome = StepOutcome::Failed;
410 step.message = Some(format!(
411 "the copy at {} does not match the original at {}, which was kept; \
412 git points at the copy (git worktree repair {} points it back)",
413 display(to),
414 display(from),
415 display(from)
416 ));
417 return Ok(());
418 }
419 let mut identity = service.identity().clone();
422 identity.refresh()?;
423 if identity.record_at(&ResolvedPath::of(from)).is_some() {
424 return Err(WorktreeError::MigrationUnsafe {
425 path: from.to_path_buf(),
426 reason: "git still registers the original after the repair; both copies were kept".into(),
427 });
428 }
429 std::fs::remove_dir_all(from).map_err(|e| WorktreeError::io(from, &e))?;
430 step.outcome = StepOutcome::Moved;
431 step.message =
432 Some("moved by copy across filesystems, verified, original removed".into());
433 step.envrc = Some(direnv::approve(primary, to));
434 return Ok(());
435 }
436 Err(e) => return Err(e),
437 },
438 MigrationAction::MoveViaStaging => {
439 let staging = staging_path(container);
440 worktree_move(primary, from, &staging)?;
441 if std::fs::symlink_metadata(container).is_ok() {
442 return Err(WorktreeError::MigrationUnsafe {
443 path: staging,
444 reason: format!(
445 "after moving the occupant out, {} still exists; the worktree waits at the staging path and the next migration moves it home",
446 display(container)
447 ),
448 });
449 }
450 service.ensure_container()?;
451 if let Some(parent) = to.parent() {
452 std::fs::create_dir_all(parent).map_err(|e| WorktreeError::io(parent, &e))?;
453 }
454 worktree_move(primary, &staging, to)?;
455 }
456 MigrationAction::CopyAndRepair | MigrationAction::None => {
457 return Err(WorktreeError::MigrationUnsafe {
458 path: from.to_path_buf(),
459 reason: "the step was not planned as a move".into(),
460 })
461 }
462 }
463 let after = fingerprint::capture(to, false)?;
464 let differences = fingerprint::differences(&before, &after);
465 step.after = Some(after);
466 step.differences = differences.clone();
467 let mut identity = service.identity().clone();
468 identity.refresh()?;
469 let registered = identity.record_at(&ResolvedPath::of(to)).is_some();
470 if !differences.is_empty() || !registered {
471 step.outcome = StepOutcome::Failed;
472 step.message = Some(if registered {
473 format!(
474 "moved to {}, and the fingerprint differs: {}. A rename cannot lose content, so something wrote to the worktree during the move; inspect it there",
475 display(to),
476 differences.join("; ")
477 )
478 } else {
479 format!("git reported the move and does not list {}", display(to))
480 });
481 return Ok(());
482 }
483 step.outcome = StepOutcome::Moved;
484 step.envrc = Some(direnv::approve(primary, to));
488 let dangling = dangling_relative_links(to);
489 if !dangling.is_empty() {
490 step.message = Some(format!(
491 "relative symbolic links that no longer resolve from the new path: {}",
492 dangling.join(", ")
493 ));
494 }
495 Ok(())
496}
497
498fn dangling_relative_links(worktree: &Path) -> Vec<String> {
502 let Ok(entries) = std::fs::read_dir(worktree) else {
503 return Vec::new();
504 };
505 let mut out = Vec::new();
506 for entry in entries.filter_map(|e| e.ok()) {
507 let path = entry.path();
508 let Ok(meta) = std::fs::symlink_metadata(&path) else {
509 continue;
510 };
511 if !meta.file_type().is_symlink() {
512 continue;
513 }
514 let Ok(target) = std::fs::read_link(&path) else {
515 continue;
516 };
517 if target.is_relative() && !worktree.join(&target).exists() {
518 out.push(format!(
519 "{} -> {}",
520 entry.file_name().to_string_lossy(),
521 target.display()
522 ));
523 }
524 }
525 out.sort();
526 out
527}
528
529fn worktree_move(primary: &Path, from: &Path, to: &Path) -> Result<()> {
533 if let Some(parent) = to.parent() {
534 std::fs::create_dir_all(parent).map_err(|e| WorktreeError::io(parent, &e))?;
535 }
536 git::run(
537 primary,
538 &[
539 "worktree".to_string(),
540 "move".to_string(),
541 "--".to_string(),
542 absolute(from),
543 absolute(to),
544 ],
545 )?;
546 Ok(())
547}
548
549fn absolute(p: &Path) -> String {
550 if p.is_absolute() {
551 display(p)
552 } else {
553 display(
554 &std::env::current_dir()
555 .map(|c| c.join(p))
556 .unwrap_or_else(|_| p.to_path_buf()),
557 )
558 }
559}
560
561fn staging_path(container: &Path) -> PathBuf {
563 let name = container
564 .file_name()
565 .map(|n| n.to_string_lossy().to_string())
566 .unwrap_or_else(|| "worktrees".into());
567 container
568 .parent()
569 .unwrap_or(container)
570 .join(format!("{name}.migrating-{}", std::process::id()))
571}
572
573fn is_cross_device(e: &WorktreeError) -> bool {
574 match e {
575 WorktreeError::GitCommandFailed { stderr, .. } => {
576 let s = stderr.to_ascii_lowercase();
577 s.contains("cross-device") || s.contains("exdev")
578 }
579 _ => false,
580 }
581}
582
583fn copy_and_repair(primary: &Path, from: &Path, to: &Path) -> Result<()> {
586 if let Some(parent) = to.parent() {
587 std::fs::create_dir_all(parent).map_err(|e| WorktreeError::io(parent, &e))?;
588 }
589 copy_tree(from, to)?;
590 git::run(
591 primary,
592 &["worktree".to_string(), "repair".to_string(), absolute(to)],
593 )?;
594 Ok(())
595}
596
597pub fn copy_tree(from: &Path, to: &Path) -> Result<()> {
600 std::fs::create_dir(to).map_err(|e| WorktreeError::io(to, &e))?;
601 let mut stack = vec![(from.to_path_buf(), to.to_path_buf())];
602 while let Some((src, dst)) = stack.pop() {
603 let read = std::fs::read_dir(&src).map_err(|e| WorktreeError::io(&src, &e))?;
604 for entry in read {
605 let entry = entry.map_err(|e| WorktreeError::io(&src, &e))?;
606 let s = entry.path();
607 let d = dst.join(entry.file_name());
608 let meta = std::fs::symlink_metadata(&s).map_err(|e| WorktreeError::io(&s, &e))?;
609 if meta.file_type().is_symlink() {
610 let target = std::fs::read_link(&s).map_err(|e| WorktreeError::io(&s, &e))?;
611 #[cfg(unix)]
612 std::os::unix::fs::symlink(&target, &d).map_err(|e| WorktreeError::io(&d, &e))?;
613 #[cfg(not(unix))]
614 std::fs::copy(&s, &d).map_err(|e| WorktreeError::io(&d, &e))?;
615 } else if meta.is_dir() {
616 std::fs::create_dir(&d).map_err(|e| WorktreeError::io(&d, &e))?;
617 std::fs::set_permissions(&d, meta.permissions())
618 .map_err(|e| WorktreeError::io(&d, &e))?;
619 stack.push((s, d));
620 } else {
621 std::fs::copy(&s, &d).map_err(|e| WorktreeError::io(&d, &e))?;
622 std::fs::set_permissions(&d, meta.permissions())
623 .map_err(|e| WorktreeError::io(&d, &e))?;
624 }
625 }
626 }
627 Ok(())
628}