Skip to main content

git_policy_cli/worktree/
worktree_ladder_finish.rs

1//! What:
2//!  What the ladder does after real Git returned: bind the add to the directories Git made, the snapshot rung's steps (read
3//!  the new `HEAD`, snapshot, apply the tree difference, swap into place, print Git's checkout line), the clone rung's steps,
4//!  and the fall back to Git's own `reset --hard` in the bound worktree when a step cannot finish.
5//! Why:
6//!  Every step before the swap leaves the destination exactly as Git's `--no-checkout` run made it, so a failure there
7//!  completes the worktree the way Git would have. The ladder never deletes or unregisters a worktree: when the fallback
8//!  fails, or the bound objects are no longer what they were, the worktree and its registration stay exactly as they are and
9//!  one loud report names what is incomplete and the command that removes it. The only deletions are of objects this process
10//!  made and still checks: the stage it took and never exchanged. The line that Git's
11//!  own checkout would have printed is printed here, so the person cannot tell the rungs apart except by the time they take.
12//!  No hook runs on any rung: every real-Git start of the wrapper switches hooks off (`hooks_off.rs`).
13//!
14//! In TS you'd write (pseudocode):
15//! ```ts
16//! const result = await ladder.finish({ git, runGit, context, out });
17//! ```
18
19use crate::add_timing::step as timed;
20use crate::diagnostic_log::{debug, warn};
21use crate::fs_beneath::ObjectId;
22use crate::transaction_git::GitContext;
23use crate::worktree_add_binding::{Bound, bind_registration, verify_association};
24use crate::worktree_add_plan::AddPlan;
25use crate::worktree_clone::{Cloned, clone_tracked_files, leftover_report};
26use crate::worktree_copy::{GitRunner, GitStatus};
27use crate::worktree_copy_error::WorktreeCopyError;
28use crate::worktree_copy_stage_claim::StageClaim;
29use crate::worktree_ladder_run::{Ladder, LadderResult, Rung, discard_stage};
30use crate::worktree_dirty_restore::{left_out_note, record_left_out, restore_dirty};
31use crate::worktree_ladder_target::{Target, apply_difference, checkout_environment, prepare_target, run_step};
32use crate::worktree_slow_route::{SlowRoute, explain_failure};
33use crate::worktree_snapshot::{PlaceError, put_in_place, settle_displaced, take_snapshot};
34use crate::worktree_snapshot_source::{ChangedPaths, SnapshotSource};
35use crate::worktree_subvolume::settle_subvolume_destination;
36use crate::worktree_trash::{TrashManifest, dispose_pending_trash};
37use std::ffi::OsString;
38use std::io::Write;
39use std::os::unix::fs::MetadataExt;
40use std::path::{Path, PathBuf};
41
42/// Tag used for diagnostics from this module.
43const TAG: &str = "worktreeLadder";
44
45/// Why a rung ended before it finished, and whether the object at the stage name is still the snapshot this process made.
46struct Ended {
47    /// The reason, in words.
48    reason: String,
49    /// Whether the stage name holds this process's snapshot, so that destroying it destroys nothing else.
50    stage_is_ours: bool,
51}
52
53impl From<String> for Ended {
54    fn from(reason: String) -> Ended {
55        return Ended { reason, stage_is_ours: true };
56    }
57}
58
59impl From<PlaceError> for Ended {
60    fn from(error: PlaceError) -> Ended {
61        return Ended { reason: error.reason, stage_is_ours: error.stage_is_ours };
62    }
63}
64
65/// What:
66///  Everything before the swap and the swap itself; the error is the reason the rung could not finish.
67/// Why:
68///   The caller destroys the stage on an error that left the stage name holding the snapshot, and completes the worktree
69///  with Git's own checkout. `made` receives the identity of the stage as soon as it exists.
70///
71/// In TS you'd write (pseudocode):
72/// ```ts
73/// async function serveSnapshot(args): Promise<string>; // the checkout line; throws a reason
74/// ```
75fn serve_snapshot(source: &SnapshotSource, plan: &AddPlan, stage: &Path, bound: &Bound, made: &mut Option<ObjectId>, run_git: &mut GitRunner, context: &GitContext) -> Result<String, Ended> {
76    verify_association(bound)?;
77    let target: Target = timed("prepareTarget", || { return prepare_target(source, plan, context, bound); })?;
78    let trash: Option<TrashManifest> = match timed("takeSnapshot", || { return take_snapshot(source, plan, stage, &bound.admin, target.pointer.as_slice()); }) {
79        Ok(taken) => {
80            *made = Some(taken.stage);
81            taken.trash
82        }
83        Err(failure) => {
84            *made = failure.stage;
85            return Err(Ended::from(failure.reason));
86        }
87    };
88    verify_association(bound)?;
89    if source.dirty.is_some() {
90        timed("restoreDirty", || { return restore_dirty(source, &target, stage, run_git, context); })?;
91    } else {
92        timed("applyDifference", || { return apply_difference(source, &target, stage, run_git); })?;
93    }
94    let mode: u32 = std::fs::metadata(source.root.as_path()).map_err(|error| { return format!("the source's root is unreadable ({error})"); })?.mode() & 0o7777;
95    timed("exchange", || { return put_in_place(stage, plan.destination.as_path(), mode, bound.binding.destination_id); })?;
96    for note in timed("settleDisplaced", || { return settle_displaced(stage, plan.destination.as_path(), bound.binding.destination_id, target.pointer.as_slice()); }) {
97        warn(TAG, format!("cli-git: {note}").as_str());
98    }
99    // What a snapshot of the main checkout set aside is emptied by the service; nothing is set aside for a linked worktree. The
100    //  worktree is in place and complete, so a trash that cannot be emptied is reported and never fails the add.
101    //  `--quiet` silences the progress lines of the hand-over; a failure is still reported below.
102    let mut notices: Box<dyn std::io::Write> = if plan.quiet { Box::new(std::io::sink()) } else { Box::new(std::io::stderr()) };
103    if let Err(reason) = timed("disposeTrash", || { return dispose_pending_trash(context, plan.destination.as_path(), target.admin.as_path(), trash.as_ref(), &mut notices); }) {
104        warn(TAG, format!("cli-git: {reason}").as_str());
105    }
106    return Ok(target.line);
107}
108
109/// What:
110///  The clone rung: clone the tracked files into the destination, install the index, apply the tree difference.
111/// Why:
112///   The destination is filled in place; an error removes nothing: the entries the run created stay, are reported, and Git's
113///  own checkout completes the worktree and overwrites the tracked ones.
114///
115/// In TS you'd write (pseudocode):
116/// ```ts
117/// async function serveClones(args): Promise<string>; // the checkout line; throws a reason
118/// ```
119fn serve_clones(source: &SnapshotSource, plan: &AddPlan, bound: &Bound, run_git: &mut GitRunner, context: &GitContext) -> Result<String, String> {
120    verify_association(bound)?;
121    let target: Target = prepare_target(source, plan, context, bound)?;
122    let cloned: Cloned = clone_tracked_files(source, &bound.destination, &bound.admin)?;
123    let applied: Result<(), String> = verify_association(bound).and_then(|()| { return apply_difference(source, &target, plan.destination.as_path(), run_git); });
124    if let Err(reason) = applied {
125        warn(TAG, format!("cli-git: {}", leftover_report(cloned.created(), plan.destination.as_path())).as_str());
126        return Err(reason);
127    }
128    return Ok(target.line);
129}
130
131/// What:
132///  Complete the worktree the way Git does after a refused rung: `git reset --hard --no-recurse-submodules`, in the bound
133///  worktree and nowhere else.
134/// Why:
135///   Real Git ran with `--no-checkout`, so the destination holds only its `.git` file; this is Git's own checkout step.
136///  The binding is verified first and again after, so a worktree that was moved or replaced meanwhile is never reset and a
137///  success against a replacement is never taken for the add's. Git's `post-checkout` hook is not run: every real-Git start
138///  of the wrapper switches hooks off (`hooks_off.rs`).
139///
140/// In TS you'd write (pseudocode):
141/// ```ts
142/// async function checkoutLikeGit(args): Promise<GitStatus>; // failure when the binding does not hold
143/// ```
144fn checkout_like_git(plan: &AddPlan, bound: &Bound, run_git: &mut GitRunner) -> GitStatus {
145    let destination: &Path = plan.destination.as_path();
146    if let Err(reason) = verify_association(bound) {
147        warn(TAG, format!("cli-git: the checkout of {} was not started: {reason}", destination.display()).as_str());
148        return GitStatus { code: Some(1) };
149    }
150    let mut arguments: Vec<OsString> = ["-C"].iter().map(OsString::from).chain([destination.as_os_str().to_os_string()]).chain(["reset", "--hard", "--no-recurse-submodules"].iter().map(OsString::from)).collect();
151    if plan.quiet {
152        arguments.push(OsString::from("--quiet"));
153    }
154    let environment: Vec<(OsString, OsString)> = checkout_environment(bound.binding.admin.as_path(), destination);
155    let status: GitStatus = match run_step(run_git, arguments.as_slice(), environment.as_slice()) {
156        Ok(status) => status,
157        Err(reason) => {
158            warn(TAG, format!("Git's checkout of {} could not start ({reason})", destination.display()).as_str());
159            return GitStatus { code: Some(1) };
160        }
161    };
162    if let (true, Err(reason)) = (status.succeeded(), verify_association(bound)) {
163        warn(TAG, format!("cli-git: the checkout of {} ran, but it cannot be taken for the add's: {reason}", destination.display()).as_str());
164        return GitStatus { code: Some(1) };
165    }
166    return status;
167}
168
169/// What:
170///  Say what an add left, loudly and once, with the commands that finish or remove it.
171/// Why:
172///   The ladder deletes and unregisters nothing, so a worktree that could not be completed stays registered and incomplete,
173///  as Git's own interrupted add leaves it; the person is told where and how to remove it.
174///
175/// In TS you'd write (pseudocode):
176/// ```ts
177/// reportIncomplete(plan);
178/// ```
179fn report_incomplete(plan: &AddPlan) {
180    let shown: String = plan.destination.display().to_string();
181    warn(TAG, format!("cli-git: the `git worktree add` of {shown} could not be completed; the worktree and its registration were left as they are. `git -C {shown} reset --hard` retries its checkout, and `git worktree remove --force {shown}` removes it.").as_str());
182}
183
184/// What:
185///  Record that the add is complete, saying so in the log when the record cannot be written.
186/// Why:
187///   Without the marker a later command reports the add as interrupted although the worktree is whole; the report changes
188///  nothing, so a failure here is a warning and not a failure of the command.
189///
190/// In TS you'd write (pseudocode):
191/// ```ts
192/// await markComplete(claim);
193/// ```
194fn mark_complete(claim: &StageClaim, swapped: bool) {
195    let marked: Result<(), WorktreeCopyError> = timed("markComplete", || { return if swapped { claim.mark_complete_unsynced() } else { claim.mark_complete() }; });
196    if let Err(error) = marked {
197        warn(TAG, format!("the completion of the add could not be recorded ({}); a later command may report it as interrupted although it is whole", error.0).as_str());
198    }
199}
200
201/// What:
202///  Complete the worktree with Git's own checkout after a refused rung. When that fails, report what was left.
203/// Why:
204///   Nothing is removed on a failure: the worktree and its registration stay and the person gets the report.
205///
206/// In TS you'd write (pseudocode):
207/// ```ts
208/// const status = await completeLikeGit({ plan, claim, bound, runGit });
209/// ```
210fn complete_like_git(plan: &AddPlan, claim: &StageClaim, bound: &Bound, run_git: &mut GitRunner) -> GitStatus {
211    let status: GitStatus = checkout_like_git(plan, bound, run_git);
212    if status.succeeded() {
213        mark_complete(claim, false);
214    } else {
215        report_incomplete(plan);
216    }
217    return status;
218}
219
220/// What:
221///  Bind the add to the directories Git made, and write that binding to the add's record.
222/// Why:
223///   Every write below goes to the bound worktree and is checked against the binding. A binding that cannot be proven ends
224///  the ladder for this add, leaving what Git made as it is.
225///
226/// In TS you'd write (pseudocode):
227/// ```ts
228/// const bound = await bind(claim, source, plan); // throws a reason
229/// ```
230fn bind(claim: &StageClaim, source: &SnapshotSource, plan: &AddPlan) -> Result<Bound, String> {
231    let bound: Bound = timed("bindRegistration", || { return bind_registration(source.common_dir.as_path(), plan.destination.as_path()); })?;
232    if let Err(error) = timed("recordBinding", || { return claim.record_binding(&bound.binding); }) {
233        warn(TAG, format!("the binding of the add could not be recorded ({}); an interruption will be reported by path only", error.0).as_str());
234    }
235    return Ok(bound);
236}
237
238/// What:
239///  Serve the clone rung after real Git returned, or complete the worktree with Git's own checkout.
240/// Why:
241///   A failed attempt has removed what it created; the ignored state is copied by the lifecycle afterwards, as for Git's
242///  checkout, so nothing is reported as carried.
243///
244/// In TS you'd write (pseudocode):
245/// ```ts
246/// async function finishClones(args): Promise<LadderResult>;
247/// ```
248fn finish_clones(source: &SnapshotSource, plan: &AddPlan, git: GitStatus, claim: &StageClaim, run_git: &mut GitRunner, context: &GitContext, out: &mut dyn Write, slow: &mut Option<SlowRoute>) -> LadderResult {
249    if !git.succeeded() {
250        return LadderResult { git, carried: None };
251    }
252    let bound: Bound = match bind(claim, source, plan) {
253        Ok(found) => found,
254        Err(reason) => {
255            warn(TAG, format!("cli-git: {} was left as Git made it, without a checkout: {reason}", plan.destination.display()).as_str());
256            report_incomplete(plan);
257            return LadderResult { git: GitStatus { code: Some(1) }, carried: None };
258        }
259    };
260    return match serve_clones(source, plan, &bound, run_git, context) {
261        Ok(line) => {
262            debug(TAG, format!("clones served {}", plan.destination.display()).as_str());
263            mark_complete(claim, false);
264            // A closed standard output leaves nowhere to print Git's line; the exit status still carries the result.
265            let _written = out.write_all(line.as_bytes());
266            LadderResult { git, carried: None }
267        }
268        Err(reason) => {
269            debug(TAG, format!("the clone rung ended: {reason}; Git checks the worktree out").as_str());
270            // The clones failed after the cause that already sent the add this way, if any: the person hears the later one.
271            *slow = Some(explain_failure(reason.as_str()));
272            LadderResult { git: complete_like_git(plan, claim, &bound, run_git), carried: None }
273        }
274    };
275}
276
277/// What:
278///  Serve the snapshot rung after real Git returned, or complete the worktree with Git's own checkout.
279/// Why:
280///   An error that leaves the stage name holding the snapshot destroys the stage (checking its identity) and falls to Git's
281///  checkout; an error that leaves somebody else's directory there destroys nothing and ends the add as failed, with the
282///  names in the message.
283///
284/// In TS you'd write (pseudocode):
285/// ```ts
286/// async function finishSnapshot(args): Promise<LadderResult>;
287/// ```
288fn finish_snapshot(source: &SnapshotSource, plan: &AddPlan, stage: &Path, claim: &StageClaim, run_git: &mut GitRunner, context: &GitContext, out: &mut dyn Write, slow: &mut Option<SlowRoute>) -> LadderResult {
289    let bound: Bound = match bind(claim, source, plan) {
290        Ok(found) => found,
291        Err(reason) => {
292            warn(TAG, format!("cli-git: {} was left as Git made it, without a checkout: {reason}", plan.destination.display()).as_str());
293            report_incomplete(plan);
294            return LadderResult { git: GitStatus { code: Some(1) }, carried: None };
295        }
296    };
297    let mut made: Option<ObjectId> = None;
298    return match serve_snapshot(source, plan, stage, &bound, &mut made, run_git, context) {
299        Ok(line) => {
300            debug(TAG, format!("the snapshot served {}", plan.destination.display()).as_str());
301            mark_complete(claim, true);
302            // A closed standard output leaves nowhere to print Git's line; the exit status still carries the result.
303            let _written = out.write_all(line.as_bytes());
304            // A snapshot of the main checkout prints no copy summary, so it says what it did, on standard error; `--quiet` stays silent.
305            if source.git_dir == source.common_dir && !plan.quiet {
306                let _noted = std::io::stderr().write_all(crate::worktree_trash::MAIN_SNAPSHOT_NOTE.as_bytes());
307                if let Some(changed) = source.dirty.as_ref().filter(|found: &&ChangedPaths| { return found.has_changes(); }) {
308                    let _left_out = std::io::stderr().write_all(left_out_note(changed).as_bytes());
309                }
310            }
311            if let Some(changed) = source.dirty.as_ref().filter(|found: &&ChangedPaths| { return found.has_changes(); }) {
312                record_left_out(plan.destination.as_path(), changed);
313            }
314            LadderResult { git: GitStatus { code: Some(0) }, carried: Some(plan.destination.clone()) }
315        }
316        Err(ended) if ended.stage_is_ours => {
317            debug(TAG, format!("the snapshot rung ended: {}; Git checks the worktree out", ended.reason).as_str());
318            discard_stage(stage, made);
319            *slow = Some(explain_failure(ended.reason.as_str()));
320            LadderResult { git: complete_like_git(plan, claim, &bound, run_git), carried: None }
321        }
322        Err(ended) => {
323            warn(TAG, format!("cli-git: the add of {} ended in a state the wrapper cannot untangle: {}; {} is not touched", plan.destination.display(), ended.reason, stage.display()).as_str());
324            report_incomplete(plan);
325            LadderResult { git: GitStatus { code: Some(1) }, carried: None }
326        }
327    };
328}
329
330/// Release the claim on a stage name, saying so when that fails; flushing nothing when a subvolume was swapped into place.
331fn release_claim(claim: StageClaim, swapped: bool) {
332    let released: Result<(), WorktreeCopyError> = timed("releaseClaim", || { return if swapped { claim.release_unsynced() } else { claim.release() }; });
333    if let Err(error) = released {
334        warn(TAG, format!("the stage claim could not be released ({})", error.0).as_str());
335    }
336}
337
338impl Ladder {
339    /// What:
340    ///  Settle the ladder after real Git returned `git`: serve the snapshot rung or fall back to Git's checkout, or settle
341    ///  the empty subvolume of the Git rung.
342    /// Why:
343    ///   The result carries the status the person should see and the destination whose ignored state a snapshot carried. The
344    ///  claim is always released: what an add left is reported when it happens, once.
345    ///
346    /// In TS you'd write (pseudocode):
347    /// ```ts
348    /// const result = await ladder.finish({ git, runGit, context, out });
349    /// ```
350    pub fn finish(self, git: GitStatus, run_git: &mut GitRunner, context: &GitContext, out: &mut dyn Write) -> LadderResult {
351        let mut slow: Option<SlowRoute> = self.slow;
352        let result: LadderResult = match (self.rung, self.plan.as_ref()) {
353            (Rung::Snapshot(source, claim), Some(plan)) => {
354                if !git.succeeded() {
355                    release_claim(claim, false);
356                    return LadderResult { git, carried: None };
357                }
358                let stage: PathBuf = claim.container().to_path_buf();
359                let result: LadderResult = finish_snapshot(&source, plan, stage.as_path(), &claim, run_git, context, out, &mut slow);
360                // A snapshot that served the add carried its ignored state: only then was a subvolume swapped into place.
361                release_claim(claim, result.carried.is_some());
362                result
363            }
364            (Rung::Snapshot(_, claim), None) => {
365                release_claim(claim, false);
366                LadderResult { git, carried: None }
367            }
368            (Rung::Clone(source, subvolume, claim), Some(plan)) => {
369                let result: LadderResult = finish_clones(&source, plan, git, &claim, run_git, context, out, &mut slow);
370                if let Some(created) = subvolume.as_ref() {
371                    settle_subvolume_destination(created);
372                }
373                release_claim(claim, false);
374                result
375            }
376            (Rung::Clone(_, subvolume, claim), None) => {
377                if let Some(created) = subvolume.as_ref() {
378                    settle_subvolume_destination(created);
379                }
380                release_claim(claim, false);
381                LadderResult { git, carried: None }
382            }
383            (Rung::Git(subvolume), _) => {
384                if let Some(created) = subvolume.as_ref() {
385                    settle_subvolume_destination(created);
386                }
387                LadderResult { git, carried: None }
388            }
389        };
390        // Only an add that succeeded is told as a slow one; a refused or failed add has a worse thing to say.
391        if let (Some(route), Some(plan), true, None) = (slow.as_ref(), self.plan.as_ref(), result.git.succeeded(), result.carried.as_ref()) {
392            route.tell(plan);
393        }
394        return result;
395    }
396}
397
398/// Ladder finish controls stay out of the release executable.
399#[cfg(test)]
400#[path = "worktree_ladder_finish_tests.rs"]
401mod tests;