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
//! What:
//!  What follows the branch move of a landing and of a restart: the conclusion state and the index update, and the decision
//!  whether a batch a killed or failed landing left open may be replayed.
//! Why:
//!  A replay runs after the lock the landing held was given up or lost, so another program may have staged into the index since.
//!  A replay is made only over the very index file the landing saw under its lock (`service_land_start.rs`), under the lock the
//!  restart takes, and the head is checked again under that lock; every other case leaves the index as its writers left it,
//!  says so in the service's log, and finishes what does not touch the index. The live landing and the restart share the one
//!  routine that finishes a batch, so an alternate index and the conclusion state are settled the same way by both
//!  (`doc/planning/cli-git-main-commit-within-native-plus-five.md`, "A replay only over the index the landing saw").
//!
//! In TS you'd write (pseudocode):
//! ```ts
//! const done = await finishAfterMove(git, intent, lock, journal, headCheck) // true: every obligation of the batch is settled
//! ```

use crate::service_head::still_checked_out;
use crate::service_instance::instance_of;
use crate::service_journal::{INDEXED_EXTENSION, Indexed, Intent, OPEN_EXTENSION, Recovery, Started, indexed_state, list_intents, list_intents_read_only, mark_done, mark_indexed, read_start, recovery_for, remove_start};
use crate::service_land_follow::{IndexPaths, build_index, remove_conclusion_state};
use crate::service_land_start::witness_of;
use crate::service_index_lock::{IndexLock, Record, remove_stale, try_acquire};
use crate::autostash_carry::carry_stages;
use crate::service_land_context::LandContext;
use crate::service_protocol::FailureCode;
use crate::transaction_git::{GitContext, GitRequest, run_git};
use std::path::Path;

/// The index entries a batch's intent names.
pub(crate) fn intent_paths(intent: &Intent) -> IndexPaths<'_> {
    return if intent.derived { IndexPaths::Derived { old: intent.old.as_str() } } else { IndexPaths::Listed(intent.paths.as_slice()) };
}

/// What:
///  The settlement a finished step stands for: done when it finished, open when it did not.
/// Why:
///  `settle` turns the result of each step into the word the caller records.
///
/// In TS you'd write (pseudocode):
/// ```ts
/// const settledOf = (finished: boolean) => finished ? 'done' : 'open';
/// ```
fn settled_of(finished: bool) -> Settled {
    return if finished { Settled::Done } else { Settled::Open };
}

/// Removes the conclusion state a landed batch leaves, when it has one. Whether it is gone.
pub(crate) fn cleanup_done(git: &GitContext, repository: &Path, intent: &Intent) -> bool {
    return match intent.cleanup.as_ref() {
        Some(cleanup) => remove_conclusion_state(git, repository, cleanup),
        None => true,
    };
}

/// What:
///  The record that the installed index is complete, written to the batch's journal while the lock is still held.
/// Why:
///   A restart that finds this record never replays the index update over what another program staged after the lock was given up.
///
/// In TS you'd write (pseudocode):
/// ```ts
/// class RecordIndexed implements Record { record() { markIndexed(journal, intent.seq, intent.new); } }
/// ```
struct RecordIndexed<'a> {
    /// The directory of the batch's journal.
    journal: &'a Path,
    /// The batch whose index was installed.
    intent: &'a Intent,
}

impl Record for RecordIndexed<'_> {
    fn record(&self) -> std::io::Result<()> {
        return mark_indexed(self.journal, self.intent.seq, self.intent.new.as_str());
    }
}

/// Finishes what follows the branch move: the conclusion state is removed, then the index entries are updated. Both are
///  idempotent, and they are recorded apart: the installed index is recorded in the journal while the lock is still held
///  (`<batch>.indexed`), so a restart never replays the index update over what another program staged after the lock was given
///  up, whether or not the conclusion state could be removed. Whether both are done.
pub fn follow_up(git: &GitContext, repository: &Path, intent: &Intent, lock: IndexLock, journal: &Path) -> bool {
    let cleaned: bool = cleanup_done(git, repository, intent);
    if crate::service_fault::fails("build-index") {
        return false;
    }
    // The new index is built beside the real one and installed by the release of the lock the landing holds.
    let Ok(built) = build_index(git, repository, intent.new.as_str(), intent_paths(intent), &lock, intent.seq) else {
        return false;
    };
    // The files a conflicted autostash apply left unmerged are unmerged in the index the landing installs.
    if let Some(cleanup) = intent.cleanup.as_ref() {
        // Git's `update-index` may write the copy, and does not flush it.
        built.set_flushed(false);
        if !carry_stages(git, repository, cleanup, built.path()) {
            // The private copy goes with `built`, which is dropped here.
            return false;
        }
    }
    let recorded: RecordIndexed<'_> = RecordIndexed { journal, intent };
    return lock.release_flushed_with_record(built.path(), built.is_flushed(), &recorded).is_ok() && cleaned;
}

/// What:
///  Settles a batch whose branch has moved, while its caller holds the index lock: whether the worktree's head still follows the
///  move decides if the index is updated, and the conclusion state is removed in every case. Whether the batch is done.
/// Why:
///   A worktree that left the batch's branch has a default index that describes another branch, so that index is not updated;
///  an index kept in another file (`GIT_INDEX_FILE`) belongs to no branch and is still finished. A head that cannot be read
///  decides nothing: the lock is given up, the index is not touched, and the batch stays open for a restart to settle.
///
/// In TS you'd write (pseudocode):
/// ```ts
/// const done = await finishAfterMove(git, intent, lock, journal, followsMove)
/// ```
pub fn finish_after_move(git: &GitContext, intent: &Intent, lock: IndexLock, journal: &Path, follows_move: Result<bool, FailureCode>) -> bool {
    let repository: &Path = intent.repository.as_path();
    let own_index: bool = intent.index == intent.git_dir.join("index");
    return match follows_move {
        Ok(false) if own_index => {
            drop(lock);
            cleanup_done(git, repository, intent)
        }
        Ok(_) => follow_up(git, repository, intent, lock, journal),
        Err(code) => {
            drop(lock);
            crate::service_error!("batch {} stays open: the head of the worktree cannot be read ({code:?}), so the index is left as it is", intent.seq);
            false
        }
    };
}

/// What a restart makes of the evidence for replaying an open batch's index update.
#[derive(Debug, Eq, PartialEq)]
enum Gate {
    /// The index file is the one the landing saw: nothing was staged since.
    Replay,
    /// The evidence cannot be read now: the batch stays open.
    Wait,
    /// The index may hold staging: it is left as it is, for this reason.
    Leave(&'static str),
}

/// What:
///  Whether the index update of the open batch may be replayed over the index as it is now, judged under the lock the caller
///  holds.
/// Why:
///   The landing recorded the witness of the index file it saw under its lock. A file that is still that file has been written
///  by nobody since, and a replay over it discards nothing. Any other index, and a batch with no record, may hold what another
///  session staged, so no path of it is reset: whether a path holds the entry the landing started from does not say whether it
///  was staged back to that entry, and a reset also rewrites the entries under and above its path.
///
/// In TS you'd write (pseudocode):
/// ```ts
/// const gate = gateOf(journal, intent) // 'replay' | 'wait' | { leave: reason }
/// ```
fn gate_of(journal: &Path, intent: &Intent) -> Gate {
    let recorded = match read_start(journal, intent.seq) {
        Started::Absent => return Gate::Leave("no record says which index file the landing saw"),
        Started::Unreadable => return Gate::Wait,
        Started::Recorded(witness) => witness,
    };
    return match witness_of(intent.index.as_path()) {
        Ok(now) if now == recorded => Gate::Replay,
        Ok(_) => Gate::Leave("the index file is not the one the landing saw, so another program may have staged into it"),
        Err(_) => Gate::Wait,
    };
}

/// What a restart made of one open batch.
#[derive(Debug, Eq, PartialEq)]
pub(crate) enum Settled {
    /// Every obligation of the batch is settled.
    Done,
    /// Something is left, and the batch stays open.
    Open,
}

/// Settles one batch whose branch has moved, found open.
pub(crate) fn settle(git: &GitContext, intent: &Intent, journal: &Path) -> Settled {
    let repository: &Path = intent.repository.as_path();
    let own_index: bool = intent.index == intent.git_dir.join("index");
    // A worktree that has since left the batch's branch has a default index that describes another branch: it is not updated.
    //  Skipping the index is safe whatever the head does next, so this look needs no lock. A head that cannot be read stops here.
    if own_index {
        match still_checked_out(git, repository, intent.git_dir.as_path(), intent.common_dir.as_path(), intent.reference.as_str()) {
            Ok(false) => return settled_of(cleanup_done(git, repository, intent)),
            Ok(true) => {}
            Err(code) => {
                crate::service_error!("batch {} stays open: the head of the worktree cannot be read ({code:?}), so the index is left as it is", intent.seq);
                return Settled::Open;
            }
        }
    }
    match indexed_state(journal, intent.seq, intent.new.as_str()) {
        // The installed index is recorded: it is not replayed, and only the conclusion state may be left.
        Indexed::Installed => return settled_of(cleanup_done(git, repository, intent)),
        // A record that cannot be read may say the index was installed: the batch stays open and the index alone.
        Indexed::Unreadable => {
            crate::service_error!("batch {} stays open: its completion record cannot be read, so the index is left as it is", intent.seq);
            return Settled::Open;
        }
        Indexed::Absent => {}
    }
    crate::service_fault::reach("restart-unlocked");
    let Some(held) = try_acquire(intent.index.as_path(), intent.seq) else {
        return Settled::Open;
    };
    crate::service_fault::reach("restart-locked");
    // Everything the replay depends on is judged again under the lock this restart holds: the head, then the index file.
    if own_index {
        match still_checked_out(git, repository, intent.git_dir.as_path(), intent.common_dir.as_path(), intent.reference.as_str()) {
            Ok(true) => {}
            Ok(false) => {
                drop(held);
                return settled_of(cleanup_done(git, repository, intent));
            }
            Err(code) => {
                drop(held);
                crate::service_error!("batch {} stays open: the head of the worktree cannot be read under the lock ({code:?}), so the index is left as it is", intent.seq);
                return Settled::Open;
            }
        }
    }
    return match gate_of(journal, intent) {
        Gate::Replay => settled_of(follow_up(git, repository, intent, held, journal)),
        Gate::Wait => {
            drop(held);
            crate::service_error!("batch {} stays open: the record of the index file its landing saw cannot be read now", intent.seq);
            Settled::Open
        }
        Gate::Leave(reason) => {
            drop(held);
            crate::service_error!(
                "batch {}: the index entries of its paths were not updated and are left as they are, because {reason}; `git status` shows the commit's paths against the index until they are staged again",
                intent.seq
            );
            settled_of(cleanup_done(git, repository, intent))
        }
    };
}

/// What a restart did with a batch it decided on.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum Handled {
    /// The batch landed and every obligation of it is settled; it is marked done.
    Finished,
    /// The batch landed and something is left (the index lock is held, a record cannot be read); it stays open.
    StillOpen,
    /// The batch never landed; its intent and records are removed.
    Dropped,
    /// The batch looks as if it never landed, and its journal is another service's: the intent and records stay on disk.
    Left,
}

/// One thing a restart did, for the service's log.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct RecoveryReport {
    /// The batch.
    pub seq: u64,
    /// What was decided.
    pub decision: Recovery,
    /// What was done about it.
    pub handled: Handled,
}

/// Whose journal a recovery works on, which decides whether it may remove the intent of a batch that looks unlanded.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum JournalOwner {
    /// This service's own: no landing of another service is in progress in it, so an unlanded intent is dropped.
    ThisService,
    /// An earlier helper's, which may be alive: its landing writes the intent before it moves the branch, so "unlanded" may be
    ///  "about to land", and the intent stays for that helper's own start-up recovery.
    EarlierHelper,
}

/// What:
///  Finishes or drops every batch the journal holds open.
/// Why:
///   Runs before the service accepts a connection, and again before each batch (`service_server.rs`), so an index update
///  that gave up while another program held Git's index lock is finished by the next commit and not only by a restart.
///
/// In TS you'd write (pseudocode):
/// ```ts
/// await recover(context)
/// ```
pub fn recover(context: &LandContext) -> Vec<RecoveryReport> {
    return recover_selected(context, None, true, JournalOwner::ThisService);
}

/// What:
///  Finishes or drops the open batches of the worktree one instance serves, found in a journal that other worktrees' batches
///  may share.
/// Why:
///   The single service of the releases before one service per worktree kept one journal for every worktree, in the directory
///  above each instance's own, and an instance takes over the batches of its worktree there (found by the Git directory the
///  intent records, which an index outside it, as `GIT_INDEX_FILE` names one, cannot change; an intent that records none is
///  resolved from its repository). A development run may also give several instances one journal. Whatever belongs to
///  another worktree is left to its instance, and a record that cannot be read is not any instance's to remove.
///
/// In TS you'd write (pseudocode):
/// ```ts
/// await recoverInstance(context, instance)
/// ```
pub fn recover_instance(context: &LandContext, instance: &str) -> Vec<RecoveryReport> {
    return recover_selected(context, Some(instance), false, JournalOwner::ThisService);
}

/// What:
///  `recover_instance` over a journal an earlier helper may still be writing: a batch that landed is finished as before, and a
///  batch that looks as if it never landed is left on disk, never dropped.
/// Why:
///   A live landing writes its intent, then waits for the index lock, then moves the branch. A drop in that window removes the
///  record of a commit that lands a moment later, and the person's index shows it reverted if the helper dies before it
///  installs the index. A finish is safe against a live landing: it never moves the branch, its index replay needs the lock and
///  the records the landing writes, and two finishes end in one `.done`. The earlier helper drops what is still unlanded when it
///  starts again.
///
/// In TS you'd write (pseudocode):
/// ```ts
/// await recoverInstanceWithoutDrops(context, instance)
/// ```
pub fn recover_instance_without_drops(context: &LandContext, instance: &str) -> Vec<RecoveryReport> {
    return recover_selected(context, Some(instance), false, JournalOwner::EarlierHelper);
}

/// The exit status `git rev-parse --verify --quiet` gives for a name that is no object: the one answer that means "not there".
const NAME_IS_NO_OBJECT: i32 = 1;

/// The exit status `git merge-base --is-ancestor` gives when the first commit is not below the second.
const NOT_AN_ANCESTOR: i32 = 1;

/// What:
///  The value of `reference` in the repository: its commit name, an empty name when Git says for certain that the reference is
///  not there, and nothing when Git gave no answer.
///  `Option<String>` is "an answer or none".
/// Why:
///   A restart drops the intent of a batch whose branch never moved. A Git that could not be started, or that failed in any way
///  other than "no such reference" (a lock, a stuck mount, a repository that cannot be read), has not said the branch is
///  unmoved, so the caller keeps the batch open. `--quiet` makes a missing reference exit 1 with nothing on the standard error;
///  every other failure exits 128, and a reference file Git cannot read exits 1 with a warning, which is not "missing" either.
///
/// In TS you'd write (pseudocode):
/// ```ts
/// async function currentValue(git, intent): Promise<string | undefined> // '' for a missing reference
/// ```
fn current_value(git: &GitContext, intent: &Intent) -> Option<String> {
    let request: GitRequest = GitRequest::new(intent.repository.as_path(), &["rev-parse", "--verify", "--quiet", intent.reference.as_str()]);
    let Ok(output) = run_git(git, &request) else {
        return None;
    };
    if output.succeeded() {
        return Some(String::from_utf8_lossy(output.stdout.as_slice()).trim().to_string());
    }
    if output.code == Some(NAME_IS_NO_OBJECT) && output.stderr.is_empty() {
        return Some(String::new());
    }
    return None;
}

/// What:
///  Whether the commit the batch landed is `current` or below it; nothing when Git gave no answer.
/// Why:
///   Exit status 1 is Git's "not below"; any other failure (an object Git cannot find, a lock, a stuck mount) is no answer, and
///  a batch is not dropped on no answer.
///
/// In TS you'd write (pseudocode):
/// ```ts
/// async function landedBelow(git, intent, current): Promise<boolean | undefined>
/// ```
fn landed_below(git: &GitContext, intent: &Intent, current: &str) -> Option<bool> {
    let request: GitRequest = GitRequest::new(intent.repository.as_path(), &["merge-base", "--is-ancestor", intent.new.as_str(), current]);
    let Ok(output) = run_git(git, &request) else {
        return None;
    };
    if output.succeeded() {
        return Some(true);
    }
    return if output.code == Some(NOT_AN_ANCESTOR) { Some(false) } else { None };
}

/// What:
///  The decision for an open batch from the branch as Git reports it now, or nothing when Git gave no answer.
/// Why:
///   The question about the ancestry is asked only when it decides: a branch at the old or the new value, or one that is gone,
///  settles the batch without it.
///
/// In TS you'd write (pseudocode):
/// ```ts
/// async function decisionFor(git, intent): Promise<Recovery | undefined>
/// ```
fn decision_for(git: &GitContext, intent: &Intent) -> Option<Recovery> {
    let current: String = current_value(git, intent)?;
    let decided_without_ancestry: bool = current.is_empty() || current == intent.old || current == intent.new;
    let ancestor: bool = if decided_without_ancestry { false } else { landed_below(git, intent, current.as_str())? };
    return Some(recovery_for(intent, current.as_str(), ancestor));
}

fn recover_selected(context: &LandContext, instance: Option<&str>, remove_unreadable: bool, owner: JournalOwner) -> Vec<RecoveryReport> {
    let mut reports: Vec<RecoveryReport> = Vec::new();
    let listed: Vec<(std::path::PathBuf, Option<Intent>)> = match owner {
        JournalOwner::ThisService => list_intents(context.journal.as_path(), OPEN_EXTENSION),
        JournalOwner::EarlierHelper => list_intents_read_only(context.journal.as_path(), OPEN_EXTENSION),
    };
    for (path, parsed) in listed {
        let Some(intent) = parsed else {
            if remove_unreadable {
                let _removed = std::fs::remove_file(&path);
            }
            continue;
        };
        // With an instance named, only the batches of that instance's worktree are recovered.
        let selected: bool = match instance {
            Some(named) => instance_of(intent.git_dir.as_path()) == named,
            None => true,
        };
        if !selected {
            continue;
        }
        let git: GitContext = GitContext { common_dir: Some(intent.common_dir.clone()), ..context.git.clone() };
        let Some(decision) = decision_for(&git, &intent) else {
            crate::service_error!("batch {} stays open: Git gave no answer about {} in {}, so its intent is kept", intent.seq, intent.reference, intent.repository.display());
            continue;
        };
        let handled: Handled = match (decision, owner) {
            (Recovery::Drop, JournalOwner::EarlierHelper) => Handled::Left,
            (Recovery::Drop, JournalOwner::ThisService) => {
                remove_stale(intent.index.as_path(), intent.seq);
                let _removed = std::fs::remove_file(&path);
                let _record_removed = std::fs::remove_file(crate::service_journal::batch_path(context.journal.as_path(), intent.seq, INDEXED_EXTENSION));
                remove_start(context.journal.as_path(), intent.seq);
                Handled::Dropped
            }
            (Recovery::Finish, _) => {
                remove_stale(intent.index.as_path(), intent.seq);
                if matches!(settle(&git, &intent, context.journal.as_path()), Settled::Done) {
                    let _marked = mark_done(context.journal.as_path(), intent.seq);
                    Handled::Finished
                } else {
                    Handled::StillOpen
                }
            }
        };
        reports.push(RecoveryReport { seq: intent.seq, decision, handled });
    }
    return reports;
}