Skip to main content

git_policy_cli/
merge_isolation_journal.rs

1//! What:
2//!  The durable intent record of an isolated merge: the commit it created, the branch value it expects, and the
3//!  process that runs it.
4//! Why:
5//!  Between the moment Git starts updating the worktree and the index for the merge commit and the moment the branch
6//!  moves, a killed process leaves a worktree and an index that agree with the merge while the branch does not.
7//!  The record, written and synced before Git starts, lets the next command under the landing lock decide whether
8//!  the merge never started (discard it) or must be completed.
9//!  Records are created once and never rewritten, like the commit transaction's own state files.
10//!
11//! In TS you'd write (pseudocode):
12//! ```ts
13//! await writeFile(join(gitDir, 'cli-git-merges', `${id}.json`), encodeMergeIntent(intent), { flag: 'wx' });
14//! ```
15
16/// Record writers and readers.
17use super::json_record::{ObjectWriter, decode_fatal, number_equals, parse_object, safe_integer_field, string_field};
18/// The owner of a record.
19use super::transaction_owner::TransactionOwner;
20/// Exclusive, synced, private files and directories.
21use super::private_storage::{create_private_directory, sync_directory, write_exclusive_synced};
22/// The fail-closed recovery failure.
23use super::recovery_error::{RecoveryError, io_failure};
24/// `Path` and `PathBuf` are borrowed and owned filesystem paths.
25use std::path::{Path, PathBuf};
26
27/// Directory, inside the worktree's Git directory, that holds the intent records.
28pub const MERGE_INTENT_DIRECTORY: &str = "cli-git-merges";
29
30/// Schema version of an intent record.
31pub const MERGE_INTENT_SCHEMA_VERSION: i64 = 1;
32
33/// Suffix of an intent record's file name.
34const RECORD_SUFFIX: &str = ".json";
35
36/// What:
37///  One isolated merge that has created its commit and is about to move the branch to it.
38/// Why:
39///   Recovery needs the two commits, the ref that moves, the worktree to work in, and the owner whose death makes
40///  the record recoverable.
41///
42/// In TS you'd write (pseudocode):
43/// ```ts
44/// type MergeIntent = { owner: TransactionOwner; repositoryRoot: string; targetRef?: string; oldOid: string; newOid: string };
45/// ```
46#[derive(Clone, Debug, Eq, PartialEq)]
47pub struct MergeIntent {
48    /// Transaction ID, also the record's file stem.
49    pub owner: TransactionOwner,
50    /// Canonical worktree top level, where recovery runs Git.
51    pub repository_root: String,
52    /// The branch ref that moves; absent for a detached `HEAD`.
53    pub target_ref: Option<String>,
54    /// The commit the target holds before the merge.
55    pub old_oid: String,
56    /// The merge commit the target moves to.
57    pub new_oid: String,
58}
59
60/// What:
61///  The directory that holds a worktree's intent records.
62/// Why:
63///   Every reader and writer names it the same way.
64///
65/// In TS you'd write (pseudocode):
66/// ```ts
67/// join(gitDir, 'cli-git-merges')
68/// ```
69pub fn intent_directory(git_dir: &Path) -> PathBuf {
70    return git_dir.join(MERGE_INTENT_DIRECTORY);
71}
72
73/// What:
74///  The record's file name.
75/// Why:
76///   The transaction ID is unique per merge.
77///
78/// In TS you'd write (pseudocode):
79/// ```ts
80/// `${id}.json`
81/// ```
82pub fn intent_path(git_dir: &Path, transaction_id: &str) -> PathBuf {
83    return intent_directory(git_dir).join(format!("{transaction_id}{RECORD_SUFFIX}"));
84}
85
86/// What:
87///  Encode an intent as compact JSON with a trailing line feed.
88/// Why:
89///   Every text is quoted at this final step.
90///
91/// In TS you'd write (pseudocode):
92/// ```ts
93/// `${JSON.stringify(record)}\n`
94/// ```
95pub fn encode_merge_intent(intent: &MergeIntent) -> String {
96    let mut writer: ObjectWriter = ObjectWriter::new();
97    writer
98        .integer("schemaVersion", MERGE_INTENT_SCHEMA_VERSION)
99        .string("state", "merge-intent")
100        .string("transactionId", intent.owner.transaction_id.as_str())
101        .integer("ownerPid", intent.owner.owner_pid)
102        .string("ownerIdentity", intent.owner.owner_identity.as_str())
103        .string("createdAt", intent.owner.created_at.as_str());
104    if let Some(namespace) = &intent.owner.owner_pid_namespace {
105        writer.string("ownerPidNamespace", namespace.as_str());
106    }
107    writer
108        .string("repositoryRoot", intent.repository_root.as_str())
109        .string("targetRef", intent.target_ref.as_deref().unwrap_or_default())
110        .string("oldOid", intent.old_oid.as_str())
111        .string("newOid", intent.new_oid.as_str());
112    return format!("{}\n", writer.finish());
113}
114
115/// What:
116///  Parse record bytes, or nothing when they are not a valid intent.
117/// Why:
118///   A malformed record fails recovery closed instead of being guessed at.
119///
120/// In TS you'd write (pseudocode):
121/// ```ts
122/// function parseMergeIntent(bytes: Uint8Array): MergeIntent | undefined;
123/// ```
124pub fn parse_merge_intent(bytes: &[u8]) -> Option<MergeIntent> {
125    let map: serde_json::Map<String, serde_json::Value> = parse_object(decode_fatal(bytes)?)?;
126    if !number_equals(&map, "schemaVersion", MERGE_INTENT_SCHEMA_VERSION)
127        || string_field(&map, "state")? != "merge-intent"
128    {
129        return None;
130    }
131    let owner_pid: i64 = safe_integer_field(&map, "ownerPid")?;
132    let owner_identity: &str = string_field(&map, "ownerIdentity")?;
133    let old_oid: &str = string_field(&map, "oldOid")?;
134    let new_oid: &str = string_field(&map, "newOid")?;
135    let repository_root: &str = string_field(&map, "repositoryRoot")?;
136    if owner_pid < 1 || owner_identity.is_empty() || old_oid.is_empty() || new_oid.is_empty()
137        || repository_root.is_empty()
138    {
139        return None;
140    }
141    let target_ref: &str = string_field(&map, "targetRef")?;
142    return Some(MergeIntent {
143        owner: TransactionOwner {
144            transaction_id: String::from(string_field(&map, "transactionId")?),
145            owner_pid,
146            owner_identity: String::from(owner_identity),
147            created_at: String::from(string_field(&map, "createdAt")?),
148            owner_pid_namespace: string_field(&map, "ownerPidNamespace")
149                .filter(|namespace| { return !namespace.is_empty(); })
150                .map(String::from),
151        },
152        repository_root: String::from(repository_root),
153        target_ref: if target_ref.is_empty() { None } else { Some(String::from(target_ref)) },
154        old_oid: String::from(old_oid),
155        new_oid: String::from(new_oid),
156    });
157}
158
159/// What:
160///  Write an intent record durably: the private directory, the exclusive synced file, the synced directory.
161/// Why:
162///   The merge may change the worktree only after the record would survive a crash.
163///
164/// In TS you'd write (pseudocode):
165/// ```ts
166/// await mkdir(directory, { mode: 0o700 }); await writeExclusiveSynced(path, bytes); await syncDirectory(directory);
167/// ```
168pub fn write_merge_intent(git_dir: &Path, intent: &MergeIntent) -> std::io::Result<PathBuf> {
169    let directory: PathBuf = intent_directory(git_dir);
170    match create_private_directory(directory.as_path()) {
171        Ok(()) => {}
172        Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {}
173        Err(error) => return Err(error),
174    }
175    let path: PathBuf = intent_path(git_dir, intent.owner.transaction_id.as_str());
176    write_exclusive_synced(path.as_path(), encode_merge_intent(intent).as_bytes())?;
177    sync_directory(directory.as_path())?;
178    return Ok(path);
179}
180
181/// What:
182///  Remove an intent record once the merge ended, and sync the directory.
183/// Why:
184///   A record that outlives its merge would be recovered again.
185///
186/// In TS you'd write (pseudocode):
187/// ```ts
188/// await rm(path, { force: true }); await syncDirectory(dirname(path));
189/// ```
190pub fn remove_merge_intent(path: &Path) -> std::io::Result<()> {
191    match std::fs::remove_file(path) {
192        Ok(()) => {}
193        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()),
194        Err(error) => return Err(error),
195    }
196    if let Some(directory) = path.parent() {
197        sync_directory(directory)?;
198    }
199    return Ok(());
200}
201
202/// What:
203///  The intent records of a worktree, oldest name first, or none when the directory does not exist.
204/// Why:
205///   The common case, no merge ever interrupted, costs one failed directory read and no Git process.
206///
207/// In TS you'd write (pseudocode):
208/// ```ts
209/// (await readdir(directory).catch(() => [])).filter((name) => name.endsWith('.json')).sort()
210/// ```
211pub fn list_merge_intents(git_dir: &Path) -> Result<Vec<PathBuf>, RecoveryError> {
212    let directory: PathBuf = intent_directory(git_dir);
213    let listing: std::fs::ReadDir = match std::fs::read_dir(directory.as_path()) {
214        Ok(found) => found,
215        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
216        Err(error) => return Err(io_failure("listing", directory.as_path(), &error)),
217    };
218    let mut records: Vec<PathBuf> = Vec::new();
219    for item in listing {
220        match item {
221            Ok(entry) => {
222                if entry.file_name().to_string_lossy().ends_with(RECORD_SUFFIX) {
223                    records.push(entry.path());
224                }
225            }
226            Err(error) => return Err(io_failure("listing", directory.as_path(), &error)),
227        }
228    }
229    records.sort();
230    return Ok(records);
231}
232
233/// Encoding, parsing, writing and listing of intent records.
234#[cfg(test)]
235#[path = "merge_isolation_journal_tests.rs"]
236mod tests;