Skip to main content

majordomus_cli/worktree/
migrate.rs

1//! Bringing misplaced worktrees home, transactionally and losslessly.
2//!
3//! A plan is computed from the topology and changes nothing. Applying it recomputes the plan
4//! under the repository lock, then for each movable step takes a
5//! [`WorktreeFingerprint`] of the worktree, asks git to move it, takes another at the
6//! destination and refuses to call the step a success unless the two are equal. A dirty
7//! worktree is not an error: it is the case this exists for, and `git worktree move` moves
8//! the directory — modified, staged and untracked files included — with one rename.
9//!
10//! Two shapes need more than a rename. A worktree that occupies the container path itself
11//! is moved out to a staging path beside the container, the container is created, and the
12//! worktree is moved into it. A move that crosses filesystems, which `rename` cannot do, is
13//! made by copying the tree, repairing git's administrative link, verifying the copy —
14//! including a manifest of every entry of the directory tree — and only then removing the
15//! original. Nothing is ever reset, stashed, cleaned or checked out.
16
17use 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/// How a step moves its worktree.
35#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
36#[serde(rename_all = "snake_case")]
37pub enum MigrationAction {
38    /// One `git worktree move`.
39    Move,
40    /// The worktree is the container itself: out to a staging path, then into the container.
41    MoveViaStaging,
42    /// The move crossed devices: copy, `git worktree repair`, verify, remove the original.
43    CopyAndRepair,
44    /// Nothing: the step is blocked.
45    None,
46}
47
48/// What became of a step.
49#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
50#[serde(rename_all = "snake_case")]
51pub enum StepOutcome {
52    /// Planned and not applied.
53    Planned,
54    /// Not carried out, with the blockers on the step.
55    Blocked,
56    /// Moved, and the fingerprint after equals the one before.
57    Moved,
58    /// Attempted and not completed, or completed and not verified; the message says which.
59    Failed,
60}
61
62/// One worktree to bring home.
63#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
64pub struct MigrationStep {
65    /// The branch it holds.
66    pub branch: String,
67    /// Where it is.
68    pub from: String,
69    /// Where it belongs.
70    pub to: String,
71    #[serde(default, skip_serializing_if = "Option::is_none")]
72    /// The commit checked out.
73    pub head: Option<String>,
74    /// How it moves.
75    pub action: MigrationAction,
76    /// Its uncommitted work, which moves with it.
77    pub dirty: DirtyState,
78    /// Why it cannot move, when it cannot.
79    pub blockers: Vec<TopologyDiagnostic>,
80    /// What happened.
81    pub outcome: StepOutcome,
82    #[serde(default, skip_serializing_if = "Option::is_none")]
83    /// What happened, in words, when it was not simply moved.
84    pub message: Option<String>,
85    #[serde(default, skip_serializing_if = "Option::is_none")]
86    /// The fingerprint before the move.
87    pub before: Option<WorktreeFingerprint>,
88    #[serde(default, skip_serializing_if = "Option::is_none")]
89    /// The fingerprint after it.
90    pub after: Option<WorktreeFingerprint>,
91    /// What differs between the two; empty when verified.
92    pub differences: Vec<String>,
93    #[serde(default, skip_serializing_if = "Option::is_none")]
94    /// What became of its `.envrc` under direnv once it was at its new path: the primary
95    /// checkout's approval carried there, or why it was not. Only on a moved step.
96    pub envrc: Option<EnvrcApproval>,
97    #[serde(default, skip_serializing_if = "Option::is_none")]
98    /// Which hooks it runs at its new path: a per-worktree `core.hooksPath` names the old
99    /// one, so a moved worktree is bound again to its own tree's (I2718). Only on a moved
100    /// step.
101    pub hooks: Option<HooksPath>,
102}
103
104/// A migration, planned or applied.
105#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
106pub struct MigrationPlan {
107    /// [`SCHEMA`].
108    pub schema: String,
109    /// The container everything moves into.
110    pub container: String,
111    /// One step per misplaced worktree with a branch, container occupants first.
112    pub steps: Vec<MigrationStep>,
113    /// Worktrees the migration cannot address by design: detached, missing, the primary
114    /// checkout off the trunk. Each says what a person does about it.
115    pub exceptions: Vec<TopologyDiagnostic>,
116    /// Steps that can be carried out as things stand.
117    pub movable: usize,
118    /// Steps that cannot.
119    pub blocked: usize,
120    /// Steps carried out and verified.
121    pub moved: usize,
122    /// Steps attempted and not verified.
123    pub failed: usize,
124    /// Whether anything was changed.
125    pub applied: bool,
126    #[serde(default, skip_serializing_if = "Option::is_none")]
127    /// The new path of the worktree the command was run from, when that one moved.
128    pub moved_current: Option<String>,
129}
130
131/// What an apply may do beyond a rename.
132#[derive(Debug, Clone, Default)]
133pub struct MigrationOptions {
134    /// Fall back to copy, repair, verify and remove when a move crosses devices.
135    pub allow_copy: bool,
136    /// Only these branches.
137    pub only: Vec<String>,
138    /// Also move the scratch checkouts of sessions (`Standing::Ephemeral`), which are
139    /// otherwise reported and left alone.
140    pub include_ephemeral: bool,
141}
142
143/// The plan: every misplaced worktree with a branch, and what stands in its way. Changes
144/// nothing. Ephemeral worktrees are exceptions, not steps.
145pub fn plan(service: &WorktreeService) -> Result<MigrationPlan> {
146    plan_with(service, false)
147}
148
149/// The plan, optionally counting ephemeral worktrees as steps.
150///
151/// A plan states the uncommitted work of the work trees it moves, and of no others — every
152/// other line of it is read out of a standing, which costs no subprocess. So the topology
153/// is read without the per-work-tree `git status` first, and measured only when that
154/// reading shows there is a step to carry work. On a repository whose work trees are all
155/// where they belong that is the difference between 129 `git status` subprocesses and
156/// none, and the Cockpit's worktree page renders this beside the topology: it was paying
157/// for the same measurement twice.
158pub 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
172/// Is a work tree with this standing one the migration would move?
173fn 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
181/// The plan a topology implies.
182///
183/// Pure: every step, every exception and every blocker is read out of the work trees the
184/// topology already judged, and the container it names is the topology's own. The
185/// migration plan is a projection of the topology and of nothing else, and this is the one
186/// place that is written.
187pub 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    // The container's occupant moves first: every other destination is inside the
261    // container, and while a worktree *is* the container those destinations would be
262    // created inside that worktree. If it cannot move, nothing can.
263    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
303/// Carry out the plan. Recomputed under the lock, never replayed from a plan the caller is
304/// holding: the tree may have changed since it was printed.
305pub 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        // Prove the destination is still free immediately before moving: planning and
322        // applying are not atomic with respect to anything outside this lock.
323        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(&current);
340        match execute(service, &primary, &container, step, &from, &to, options) {
341            Ok(()) => {
342                // A per-worktree `core.hooksPath` names the old path: whichever way it got
343                // there, a moved worktree is bound again to its own tree's hooks (I2718).
344                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                // verified: the original is now an unregistered duplicate, and this is the one
420                // place a directory is deleted — after the proof, never before it
421                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    // A moved `.envrc` is a new path to direnv, and the approval it had is gone with the old
485    // one; carry it, and say what happened beside the fingerprint that says the move was
486    // faithful.
487    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
498/// Top-level symbolic links of a worktree whose relative target does not resolve any more:
499/// a `node_modules -> ../sibling/node_modules` written for the old path. Reported, never
500/// rewritten; the link is the person's and the move did not change it.
501fn 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
529/// `git worktree move`, with absolute paths, from the primary checkout. Git resolves a
530/// relative path against its working directory and matches a bare name against the suffix
531/// of any registered worktree; absolute paths are the only form that says what it means.
532fn 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
561/// Where the container's occupant waits between its two moves.
562fn 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
583/// Copy the tree, point git at the copy, and leave the original in place for the caller to
584/// verify against and remove.
585fn 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
597/// A faithful copy: directories, files with their permissions, symbolic links as links.
598/// Nothing is followed and nothing is skipped.
599pub 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}