monochromatic-git-policy-cli 0.6.0

Repository-owned Git policy wrapper: forwards Git commands and checks commits against configured policies.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
//! What:
//!  What the ladder does after real Git returned: bind the add to the directories Git made, the snapshot rung's steps (read
//!  the new `HEAD`, snapshot, apply the tree difference, swap into place, print Git's checkout line), the clone rung's steps,
//!  and the fall back to Git's own `reset --hard` in the bound worktree when a step cannot finish.
//! Why:
//!  Every step before the swap leaves the destination exactly as Git's `--no-checkout` run made it, so a failure there
//!  completes the worktree the way Git would have. The ladder never deletes or unregisters a worktree: when the fallback
//!  fails, or the bound objects are no longer what they were, the worktree and its registration stay exactly as they are and
//!  one loud report names what is incomplete and the command that removes it. The only deletions are of objects this process
//!  made and still checks: the stage it took and never exchanged. The line that Git's
//!  own checkout would have printed is printed here, so the person cannot tell the rungs apart except by the time they take.
//!  No hook runs on any rung: every real-Git start of the wrapper switches hooks off (`hooks_off.rs`).
//!
//! In TS you'd write (pseudocode):
//! ```ts
//! const result = await ladder.finish({ git, runGit, context, out });
//! ```

use crate::add_timing::{begin_step, end_step};
use crate::diagnostic_log::{debug, warn};
use crate::fs_beneath::ObjectId;
use crate::transaction_git::GitContext;
use crate::worktree_add_binding::{Bound, bind_registration, verify_association};
use crate::worktree_add_plan::AddPlan;
use crate::worktree_clone::{Cloned, clone_tracked_files, leftover_report};
use crate::worktree_copy::{GitRunner, GitStatus};
use crate::worktree_copy_error::WorktreeCopyError;
use crate::worktree_copy_stage_claim::StageClaim;
use crate::worktree_ladder_run::{Ladder, LadderResult, Rung, discard_stage};
use crate::worktree_dirty_restore::{left_out_note, record_left_out, restore_dirty};
use crate::worktree_ladder_target::{Target, apply_difference, checkout_environment, prepare_target, run_step};
use crate::worktree_slow_route::{SlowRoute, explain_failure};
use crate::worktree_snapshot::{PlaceError, put_in_place, settle_displaced, take_snapshot};
use crate::worktree_snapshot_source::{ChangedPaths, SnapshotSource};
use crate::worktree_subvolume::settle_subvolume_destination;
use crate::worktree_trash::{TrashManifest, dispose_pending_trash};
use std::ffi::OsString;
use std::io::Write;
use std::os::unix::fs::MetadataExt;
use std::path::{Path, PathBuf};

/// Tag used for diagnostics from this module.
const TAG: &str = "worktreeLadder";

/// Why a rung ended before it finished, and whether the object at the stage name is still the snapshot this process made.
struct Ended {
    /// The reason, in words.
    reason: String,
    /// Whether the stage name holds this process's snapshot, so that destroying it destroys nothing else.
    stage_is_ours: bool,
}

impl From<String> for Ended {
    fn from(reason: String) -> Ended {
        return Ended { reason, stage_is_ours: true };
    }
}

impl From<PlaceError> for Ended {
    fn from(error: PlaceError) -> Ended {
        return Ended { reason: error.reason, stage_is_ours: error.stage_is_ours };
    }
}

/// What:
///  Everything before the swap and the swap itself; the error is the reason the rung could not finish.
/// Why:
///   The caller destroys the stage on an error that left the stage name holding the snapshot, and completes the worktree
///  with Git's own checkout. `made` receives the identity of the stage as soon as it exists.
///
/// In TS you'd write (pseudocode):
/// ```ts
/// async function serveSnapshot(args): Promise<string>; // the checkout line; throws a reason
/// ```
fn serve_snapshot(source: &SnapshotSource, plan: &AddPlan, stage: &Path, bound: &Bound, made: &mut Option<ObjectId>, run_git: &mut dyn GitRunner, context: &GitContext) -> Result<String, Ended> {
    verify_association(bound)?;
    let prepare_target_entered: bool = begin_step("prepareTarget");
    let prepare_target_outcome = prepare_target(source, plan, context, bound);
    end_step(prepare_target_entered);
    let target: Target = prepare_target_outcome?;
    let take_snapshot_entered: bool = begin_step("takeSnapshot");
    let take_snapshot_outcome = take_snapshot(source, plan, stage, &bound.admin, target.pointer.as_slice());
    end_step(take_snapshot_entered);
    let trash: Option<TrashManifest> = match take_snapshot_outcome {
        Ok(taken) => {
            *made = Some(taken.stage);
            taken.trash
        }
        Err(failure) => {
            *made = failure.stage;
            return Err(Ended::from(failure.reason));
        }
    };
    verify_association(bound)?;
    if source.dirty.is_some() {
        let restore_dirty_entered: bool = begin_step("restoreDirty");
        let restore_dirty_outcome = restore_dirty(source, &target, stage, run_git, context);
        end_step(restore_dirty_entered);
        restore_dirty_outcome?;
    } else {
        let apply_difference_entered: bool = begin_step("applyDifference");
        let apply_difference_outcome = apply_difference(source, &target, stage, run_git);
        end_step(apply_difference_entered);
        apply_difference_outcome?;
    }
    let root_metadata = match std::fs::metadata(source.root.as_path()) {
        Ok(found) => found,
        Err(error) => return Err(Ended::from(format!("the source's root is unreadable ({error})"))),
    };
    let mode: u32 = root_metadata.mode() & 0o7777;
    let exchange_entered: bool = begin_step("exchange");
    let exchange_outcome = put_in_place(stage, plan.destination.as_path(), mode, bound.binding.destination_id);
    end_step(exchange_entered);
    exchange_outcome?;
    let settle_displaced_entered: bool = begin_step("settleDisplaced");
    let settle_displaced_outcome = settle_displaced(stage, plan.destination.as_path(), bound.binding.destination_id, target.pointer.as_slice());
    end_step(settle_displaced_entered);
    for note in settle_displaced_outcome {
        warn(TAG, format!("monochromatic-git-policy-cli: {note}").as_str());
    }
    // What a snapshot of the main checkout set aside is emptied by the service; nothing is set aside for a linked worktree. The
    //  worktree is in place and complete, so a trash that cannot be emptied is reported and never fails the add.
    //  `--quiet` silences the progress lines of the hand-over; a failure is still reported below.
    let mut notices: Box<dyn std::io::Write> = if plan.quiet { Box::new(std::io::sink()) } else { Box::new(std::io::stderr()) };
    let dispose_trash_entered: bool = begin_step("disposeTrash");
    let dispose_trash_outcome = dispose_pending_trash(context, plan.destination.as_path(), target.admin.as_path(), trash.as_ref(), &mut notices);
    end_step(dispose_trash_entered);
    if let Err(reason) = dispose_trash_outcome {
        warn(TAG, format!("monochromatic-git-policy-cli: {reason}").as_str());
    }
    return Ok(target.line);
}

/// What:
///  The paths the source has changed since its last commit, when there are any.
/// Why:
///   A snapshot carries the source's uncommitted state, so the person is told which of it was left out of the new worktree.
///
/// In TS you'd write (pseudocode):
/// ```ts
/// source.dirty?.hasChanges() ? source.dirty : undefined
/// ```
fn changes_left_out(source: &SnapshotSource) -> Option<&ChangedPaths> {
    return match source.dirty.as_ref() {
        Some(found) if found.has_changes() => Some(found),
        _ => None,
    };
}

/// What:
///  The clone rung: clone the tracked files into the destination, install the index, apply the tree difference.
/// Why:
///   The destination is filled in place; an error removes nothing: the entries the run created stay, are reported, and Git's
///  own checkout completes the worktree and overwrites the tracked ones.
///
/// In TS you'd write (pseudocode):
/// ```ts
/// async function serveClones(args): Promise<string>; // the checkout line; throws a reason
/// ```
fn serve_clones(source: &SnapshotSource, plan: &AddPlan, bound: &Bound, run_git: &mut dyn GitRunner, context: &GitContext) -> Result<String, String> {
    verify_association(bound)?;
    let target: Target = prepare_target(source, plan, context, bound)?;
    let cloned: Cloned = clone_tracked_files(source, &bound.destination, &bound.admin)?;
    let applied: Result<(), String> = match verify_association(bound) {
        Ok(()) => apply_difference(source, &target, plan.destination.as_path(), run_git),
        Err(reason) => Err(reason),
    };
    if let Err(reason) = applied {
        warn(TAG, format!("monochromatic-git-policy-cli: {}", leftover_report(cloned.created(), plan.destination.as_path())).as_str());
        return Err(reason);
    }
    return Ok(target.line);
}

/// What:
///  Complete the worktree the way Git does after a refused rung: `git reset --hard --no-recurse-submodules`, in the bound
///  worktree and nowhere else.
/// Why:
///   Real Git ran with `--no-checkout`, so the destination holds only its `.git` file; this is Git's own checkout step.
///  The binding is verified first and again after, so a worktree that was moved or replaced meanwhile is never reset and a
///  success against a replacement is never taken for the add's. Git's `post-checkout` hook is not run: every real-Git start
///  of the wrapper switches hooks off (`hooks_off.rs`).
///
/// In TS you'd write (pseudocode):
/// ```ts
/// async function checkoutLikeGit(args): Promise<GitStatus>; // failure when the binding does not hold
/// ```
fn checkout_like_git(plan: &AddPlan, bound: &Bound, run_git: &mut dyn GitRunner) -> GitStatus {
    let destination: &Path = plan.destination.as_path();
    if let Err(reason) = verify_association(bound) {
        warn(TAG, format!("monochromatic-git-policy-cli: the checkout of {} was not started: {reason}", destination.display()).as_str());
        return GitStatus { code: Some(1) };
    }
    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();
    if plan.quiet {
        arguments.push(OsString::from("--quiet"));
    }
    let environment: Vec<(OsString, OsString)> = checkout_environment(bound.binding.admin.as_path(), destination);
    let status: GitStatus = match run_step(run_git, arguments.as_slice(), environment.as_slice()) {
        Ok(status) => status,
        Err(reason) => {
            warn(TAG, format!("Git's checkout of {} could not start ({reason})", destination.display()).as_str());
            return GitStatus { code: Some(1) };
        }
    };
    if let (true, Err(reason)) = (status.succeeded(), verify_association(bound)) {
        warn(TAG, format!("monochromatic-git-policy-cli: the checkout of {} ran, but it cannot be taken for the add's: {reason}", destination.display()).as_str());
        return GitStatus { code: Some(1) };
    }
    return status;
}

/// What:
///  Say what an add left, loudly and once, with the commands that finish or remove it.
/// Why:
///   The ladder deletes and unregisters nothing, so a worktree that could not be completed stays registered and incomplete,
///  as Git's own interrupted add leaves it; the person is told where and how to remove it.
///
/// In TS you'd write (pseudocode):
/// ```ts
/// reportIncomplete(plan);
/// ```
fn report_incomplete(plan: &AddPlan) {
    let shown: String = plan.destination.display().to_string();
    warn(TAG, format!("monochromatic-git-policy-cli: 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());
}

/// What:
///  Record that the add is complete, saying so in the log when the record cannot be written.
/// Why:
///   Without the marker a later command reports the add as interrupted although the worktree is whole; the report changes
///  nothing, so a failure here is a warning and not a failure of the command.
///
/// In TS you'd write (pseudocode):
/// ```ts
/// await markComplete(claim);
/// ```
fn mark_complete(claim: &StageClaim, swapped: bool) {
    let mark_complete_entered: bool = begin_step("markComplete");
    let marked: Result<(), WorktreeCopyError> = if swapped { claim.mark_complete_unsynced() } else { claim.mark_complete() };
    end_step(mark_complete_entered);
    if let Err(error) = marked {
        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());
    }
}

/// What:
///  Complete the worktree with Git's own checkout after a refused rung. When that fails, report what was left.
/// Why:
///   Nothing is removed on a failure: the worktree and its registration stay and the person gets the report.
///
/// In TS you'd write (pseudocode):
/// ```ts
/// const status = await completeLikeGit({ plan, claim, bound, runGit });
/// ```
fn complete_like_git(plan: &AddPlan, claim: &StageClaim, bound: &Bound, run_git: &mut dyn GitRunner) -> GitStatus {
    let status: GitStatus = checkout_like_git(plan, bound, run_git);
    if status.succeeded() {
        mark_complete(claim, false);
    } else {
        report_incomplete(plan);
    }
    return status;
}

/// What:
///  Bind the add to the directories Git made, and write that binding to the add's record.
/// Why:
///   Every write below goes to the bound worktree and is checked against the binding. A binding that cannot be proven ends
///  the ladder for this add, leaving what Git made as it is.
///
/// In TS you'd write (pseudocode):
/// ```ts
/// const bound = await bind(claim, source, plan); // throws a reason
/// ```
fn bind(claim: &StageClaim, source: &SnapshotSource, plan: &AddPlan) -> Result<Bound, String> {
    let bind_registration_entered: bool = begin_step("bindRegistration");
    let bind_registration_outcome = bind_registration(source.common_dir.as_path(), plan.destination.as_path());
    end_step(bind_registration_entered);
    let bound: Bound = bind_registration_outcome?;
    let record_binding_entered: bool = begin_step("recordBinding");
    let record_binding_outcome = claim.record_binding(&bound.binding);
    end_step(record_binding_entered);
    if let Err(error) = record_binding_outcome {
        warn(TAG, format!("the binding of the add could not be recorded ({}); an interruption will be reported by path only", error.0).as_str());
    }
    return Ok(bound);
}

/// What:
///  Serve the clone rung after real Git returned, or complete the worktree with Git's own checkout.
/// Why:
///   A failed attempt has removed what it created; the ignored state is copied by the lifecycle afterwards, as for Git's
///  checkout, so nothing is reported as carried.
///
/// In TS you'd write (pseudocode):
/// ```ts
/// async function finishClones(args): Promise<LadderResult>;
/// ```
fn finish_clones(source: &SnapshotSource, plan: &AddPlan, git: GitStatus, claim: &StageClaim, run_git: &mut dyn GitRunner, context: &GitContext, out: &mut dyn Write, slow: &mut Option<SlowRoute>) -> LadderResult {
    if !git.succeeded() {
        return LadderResult { git, carried: None };
    }
    let bound: Bound = match bind(claim, source, plan) {
        Ok(found) => found,
        Err(reason) => {
            warn(TAG, format!("monochromatic-git-policy-cli: {} was left as Git made it, without a checkout: {reason}", plan.destination.display()).as_str());
            report_incomplete(plan);
            return LadderResult { git: GitStatus { code: Some(1) }, carried: None };
        }
    };
    return match serve_clones(source, plan, &bound, run_git, context) {
        Ok(line) => {
            debug(TAG, format!("clones served {}", plan.destination.display()).as_str());
            mark_complete(claim, false);
            // A closed standard output leaves nowhere to print Git's line; the exit status still carries the result.
            let _written = out.write_all(line.as_bytes());
            LadderResult { git, carried: None }
        }
        Err(reason) => {
            debug(TAG, format!("the clone rung ended: {reason}; Git checks the worktree out").as_str());
            // The clones failed after the cause that already sent the add this way, if any: the person hears the later one.
            *slow = Some(explain_failure(reason.as_str()));
            LadderResult { git: complete_like_git(plan, claim, &bound, run_git), carried: None }
        }
    };
}

/// What:
///  Serve the snapshot rung after real Git returned, or complete the worktree with Git's own checkout.
/// Why:
///   An error that leaves the stage name holding the snapshot destroys the stage (checking its identity) and falls to Git's
///  checkout; an error that leaves somebody else's directory there destroys nothing and ends the add as failed, with the
///  names in the message.
///
/// In TS you'd write (pseudocode):
/// ```ts
/// async function finishSnapshot(args): Promise<LadderResult>;
/// ```
fn finish_snapshot(source: &SnapshotSource, plan: &AddPlan, stage: &Path, claim: &StageClaim, run_git: &mut dyn GitRunner, context: &GitContext, out: &mut dyn Write, slow: &mut Option<SlowRoute>) -> LadderResult {
    let bound: Bound = match bind(claim, source, plan) {
        Ok(found) => found,
        Err(reason) => {
            warn(TAG, format!("monochromatic-git-policy-cli: {} was left as Git made it, without a checkout: {reason}", plan.destination.display()).as_str());
            report_incomplete(plan);
            return LadderResult { git: GitStatus { code: Some(1) }, carried: None };
        }
    };
    let mut made: Option<ObjectId> = None;
    return match serve_snapshot(source, plan, stage, &bound, &mut made, run_git, context) {
        Ok(line) => {
            debug(TAG, format!("the snapshot served {}", plan.destination.display()).as_str());
            mark_complete(claim, true);
            // A closed standard output leaves nowhere to print Git's line; the exit status still carries the result.
            let _written = out.write_all(line.as_bytes());
            // A snapshot of the main checkout prints no copy summary, so it says what it did, on standard error; `--quiet` stays silent.
            if source.git_dir == source.common_dir && !plan.quiet {
                let _noted = std::io::stderr().write_all(crate::worktree_trash::MAIN_SNAPSHOT_NOTE.as_bytes());
                if let Some(changed) = changes_left_out(source) {
                    let _left_out = std::io::stderr().write_all(left_out_note(changed).as_bytes());
                }
            }
            if let Some(changed) = changes_left_out(source) {
                record_left_out(plan.destination.as_path(), changed);
            }
            LadderResult { git: GitStatus { code: Some(0) }, carried: Some(plan.destination.clone()) }
        }
        Err(ended) if ended.stage_is_ours => {
            debug(TAG, format!("the snapshot rung ended: {}; Git checks the worktree out", ended.reason).as_str());
            discard_stage(stage, made);
            *slow = Some(explain_failure(ended.reason.as_str()));
            LadderResult { git: complete_like_git(plan, claim, &bound, run_git), carried: None }
        }
        Err(ended) => {
            warn(TAG, format!("monochromatic-git-policy-cli: the add of {} ended in a state the wrapper cannot untangle: {}; {} is not touched", plan.destination.display(), ended.reason, stage.display()).as_str());
            report_incomplete(plan);
            LadderResult { git: GitStatus { code: Some(1) }, carried: None }
        }
    };
}

/// Release the claim on a stage name, saying so when that fails; flushing nothing when a subvolume was swapped into place.
fn release_claim(claim: StageClaim, swapped: bool) {
    let release_claim_entered: bool = begin_step("releaseClaim");
    let released: Result<(), WorktreeCopyError> = if swapped { claim.release_unsynced() } else { claim.release() };
    end_step(release_claim_entered);
    if let Err(error) = released {
        warn(TAG, format!("the stage claim could not be released ({})", error.0).as_str());
    }
}

impl Ladder {
    /// What:
    ///  Settle the ladder after real Git returned `git`: serve the snapshot rung or fall back to Git's checkout, or settle
    ///  the empty subvolume of the Git rung.
    /// Why:
    ///   The result carries the status the person should see and the destination whose ignored state a snapshot carried. The
    ///  claim is always released: what an add left is reported when it happens, once.
    ///
    /// In TS you'd write (pseudocode):
    /// ```ts
    /// const result = await ladder.finish({ git, runGit, context, out });
    /// ```
    pub fn finish(self, git: GitStatus, run_git: &mut dyn GitRunner, context: &GitContext, out: &mut dyn Write) -> LadderResult {
        let mut slow: Option<SlowRoute> = self.slow;
        let result: LadderResult = match (self.rung, self.plan.as_ref()) {
            (Rung::Snapshot(source, claim), Some(plan)) => {
                if !git.succeeded() {
                    release_claim(claim, false);
                    return LadderResult { git, carried: None };
                }
                let stage: PathBuf = claim.container().to_path_buf();
                let result: LadderResult = finish_snapshot(&source, plan, stage.as_path(), &claim, run_git, context, out, &mut slow);
                // A snapshot that served the add carried its ignored state: only then was a subvolume swapped into place.
                release_claim(claim, result.carried.is_some());
                result
            }
            (Rung::Snapshot(_, claim), None) => {
                release_claim(claim, false);
                LadderResult { git, carried: None }
            }
            (Rung::Clone(source, subvolume, claim), Some(plan)) => {
                let result: LadderResult = finish_clones(&source, plan, git, &claim, run_git, context, out, &mut slow);
                if let Some(created) = subvolume.as_ref() {
                    settle_subvolume_destination(created);
                }
                release_claim(claim, false);
                result
            }
            (Rung::Clone(_, subvolume, claim), None) => {
                if let Some(created) = subvolume.as_ref() {
                    settle_subvolume_destination(created);
                }
                release_claim(claim, false);
                LadderResult { git, carried: None }
            }
            (Rung::Git(subvolume), _) => {
                if let Some(created) = subvolume.as_ref() {
                    settle_subvolume_destination(created);
                }
                LadderResult { git, carried: None }
            }
        };
        // Only an add that succeeded is told as a slow one; a refused or failed add has a worse thing to say.
        if let (Some(route), Some(plan), true, None) = (slow.as_ref(), self.plan.as_ref(), result.git.succeeded(), result.carried.as_ref()) {
            route.tell(plan);
        }
        return result;
    }
}

/// Ladder finish controls stay out of the release executable.
#[cfg(test)]
#[path = "worktree_ladder_finish_tests.rs"]
mod tests;