Skip to main content

git_policy_cli/worktree/
worktree_ladder_run.rs

1//! What:
2//!  The ladder around one `git worktree add` on Linux: the choice of rung before real Git runs, and, after it
3//!  returned, the snapshot rung's steps with a fall back to Git's own checkout whenever one cannot finish.
4//! Why:
5//!  `doc/planning/cli-git-btrfs-worktree.md` designs it. With a snapshot source Git runs with `--no-checkout` (branch
6//!  validation, administrative files, configuration copy and every message stay Git's), the wrapper snapshots the source
7//!  into a stage name beside the destination, makes it the worktree, applies the difference between the source's tree
8//!  and the new `HEAD` with `git read-tree -m -u`, and swaps it into place. Any failure before the swap destroys the
9//!  stage and completes the worktree the way Git does, with `git reset --hard`; the person only ever sees Git's result.
10//!
11//! In TS you'd write (pseudocode):
12//! ```ts
13//! const ladder = beginLadder(plan, environment);
14//! const git = await runGit(ladder.firstStep());
15//! const result = await ladder.finish(git, runGit);
16//! ```
17
18use crate::add_timing::{note as note_timing, step as timed};
19use crate::diagnostic_log::{debug, warn};
20use crate::fs_btrfs_ioctl::subvolume_destroy_at;
21use crate::transaction_git::GitContext;
22use crate::worktree_copy::{GitStatus, GitStep};
23use crate::worktree_subvolume::{CreatedSubvolume, prepare_subvolume_destination};
24use crate::worktree_add_plan::AddPlan;
25use crate::worktree_copy_stage_claim::{StageClaim, claim_stage_for_add};
26use crate::worktree_identity::WorktreeIdentity;
27use crate::worktree_slow_route::{SlowRoute, explain_cheap_refusal, explain_refusal, explain_unsupported};
28use crate::worktree_snapshot_source::{SnapshotSource, clone_refusal, snapshot_blocker, snapshot_refusal, source_or_dirty};
29use std::path::{Path, PathBuf};
30
31/// Tag used for diagnostics from this module.
32const TAG: &str = "worktreeLadder";
33
34/// What:
35///  Which rung the ladder chose before Git ran, with what it prepared for it.
36/// Why:
37///   The snapshot rung holds the claim on its stage name, which must be released on every path out; the rungs that leave
38///  Git or the wrapper to fill the destination prepare an empty subvolume there when they can.
39///
40/// In TS you'd write (pseudocode):
41/// ```ts
42/// type Rung = { kind: 'git'; subvolume?: CreatedSubvolume } | { kind: 'clone'; source: SnapshotSource; subvolume?: CreatedSubvolume; claim: StageClaim } | { kind: 'snapshot'; source: SnapshotSource; claim: StageClaim };
43/// ```
44pub(crate) enum Rung {
45    /// Git's own checkout, into an empty subvolume the wrapper made when it could.
46    Git(Option<CreatedSubvolume>),
47    /// Clones of the source's tracked files, Git running without a checkout first; the claim records the add durably, so
48    ///  an interrupted one is removed by the next command.
49    Clone(Box<SnapshotSource>, Option<CreatedSubvolume>, StageClaim),
50    /// A snapshot of the source, Git running without a checkout first.
51    Snapshot(Box<SnapshotSource>, StageClaim),
52}
53
54/// What:
55///  The ladder of one invocation.
56/// Why:
57///   It carries the plan and the chosen rung from before Git to after it.
58///
59/// In TS you'd write (pseudocode):
60/// ```ts
61/// type Ladder = { plan?: AddPlan; rung: Rung; slow?: SlowRoute };
62/// ```
63pub struct Ladder {
64    /// The destination plan, absent when the invocation is not a form the ladder serves.
65    pub(crate) plan: Option<AddPlan>,
66    /// The chosen rung.
67    pub(crate) rung: Rung,
68    /// Why the add will not be a snapshot, when it will not and a sentence would help; told to the person after Git ran.
69    pub(crate) slow: Option<SlowRoute>,
70}
71
72/// What:
73///  What the ladder tells the lifecycle after Git: Git's status as the person should see it, and the destination whose
74///  ignored state the rung already carried.
75/// Why:
76///   A snapshot carries the source's ignored state, so the lifecycle's own copy skips that destination.
77///
78/// In TS you'd write (pseudocode):
79/// ```ts
80/// type LadderResult = { git: GitStatus; carried?: string };
81/// ```
82#[derive(Clone, Debug, Eq, PartialEq)]
83pub struct LadderResult {
84    /// Git's status for the person.
85    pub git: GitStatus,
86    /// The destination that received the source's ignored state with the snapshot.
87    pub carried: Option<PathBuf>,
88}
89
90/// What:
91///  Choose the rung for one invocation and prepare it: a snapshot source and the claim on its stage, a clone source, or an
92///  empty subvolume at the destination for Git to fill; with the sentence that says why it is not the snapshot, when it is not.
93/// Why:
94///   Every check that can be made before Git runs is made now, cheapest first, so a refused rung costs no extra Git run and
95///  each refusal leaves its reason in the log and, in words, with the person once Git has succeeded.
96///
97/// In TS you'd write (pseudocode):
98/// ```ts
99/// function chooseRung(plan: AddPlan, context: GitContext, identity: WorktreeIdentity): { rung: Rung; slow?: SlowRoute };
100/// ```
101fn choose_rung(plan: &AddPlan, context: &GitContext, identity: &WorktreeIdentity) -> (Rung, Option<SlowRoute>) {
102    let snapshot_refusal: Option<String> = timed("refusals", || { return snapshot_refusal(identity, plan); });
103    let clone_refusal: Option<String> = timed("refusals", || { return clone_refusal(identity, plan); });
104    let reasons = |reason: &str| { debug(TAG, format!("{}: Git checks the worktree out ({reason}; snapshot: {snapshot_refusal:?}; clone: {clone_refusal:?})", plan.destination.display()).as_str()); };
105    // The cheap refusal comes first in the sentence: it is the structural one, which no commit or wait removes.
106    let cheap: Option<SlowRoute> = snapshot_refusal.as_deref().and_then(|reason: &str| { return explain_cheap_refusal(identity, reason); });
107    if snapshot_refusal.is_some() && clone_refusal.is_some() {
108        reasons("neither a snapshot nor clones apply");
109        return (Rung::Git(prepare_subvolume_destination(plan)), cheap);
110    }
111    let source: SnapshotSource = match timed("checkSource", || { return source_or_dirty(context, identity); }) {
112        Ok(found) => found,
113        Err(refusal) => {
114            reasons(refusal.reason.as_str());
115            let slow: Option<SlowRoute> = cheap.or_else(|| { return explain_refusal(context, identity, &refusal); });
116            return (Rung::Git(prepare_subvolume_destination(plan)), slow);
117        }
118    };
119    let snapshot_blocked: Option<String> = snapshot_refusal.clone().or_else(|| { return snapshot_blocker(&source); });
120    let mut slow: Option<SlowRoute> = cheap.or_else(|| { return snapshot_blocked.as_deref().map(explain_unsupported); });
121    if snapshot_blocked.is_none() {
122        match timed("claimStage", || { return claim_stage_for_add(source.common_dir.as_path(), plan.destination.as_path()); }) {
123            Ok(claim) => return (Rung::Snapshot(Box::new(source), claim), None),
124            Err(error) => {
125                debug(TAG, format!("the stage cannot be claimed ({}); no snapshot", error.0).as_str());
126                slow = Some(explain_unsupported(format!("the add could not be recorded durably ({})", error.0).as_str()));
127            }
128        }
129    } else {
130        debug(TAG, format!("no snapshot for {} ({:?})", plan.destination.display(), snapshot_blocked).as_str());
131    }
132    // Clones copy the tracked files of a source the checks proved clean; a source with changes is only ever snapshotted.
133    if clone_refusal.is_none() && source.dirty.is_none() {
134        // The record comes first: without it a killed add could not be removed, so the rung is not tried.
135        match timed("claimStage", || { return claim_stage_for_add(source.common_dir.as_path(), plan.destination.as_path()); }) {
136            Ok(claim) => return (Rung::Clone(Box::new(source), prepare_subvolume_destination(plan), claim), slow),
137            Err(error) => debug(TAG, format!("the add cannot be recorded ({}); no clones", error.0).as_str()),
138        }
139    }
140    reasons("no rung applies");
141    return (Rung::Git(prepare_subvolume_destination(plan)), slow);
142}
143
144/// What:
145///  Choose the rung for one invocation and prepare it; see `choose_rung`.
146/// Why:
147///   No plan means a form the ladder does not serve, which Git runs as the person wrote it.
148///
149/// In TS you'd write (pseudocode):
150/// ```ts
151/// function beginLadder(plan: AddPlan | undefined, context: GitContext, identity: WorktreeIdentity): Ladder;
152/// ```
153pub fn begin_ladder(plan: Option<&AddPlan>, context: &GitContext, identity: &WorktreeIdentity) -> Ladder {
154    let Some(planned) = plan else { return Ladder { plan: None, rung: Rung::Git(None), slow: None } };
155    let began: std::time::Instant = std::time::Instant::now();
156    let (rung, slow) = choose_rung(planned, context, identity);
157    let chosen: &str = match &rung {
158        Rung::Git(_) => "Git's checkout",
159        Rung::Clone(..) => "clones",
160        Rung::Snapshot(source, _claim) if source.dirty.is_some() => "a snapshot with a restore",
161        Rung::Snapshot(..) => "a snapshot",
162    };
163    debug(TAG, format!("{}: {chosen} chosen in {} ms", planned.destination.display(), began.elapsed().as_millis()).as_str());
164    note_timing("route", chosen);
165    if let Some(route) = slow.as_ref() {
166        note_timing("slowRoute", format!("{}: {}", route.word(), route.detail()).as_str());
167    }
168    return Ladder { plan: Some(planned.clone()), rung, slow };
169}
170
171/// What:
172///  Destroy the stage this process made, a subvolume or, where the mount does not allow the destroy, a tree, but only if the
173///  name still holds the object that was made.
174/// Why:
175///   A name can be taken by another process; nothing is removed that is not the object this process made, and a stage that
176///  cannot be proven is left and reported. The stage name is random and private, so the window between the check and the
177///  destroy has no other party in practice.
178///
179/// In TS you'd write (pseudocode):
180/// ```ts
181/// await discardStage(stage, made);
182/// ```
183pub(crate) fn discard_stage(stage: &Path, made: Option<crate::fs_beneath::ObjectId>) {
184    let Some(expected) = made else {
185        warn(TAG, format!("the stage {} was not identified and is left in place", stage.display()).as_str());
186        return;
187    };
188    let current = stage.parent().and_then(|parent: &Path| { return crate::fs_beneath::open_root(parent).ok(); }).and_then(|held: std::fs::File| { return crate::fs_beneath::identity_at(&held, stage.file_name()?).ok(); });
189    if current.is_none() {
190        return;
191    }
192    if current != Some(expected) {
193        warn(TAG, format!("the stage {} is not the directory this add made and is left in place", stage.display()).as_str());
194        return;
195    }
196    if subvolume_destroy_at(stage).is_ok() {
197        return;
198    }
199    if let Err(error) = crate::subvolume_remove::remove_tree(stage) {
200        warn(TAG, format!("the stage {} could not be removed ({error}); it stays for the person to remove", stage.display()).as_str());
201    }
202}
203
204impl Ladder {
205    /// What:
206    ///  The first Git run the lifecycle starts: the person's arguments, with `--no-checkout` for the snapshot rung.
207    /// Why:
208    ///   Git then validates, registers and configures the worktree, and leaves the checkout to the rung.
209    ///
210    /// In TS you'd write (pseudocode):
211    /// ```ts
212    /// ladder.firstStep(); // 'person' | { withoutCheckout: insertAt }
213    /// ```
214    pub fn first_step(&self) -> GitStep<'static> {
215        return match (&self.rung, &self.plan) {
216            (Rung::Snapshot(..) | Rung::Clone(..), Some(plan)) => GitStep::WithoutCheckout(plan.insert_at),
217            _ => GitStep::Person,
218        };
219    }
220}
221
222/// Ladder run controls stay out of the release executable.
223#[cfg(test)]
224#[path = "worktree_ladder_run_tests.rs"]
225mod tests;