Skip to main content

git_policy_cli/
service_journal.rs

1//! What:
2//!  The resident service's journal: one file per batch of commits, with the one durable write the batch needs, and the
3//!  decision a restart makes about a batch it finds open.
4//! Why:
5//!  The branch moves only after the intent is on disk, so after any kill the journal and the branch say whether the
6//!  batch landed; a restart finishes the one step that may be missing (the index update) or drops the intent, and a
7//!  request that landed is remembered by its identity so a retry is answered, not landed twice
8//!  (`doc/planning/cli-git-resident-service.md`, "Durability and crash recovery").
9//!
10//! In TS you'd write (pseudocode):
11//! ```ts
12//! await writeIntent(dir, intent); // file fsync, rename, directory fsync: the batch's only synchronous write
13//! ```
14
15use super::service_protocol::Cleanup;
16use std::collections::HashMap;
17use std::fs::{self, File};
18use std::io::Write;
19use std::path::{Path, PathBuf};
20
21/// Extension of an intent whose batch may be unfinished.
22pub const OPEN_EXTENSION: &str = "intent";
23
24/// Extension of a finished batch, kept so its request identities are remembered.
25pub const DONE_EXTENSION: &str = "done";
26
27/// Extension of a file being written; never read as an intent.
28const TEMPORARY_EXTENSION: &str = "tmp";
29
30/// What:
31///  What the service decided to do for a batch, written before the branch moves.
32/// Why:
33///   Enough to finish the batch after a kill: where the branch was, where it goes, which requests, which index paths.
34///
35/// In TS you'd write (pseudocode):
36/// ```ts
37/// type Intent = { seq; repository; commonDir; reference; old; new; ids; paths };
38/// ```
39#[derive(Clone, Debug, Eq, PartialEq)]
40pub struct Intent {
41    /// Number of the batch, unique and increasing within one journal.
42    pub seq: u64,
43    /// Worktree root whose index the batch updates.
44    pub repository: PathBuf,
45    /// Common git directory.
46    pub common_dir: PathBuf,
47    /// The branch.
48    pub reference: String,
49    /// The branch before the batch.
50    pub old: String,
51    /// The branch after the batch.
52    pub new: String,
53    /// Identities of the requests in the batch.
54    pub ids: Vec<String>,
55    /// The commit each request became, in the same order as the identities.
56    pub commits: Vec<String>,
57    /// Paths whose index entries the batch updates.
58    pub paths: Vec<String>,
59    /// Whether the index entries to update are the paths that differ between `old` and `new`, read from the repository
60    ///  when they are needed (a commit Git built has no list of changes to carry, and its names are bytes).
61    pub derived: bool,
62    /// The conclusion state the landing removes after the branch moved; nothing for any other batch.
63    pub cleanup: Option<Cleanup>,
64    /// The real index whose lock the landing held, so a restart can remove its own stale lock and finish the index.
65    pub index: PathBuf,
66    /// The worktree's own Git directory, which names the instance that serves the batch; an intent written before it was
67    ///  recorded reads as the directory its index sits in.
68    pub git_dir: PathBuf,
69}
70
71/// What a restart does with an open intent.
72#[derive(Clone, Copy, Debug, Eq, PartialEq)]
73pub enum Recovery {
74    /// The batch never landed; forget the intent.
75    Drop,
76    /// The batch landed; make sure the index shows it, then remember the identities.
77    Finish,
78}
79
80/// What:
81///  The decision for an open intent from the branch's current value.
82/// Why:
83///   The branch is the commit point: still at the old value means nothing happened, at the new value or past it
84///  (`new_is_ancestor_of_current`) means the batch landed, anywhere else means it cannot land now.
85///
86/// In TS you'd write (pseudocode):
87/// ```ts
88/// recoveryFor(intent, current, newIsAncestorOfCurrent)
89/// ```
90pub fn recovery_for(intent: &Intent, current: &str, new_is_ancestor_of_current: bool) -> Recovery {
91    if current == intent.new || (current != intent.old && new_is_ancestor_of_current) {
92        return Recovery::Finish;
93    }
94    return Recovery::Drop;
95}
96
97/// The text of an intent: `key=value` lines, tabs between list items.
98pub fn intent_text(intent: &Intent) -> String {
99    let cleanup: String = intent.cleanup.as_ref().map_or_else(String::new, |found| {
100        return format!("{}\t{}\t{}", found.git_dir.display(), found.scratch.display(), u8::from(found.reftable));
101    });
102    return format!(
103        "seq={}\nrepository={}\ncommon={}\nref={}\nold={}\nnew={}\nids={}\ncommits={}\npaths={}\nderived={}\ncleanup={}\nindex={}\ngitdir={}\n",
104        intent.seq,
105        intent.repository.display(),
106        intent.common_dir.display(),
107        intent.reference,
108        intent.old,
109        intent.new,
110        intent.ids.join("\t"),
111        intent.commits.join("\t"),
112        intent.paths.join("\t"),
113        u8::from(intent.derived),
114        cleanup,
115        intent.index.display(),
116        intent.git_dir.display(),
117    );
118}
119
120/// What:
121///  Reads an intent's text.
122/// Why:
123///   A damaged file is no intent: recovery drops it.
124///
125/// In TS you'd write (pseudocode):
126/// ```ts
127/// parseIntent(text) // undefined when damaged
128/// ```
129pub fn parse_intent(text: &str) -> Option<Intent> {
130    let value = |key: &str| -> Option<&str> { return text.lines().find_map(|line| { return line.strip_prefix(key)?.strip_prefix('='); }); };
131    let list = |key: &str| -> Option<Vec<String>> {
132        return Some(value(key)?.split('\t').filter(|item| { return !item.is_empty(); }).map(str::to_string).collect());
133    };
134    let index: PathBuf = value("index").map_or_else(|| { return PathBuf::from(value("repository").unwrap_or("")).join(".git").join("index"); }, PathBuf::from);
135    // An intent written before the Git directory was recorded names its worktree by the directory its index sits in.
136    let worktree_git_dir: PathBuf = value("gitdir").map_or_else(|| { return index.parent().map(Path::to_path_buf).unwrap_or_default(); }, PathBuf::from);
137    return Some(Intent {
138        seq: value("seq")?.parse().ok()?,
139        repository: PathBuf::from(value("repository")?),
140        common_dir: PathBuf::from(value("common")?),
141        reference: value("ref")?.to_string(),
142        old: value("old")?.to_string(),
143        new: value("new")?.to_string(),
144        ids: list("ids")?,
145        commits: list("commits")?,
146        paths: list("paths")?,
147        // Intents written before these fields existed name neither.
148        derived: value("derived") == Some("1"),
149        index,
150        git_dir: worktree_git_dir,
151        cleanup: value("cleanup").and_then(|entry| {
152            let mut parts = entry.split('\t');
153            let (git_dir, scratch, reftable) = (parts.next()?, parts.next()?, parts.next()?);
154            return Some(Cleanup { git_dir: PathBuf::from(git_dir), scratch: PathBuf::from(scratch), reftable: reftable == "1" });
155        }),
156    });
157}
158
159/// The path of a batch's file with an extension.
160pub fn batch_path(dir: &Path, seq: u64, extension: &str) -> PathBuf {
161    return dir.join(format!("{seq}.{extension}"));
162}
163
164/// What:
165///  Writes an intent durably: the file is synced, renamed into place, and the directory is synced.
166/// Why:
167///   This is the only synchronous write of a batch, whatever its size.
168///
169/// In TS you'd write (pseudocode):
170/// ```ts
171/// await writeIntent(dir, intent)
172/// ```
173pub fn write_intent(dir: &Path, intent: &Intent) -> std::io::Result<()> {
174    let temporary: PathBuf = batch_path(dir, intent.seq, TEMPORARY_EXTENSION);
175    let mut file: File = File::create(&temporary)?;
176    file.write_all(intent_text(intent).as_bytes())?;
177    file.sync_all()?;
178    fs::rename(&temporary, batch_path(dir, intent.seq, OPEN_EXTENSION))?;
179    return File::open(dir)?.sync_all();
180}
181
182/// Marks a batch finished. Not synced: if the mark is lost a restart redoes an idempotent step.
183pub fn mark_done(dir: &Path, seq: u64) -> std::io::Result<()> {
184    return fs::rename(batch_path(dir, seq, OPEN_EXTENSION), batch_path(dir, seq, DONE_EXTENSION));
185}
186
187/// What:
188///  The intents in the journal with an extension, in batch order, and the temporary files removed on the way.
189/// Why:
190///   Recovery reads the open ones; startup reads the done ones for the request identities.
191///
192/// In TS you'd write (pseudocode):
193/// ```ts
194/// listIntents(dir, 'intent')
195/// ```
196pub fn list_intents(dir: &Path, extension: &str) -> Vec<(PathBuf, Option<Intent>)> {
197    let Ok(entries) = fs::read_dir(dir) else {
198        return Vec::new();
199    };
200    let mut found: Vec<(PathBuf, Option<Intent>)> = Vec::new();
201    for entry in entries.flatten() {
202        let path: PathBuf = entry.path();
203        match path.extension().and_then(|text| { return text.to_str(); }) {
204            Some(TEMPORARY_EXTENSION) => {
205                let _removed = fs::remove_file(&path);
206            }
207            Some(found_extension) if found_extension == extension => {
208                let parsed: Option<Intent> = fs::read_to_string(&path).ok().and_then(|text| { return parse_intent(text.as_str()); });
209                found.push((path, parsed));
210            }
211            _ => {}
212        }
213    }
214    found.sort_by_key(|(path, intent)| {
215        return intent.as_ref().map_or_else(|| { return path.to_string_lossy().len() as u64; }, |parsed| { return parsed.seq; });
216    });
217    return found;
218}
219
220/// The request identities of every finished batch, each with the commit it became.
221pub fn landed_commits(dir: &Path) -> HashMap<String, String> {
222    let mut landed: HashMap<String, String> = HashMap::new();
223    for (_, intent) in list_intents(dir, DONE_EXTENSION) {
224        if let Some(parsed) = intent {
225            landed.extend(parsed.ids.into_iter().zip(parsed.commits));
226        }
227    }
228    return landed;
229}
230
231/// Journal controls stay out of the release executable.
232#[cfg(test)]
233#[path = "service_journal_tests.rs"]
234mod tests;