Skip to main content

git_policy_cli/
service_land.rs

1//! What:
2//!  The service's serialized step: land a batch of prepared commits on a branch, and finish the batches a killed
3//!  service left open.
4//! Why:
5//!  Everything that must happen one commit at a time happens here, in a fixed order, with one durable write: the objects
6//!  of the whole batch are written by one `fast-import`, the intent is journaled, the branch moves by one compare-and-swap,
7//!  the index entries of the named paths are updated, and the intent is marked done
8//!  (`doc/planning/cli-git-resident-service.md`, "The serialized step, group commit, FIFO and backpressure").
9//!
10//! In TS you'd write (pseudocode):
11//! ```ts
12//! const outcomes = await landBatch(context, seq, requests);
13//! ```
14
15use super::journald_native::Priority;
16use super::service_fault::reach;
17use super::service_head::head_names;
18use super::service_instance::instance_of;
19use super::service_pruned::batch_content_missing;
20use super::service_journal::{Intent, Recovery, list_intents, mark_done, recovery_for, write_intent, OPEN_EXTENSION};
21use super::service_index_lock::{Announce, IndexLock, acquire_noting_wait, remove_stale, try_acquire};
22use super::service_timing::{LandTiming, wait_line};
23use super::service_land_follow::{IndexPaths, build_index, classify, remove_conclusion_state, storage_failure};
24use super::service_protocol::{CommitRequest, FailureCode, is_zero_object};
25use super::transaction_git::{GitContext, GitOutput, GitRequest, run_git};
26use std::collections::{BTreeSet, HashMap};
27use std::path::{Path, PathBuf};
28use std::time::Instant;
29
30/// How many times a batch is built again when the branch moved under it before the failure is reported.
31pub const LANDING_ATTEMPTS: usize = 3;
32
33/// What the service needs to land: how to run Git and where the journal lives.
34pub struct LandContext {
35    /// How to run the real Git, with the bounds of `child_class.rs`.
36    pub git: GitContext,
37    /// Directory of the journal.
38    pub journal: PathBuf,
39    /// Test seam: called after the batch's objects exist and before the branch moves.
40    pub on_objects_written: Option<Box<dyn Fn() + Send + Sync>>,
41    /// How long a landing waits for another program that holds the index lock before it gives up with nothing changed.
42    pub index_wait: std::time::Duration,
43}
44
45/// What became of one request of a batch.
46#[derive(Clone, Debug, Eq, PartialEq)]
47pub enum Outcome {
48    /// The commit is on the branch.
49    Landed { commit: String },
50    /// A named path changed on the branch since the request's base.
51    RetryPrepare { base: String },
52    /// An amend, whose parents are the base's parents, found the branch somewhere else than at its base.
53    BranchMoved,
54}
55
56fn run(git: &GitContext, repository: &Path, arguments: &[&str], input: Option<Vec<u8>>) -> Result<GitOutput, FailureCode> {
57    let mut request: GitRequest = GitRequest::new(repository, arguments);
58    request.input = input;
59    let output: GitOutput = run_git(git, &request).map_err(|_| { return FailureCode::Unavailable; })?;
60    return Ok(output);
61}
62
63fn checked(git: &GitContext, repository: &Path, arguments: &[&str], input: Option<Vec<u8>>) -> Result<String, FailureCode> {
64    let output: GitOutput = run(git, repository, arguments, input)?;
65    if !output.succeeded() {
66        return Err(classify(&output));
67    }
68    return Ok(String::from_utf8_lossy(output.stdout.as_slice()).trim().to_string());
69}
70
71/// A path in the quoted form `fast-import` reads, so no byte of a name can end the line or the field.
72fn quoted(path: &str) -> String {
73    let mut text: String = String::from("\"");
74    for character in path.chars() {
75        match character {
76            '"' => text.push_str("\\\""),
77            '\\' => text.push_str("\\\\"),
78            other => text.push(other),
79        }
80    }
81    text.push('"');
82    return text;
83}
84
85/// Whether two repository paths touch the same file or one contains the other.
86fn overlaps(path: &str, changed: &str) -> bool {
87    return path == changed || path.starts_with(&format!("{changed}/")) || changed.starts_with(&format!("{path}/"));
88}
89
90/// The stream that makes one commit per request on a scratch branch that starts at `old`, or, for an amend, one commit
91///  whose parents are `amended_parents` and whose whole tree is the request's tree.
92fn import_stream(scratch: &str, old: &str, requests: &[&CommitRequest], amended_parents: &[String]) -> Vec<u8> {
93    let mut bytes: Vec<u8> = Vec::new();
94    for (index, request) in requests.iter().enumerate() {
95        bytes.extend(format!("commit {scratch}\nmark :{}\n", index + 1).as_bytes());
96        if let Some(author) = &request.author {
97            bytes.extend(format!("author {author}\n").as_bytes());
98        }
99        bytes.extend(format!("committer {}\ndata {}\n", request.committer, request.message.len()).as_bytes());
100        bytes.extend(request.message.as_bytes());
101        bytes.push(b'\n');
102        if let Some(tree) = request.tree.as_deref().filter(|_| { return request.amend; }) {
103            // An amend replaces the tip: its parents are the base's parents, and its tree is the one the client wrote.
104            for (position, parent) in amended_parents.iter().enumerate() {
105                bytes.extend(format!("{} {parent}\n", if position == 0 { "from" } else { "merge" }).as_bytes());
106            }
107            bytes.extend(format!("M 040000 {tree} \"\"\n").as_bytes());
108            bytes.push(b'\n');
109            continue;
110        }
111        if index == 0 {
112            bytes.extend(format!("from {old}\n").as_bytes());
113        }
114        for change in &request.changes {
115            match &change.blob {
116                Some(blob) => bytes.extend(format!("M {:o} {blob} {}\n", change.mode, quoted(change.path.as_str())).as_bytes()),
117                None => bytes.extend(format!("D {}\n", quoted(change.path.as_str())).as_bytes()),
118            }
119        }
120        bytes.push(b'\n');
121    }
122    bytes.extend(b"done\n");
123    return bytes;
124}
125
126/// The object names `fast-import` recorded for the marks, in request order.
127fn commits_from_marks(text: &str, count: usize) -> Option<Vec<String>> {
128    let mut commits: Vec<Option<String>> = vec![None; count];
129    for line in text.lines() {
130        let mut parts = line.split(' ');
131        let (Some(mark), Some(name)) = (parts.next(), parts.next()) else { continue };
132        let slot: usize = mark.trim_start_matches(':').parse::<usize>().ok()?;
133        if slot >= 1 && slot <= count {
134            commits[slot - 1] = Some(name.to_string());
135        }
136    }
137    return commits.into_iter().collect();
138}
139
140/// The index entries a batch's intent names.
141fn intent_paths(intent: &Intent) -> IndexPaths<'_> {
142    return if intent.derived { IndexPaths::Derived { old: intent.old.as_str() } } else { IndexPaths::Listed(intent.paths.as_slice()) };
143}
144
145/// Finishes what follows the branch move: the conclusion state is removed, then the index entries are updated. Both are
146///  idempotent. Whether both are done.
147fn follow_up(git: &GitContext, repository: &Path, intent: &Intent, lock: IndexLock) -> bool {
148    let cleaned: bool = intent.cleanup.as_ref().is_none_or(|cleanup| { return remove_conclusion_state(git, repository, cleanup); });
149    // The new index is built beside the real one and installed by the release of the lock the landing holds.
150    let Ok(built) = build_index(git, repository, intent.new.as_str(), intent_paths(intent), intent.index.as_path(), intent.seq) else {
151        return false;
152    };
153    return lock.release_with(built.as_path()).is_ok() && cleaned;
154}
155
156/// What:
157///  Lands a batch of requests that share a repository, a common git directory and a branch.
158/// Why:
159///   Requests whose paths changed on the branch since their base are sent back to be prepared again; the others become
160///  one commit each, in order, on the branch, with one journal write and one branch update for all of them.
161///
162/// In TS you'd write (pseudocode):
163/// ```ts
164/// await landBatch(context, seq, requests) // one outcome per request, in order
165/// ```
166pub fn land_batch(context: &LandContext, seq: u64, requests: &[CommitRequest]) -> Result<Vec<Outcome>, FailureCode> {
167    return land_batch_announcing(context, seq, requests, &|_| {});
168}
169
170/// `land_batch`, telling the clients of the batch what it waits for when another program holds the index.
171pub fn land_batch_announcing(context: &LandContext, seq: u64, requests: &[CommitRequest], announce: &dyn Fn(Announce)) -> Result<Vec<Outcome>, FailureCode> {
172    return land_batch_timed(context, seq, requests, announce, &mut LandTiming::default());
173}
174
175/// What:
176///  `land_batch_announcing`,
177/// leaving in `timing` the time each phase of the landing took.
178/// Why:
179///   The service's log line for each request states the wait for another program's hold on the index,
180/// the objects,
181/// the journal
182///  write,
183/// the branch move and the index update;
184/// `timing` is filled as the phases end,
185/// so a landing that fails part way still
186///  reports the phases it reached.
187///
188/// In TS you'd write (pseudocode):
189/// ```ts
190/// await landBatchTimed(context, seq, requests, announce, timing)
191/// ```
192///
193/// # Errors
194///
195/// The failure code of the landing,
196/// as `land_batch_announcing`.
197pub fn land_batch_timed(
198    context: &LandContext,
199    seq: u64,
200    requests: &[CommitRequest],
201    announce: &dyn Fn(Announce),
202    timing: &mut LandTiming,
203) -> Result<Vec<Outcome>, FailureCode> {
204    let first: &CommitRequest = requests.first().ok_or(FailureCode::Rejected)?;
205    if requests.iter().any(|request| {
206        return request.repository != first.repository || request.common_dir != first.common_dir || request.reference != first.reference;
207    }) {
208        return Err(FailureCode::Rejected);
209    }
210    // An amend, and a commit built elsewhere, are landed alone: each is only valid while the branch is still at its base,
211    //  which a neighbour in the same batch would change.
212    let alone: bool = requests.iter().any(CommitRequest::lands_alone);
213    if alone && requests.len() != 1 {
214        return Err(FailureCode::Rejected);
215    }
216    let repository: &Path = first.repository.as_path();
217    let git: GitContext = GitContext { common_dir: Some(first.common_dir.clone()), ..context.git.clone() };
218    let scratch: String = format!("refs/worktree/cli-git-service/{seq}");
219    let mut outcomes: Vec<Outcome>;
220    let mut attempts_left: usize = LANDING_ATTEMPTS;
221    // A branch that does not exist yet is prepared against the all-zero name and lands only while it still does not.
222    let unborn: bool = is_zero_object(first.base.as_str());
223    // Git's own order: the index lock is taken before the branch is read or moved and given up when the new index is in place,
224    //  so a foreign `git` that holds it finishes first and this landing builds on what it left. A lock that is not given up
225    //  here (a refusal, a failure) is removed when it goes out of scope, and the index stays as it was.
226    let waiting_since: Instant = Instant::now();
227    let taken = acquire_noting_wait(first.index.as_path(), seq, context.index_wait, announce, &mut timing.lock_notes);
228    timing.lock_wait = waiting_since.elapsed();
229    timing.lock_holder.clone_from(&timing.lock_notes.holder);
230    // The wait is written down when it ends, not with the answer, so a landing that fails later still leaves it.
231    if let Some(line) = wait_line(seq, timing.lock_wait, &timing.lock_notes, taken.is_ok()) {
232        // A lock that was refused fails the commit, so the line stands out from a wait that ended in the lock.
233        let priority: Priority = if taken.is_ok() { Priority::Info } else { Priority::Error };
234        crate::service_timing::log_entry(priority, line.as_str(), &[("CLI_GIT_BATCH", seq.to_string())]);
235    }
236    let mut lock: Option<IndexLock> = Some(taken.map_err(|failure| { return failure.code; })?);
237    loop {
238        timing.attempts += 1;
239        let old: String = match checked(&git, repository, &["rev-parse", "--verify", "--quiet", first.reference.as_str()], None) {
240            Ok(found) => found,
241            Err(_) if unborn => first.base.clone(),
242            Err(code) => return Err(code),
243        };
244        if alone && first.base != old {
245            return Ok(vec![Outcome::BranchMoved]);
246        }
247        // Git's own compare does not refuse a symbolic `HEAD` when it is told not to follow it (measured: it replaces the
248        //  symbolic reference), so a head that became a branch since the commit was prepared is found here, before the move.
249        if first.reference == "HEAD" && run(&git, repository, &["symbolic-ref", "--quiet", "HEAD"], None)?.succeeded() {
250            return Ok(vec![Outcome::BranchMoved]);
251        }
252        // Native Git updates `HEAD`, so a worktree that switched branches while the commit waited fails the commit with nothing
253        //  changed. The named branch would move all the same, and the index built for it would replace the other branch's.
254        if first.reference != "HEAD" && !head_names(&git, repository, first.index.as_path(), first.common_dir.as_path(), first.reference.as_str())? {
255            return Ok(vec![Outcome::BranchMoved; requests.len()]);
256        }
257        let amended_parents: Vec<String> = if alone && !unborn {
258            let listing: String = checked(&git, repository, &["rev-list", "--parents", "--max-count=1", first.base.as_str()], None)?;
259            listing.split_whitespace().skip(1).map(str::to_string).collect()
260        } else {
261            Vec::new()
262        };
263        // One tree comparison per distinct base: the paths that changed on the branch since then.
264        let mut changed_since: HashMap<&str, BTreeSet<String>> = HashMap::new();
265        let mut landing: Vec<(usize, &CommitRequest)> = Vec::new();
266        outcomes = vec![Outcome::RetryPrepare { base: old.clone() }; requests.len()];
267        for (index, request) in requests.iter().enumerate() {
268            if request.base != old && !changed_since.contains_key(request.base.as_str()) {
269                let listing: String = checked(&git, repository, &["diff-tree", "-r", "--name-only", "-z", request.base.as_str(), old.as_str()], None)?;
270                changed_since.insert(request.base.as_str(), listing.split('\0').filter(|name| { return !name.is_empty(); }).map(str::to_string).collect());
271            }
272            let conflicts: bool = request.base != old
273                && request.changes.iter().any(|change| {
274                    return changed_since.get(request.base.as_str()).is_some_and(|names| { return names.iter().any(|name| { return overlaps(change.path.as_str(), name.as_str()); }); });
275                });
276            // A member of this batch that an earlier member overlaps would replace that member's bytes in the stream below. It
277            //  stays in the batch only when it captured the worktree (a batch has one) later than that member, so its bytes
278            //  hold the member's edit; any other request is sent back and merged against the member's landed commit.
279            let shadowed: bool = landing.iter().any(|(_, earlier)| {
280                let later_capture: bool = earlier.captured != 0 && request.captured > earlier.captured;
281                return !later_capture && request.changes.iter().any(|change| { return earlier.changes.iter().any(|member| { return overlaps(change.path.as_str(), member.path.as_str()); }); });
282            });
283            if !conflicts && !shadowed {
284                landing.push((index, request));
285            }
286        }
287        if landing.is_empty() {
288            return Ok(outcomes);
289        }
290        let batch: Vec<&CommitRequest> = landing.iter().map(|(_, request)| { return *request; }).collect();
291        // Content that `git gc --prune=now` removed after the preparation is found before anything is imported or moved: the
292        //  client prepares the commit again, and says so plainly when that is not enough.
293        if batch_content_missing(first.common_dir.as_path(), batch.as_slice()) {
294            return Err(FailureCode::Unavailable);
295        }
296        let objects_began: Instant = Instant::now();
297        let (commits, new): (Vec<String>, String) = if let Some(built) = first.commit.as_deref() {
298            // A commit Git built elsewhere lands as it is; it must be a commit with the parents this request's rule gives:
299            //  an amend has the replaced commit's parents, a commit on a branch that did not exist has none, and any other
300            //  commit has the branch tip first (a merge conclusion has the merged commits after it).
301            let listing: GitOutput = run(&git, repository, &["rev-list", "--parents", "--max-count=1", built], None)?;
302            let text: String = String::from_utf8_lossy(listing.stdout.as_slice()).into_owned();
303            let mut words = text.split_whitespace();
304            let named_itself: bool = listing.succeeded() && words.next() == Some(built);
305            let parents: Vec<String> = words.map(str::to_string).collect();
306            let acceptable: bool = if first.amend {
307                parents == amended_parents
308            } else if unborn {
309                parents.is_empty()
310            } else {
311                parents.first() == Some(&first.base)
312            };
313            if !named_itself || !acceptable {
314                return Err(FailureCode::Rejected);
315            }
316            // Git moves a branch to a commit without looking at what the commit holds, so a tree or a blob that a prune took
317            //  while the commit object survived would put a damaged commit on the branch.
318            let connected: Vec<&str> = if unborn { vec!["rev-list", "--objects", "--quiet", built] } else { vec!["rev-list", "--objects", "--quiet", built, "--not", old.as_str()] };
319            if !run(&git, repository, connected.as_slice(), None)?.succeeded() {
320                return Err(FailureCode::Unavailable);
321            }
322            (vec![built.to_string()], built.to_string())
323        } else {
324            let marks: PathBuf = context.journal.join(format!("marks-{seq}"));
325            let marks_argument: String = format!("--export-marks={}", marks.display());
326            checked(&git, repository, &["fast-import", "--quiet", "--done", marks_argument.as_str()], Some(import_stream(scratch.as_str(), old.as_str(), &batch, amended_parents.as_slice())))?;
327            let marks_text: String = std::fs::read_to_string(&marks).map_err(|_| { return FailureCode::Unavailable; })?;
328            let _marks_removed = std::fs::remove_file(&marks);
329            let made: Vec<String> = commits_from_marks(marks_text.as_str(), batch.len()).ok_or(FailureCode::Unavailable)?;
330            let last: String = made.last().cloned().ok_or(FailureCode::Unavailable)?;
331            (made, last)
332        };
333        timing.objects += objects_began.elapsed();
334        reach("after-objects");
335        if let Some(seam) = &context.on_objects_written {
336            seam();
337        }
338        // A commit Git built carries no list of changes: its index entries are the paths that differ from the old branch.
339        let derived: bool = first.commit.is_some();
340        let paths: Vec<String> = if derived {
341            Vec::new()
342        } else {
343            batch.iter().flat_map(|request| { return request.changes.iter().map(|change| { return change.path.clone(); }); }).collect()
344        };
345        let intent: Intent = Intent {
346            seq,
347            repository: first.repository.clone(),
348            common_dir: first.common_dir.clone(),
349            reference: first.reference.clone(),
350            old: old.clone(),
351            new: new.clone(),
352            ids: batch.iter().map(|request| { return request.id.clone(); }).collect(),
353            commits: commits.clone(),
354            paths,
355            derived,
356            cleanup: first.cleanup.clone(),
357            index: first.index.clone(),
358            git_dir: first.git_dir.clone(),
359        };
360        let journal_began: Instant = Instant::now();
361        let written = write_intent(context.journal.as_path(), &intent);
362        timing.journal += journal_began.elapsed();
363        written.map_err(|error| { return storage_failure(&error); })?;
364        reach("after-intent");
365        // A commit built elsewhere made no scratch branch, so there is none to remove with the move.
366        let removal: String = if first.commit.is_some() { String::new() } else { format!("delete {scratch}\n") };
367        // A detached head is the `HEAD` file itself: it is updated without following a symbolic reference, so a head that
368        //  became a branch since the commit was prepared fails the compare instead of moving that branch.
369        let direct: &str = if first.reference == "HEAD" { "option no-deref\n" } else { "" };
370        let transaction: String = format!("start\n{direct}update {} {new} {old}\n{removal}prepare\ncommit\n", first.reference);
371        let ref_began: Instant = Instant::now();
372        let ran = run(&git, repository, &["update-ref", "--stdin"], Some(transaction.into_bytes()));
373        timing.ref_update += ref_began.elapsed();
374        let moved: GitOutput = ran?;
375        if !moved.succeeded() {
376            // The branch moved under the batch: forget the intent and the scratch branch, and build the batch again.
377            let _intent_removed = std::fs::remove_file(super::service_journal::batch_path(context.journal.as_path(), seq, OPEN_EXTENSION));
378            let _deleted = run(&git, repository, &["update-ref", "-d", scratch.as_str()], None);
379            // For an amend, the next round finds the tip away from its base and answers that the branch moved.
380            attempts_left -= 1;
381            if attempts_left == 0 {
382                return Err(FailureCode::BranchKeepsChanging);
383            }
384            continue;
385        }
386        reach("after-ref");
387        // The branch has moved: the commits exist whatever happens to the index, which recovery finishes.
388        let Some(held) = lock.take() else { return Err(FailureCode::Unavailable) };
389        let sync_began: Instant = Instant::now();
390        let synced: bool = follow_up(&git, repository, &intent, held);
391        timing.sync += sync_began.elapsed();
392        if synced {
393            reach("after-index");
394            let _marked = mark_done(context.journal.as_path(), seq);
395        }
396        for (slot, (index, request)) in landing.iter().enumerate() {
397            outcomes[*index] = Outcome::Landed { commit: commits[slot].clone() };
398            super::service_captures::record_landed(request.common_dir.as_path(), request.repository.as_path(), commits[slot].as_str(), request.captured);
399        }
400        return Ok(outcomes);
401    }
402}
403
404/// One thing a restart did, for the service's log.
405#[derive(Clone, Debug, Eq, PartialEq)]
406pub struct RecoveryReport {
407    /// The batch.
408    pub seq: u64,
409    /// What was decided.
410    pub decision: Recovery,
411}
412
413/// What:
414///  Finishes or drops every batch the journal holds open.
415/// Why:
416///   Runs before the service accepts a connection, and again before each batch (`service_server.rs`), so an index update
417///  that gave up while another program held Git's index lock is finished by the next commit and not only by a restart.
418///
419/// In TS you'd write (pseudocode):
420/// ```ts
421/// await recover(context)
422/// ```
423pub fn recover(context: &LandContext) -> Vec<RecoveryReport> {
424    return recover_selected(context, &|_| { return true; }, true);
425}
426
427/// What:
428///  Finishes or drops the open batches of the worktree one instance serves, found in a journal that other worktrees' batches
429///  may share.
430/// Why:
431///   The single service of the releases before one service per worktree kept one journal for every worktree, in the directory
432///  above each instance's own, and an instance takes over the batches of its worktree there (found by the Git directory the
433///  intent records, which an index outside it, as `GIT_INDEX_FILE` names one, cannot change; an intent that records none is
434///  resolved from its repository). A development run may also give several instances one journal. Whatever belongs to
435///  another worktree is left to its instance, and a record that cannot be read is not any instance's to remove.
436///
437/// In TS you'd write (pseudocode):
438/// ```ts
439/// await recoverInstance(context, instance)
440/// ```
441pub fn recover_instance(context: &LandContext, instance: &str) -> Vec<RecoveryReport> {
442    let ours = |intent: &Intent| -> bool { return instance_of(intent.git_dir.as_path()) == instance; };
443    return recover_selected(context, &ours, false);
444}
445
446fn recover_selected(context: &LandContext, selected: &dyn Fn(&Intent) -> bool, remove_unreadable: bool) -> Vec<RecoveryReport> {
447    let mut reports: Vec<RecoveryReport> = Vec::new();
448    for (path, parsed) in list_intents(context.journal.as_path(), OPEN_EXTENSION) {
449        let Some(intent) = parsed else {
450            if remove_unreadable {
451                let _removed = std::fs::remove_file(&path);
452            }
453            continue;
454        };
455        if !selected(&intent) {
456            continue;
457        }
458        let git: GitContext = GitContext { common_dir: Some(intent.common_dir.clone()), ..context.git.clone() };
459        let current: String = checked(&git, intent.repository.as_path(), &["rev-parse", "--verify", intent.reference.as_str()], None).unwrap_or_default();
460        let ancestor: bool = run(&git, intent.repository.as_path(), &["merge-base", "--is-ancestor", intent.new.as_str(), current.as_str()], None)
461            .is_ok_and(|output| { return output.succeeded(); });
462        let decision: Recovery = recovery_for(&intent, current.as_str(), ancestor);
463        match decision {
464            Recovery::Drop => {
465                remove_stale(intent.index.as_path(), intent.seq);
466                let _removed = std::fs::remove_file(&path);
467            }
468            Recovery::Finish => {
469                remove_stale(intent.index.as_path(), intent.seq);
470                if let Some(held) = try_acquire(intent.index.as_path(), intent.seq)
471                    && follow_up(&git, intent.repository.as_path(), &intent, held)
472                {
473                    let _marked = mark_done(context.journal.as_path(), intent.seq);
474                }
475            }
476        }
477        reports.push(RecoveryReport { seq: intent.seq, decision });
478    }
479    return reports;
480}
481
482/// Landing controls stay out of the release executable.
483#[cfg(test)]
484#[path = "service_land_tests.rs"]
485mod tests;