Skip to main content

git_policy_cli/
conclusion_cleanup.rs

1//! What:
2//!  Native conclusion cleanup reproduced in the owning worktree's Git directory after a
3//!       merge,
4//!  cherry-pick or revert conclusion lands,
5//!  and the names of that state.
6//! Why:
7//!  Native `git commit` removes `MERGE_HEAD` and its siblings in the shadow;
8//!  the real
9//!      worktree must end the same way,
10//!  but an entry is removed only while it still holds the
11//!      bytes or value copied at preparation,
12//!  because a changed entry belongs to a later command
13//!      (`src/shadow-repository/shadow-conclusion-cleanup.ts`,
14//!  `-files.ts`,
15//!  `-names.ts`).
16//!
17//! In TS you'd write (pseudocode):
18//! ```ts
19//! await reproduceConclusionCleanup({ gitPath, cwd, gitDir, shadowPath, transactionDirectory, refFormat });
20//! ```
21
22/// Debug diagnostics.
23use super::diagnostic_log::debug;
24/// Tree removal and private files.
25use super::private_storage::remove_tree;
26/// The fail-closed recovery failure.
27use super::recovery_error::{RecoveryError, io_failure};
28/// Exact reads and existence probes.
29use super::recovery_files::{path_present, read_optional, remove_file_if_present};
30/// Shadow commands.
31use super::shadow_git::shadow_request;
32/// Running real Git.
33use super::transaction_git::{GitContext, GitRequest, run_git, run_git_checked};
34/// The ref storage backend.
35use super::transaction_journal::RefFormat;
36/// `BTreeMap` is a sorted map.
37use std::collections::BTreeMap;
38/// `Path`/`PathBuf` are borrowed/owned filesystem paths.
39use std::path::{Path, PathBuf};
40
41/// Conclusion state files copied into the shadow at preparation.
42pub const CONCLUSION_STATE_FILES: [&str; 8] = [
43    "MERGE_HEAD",
44    "MERGE_MSG",
45    "MERGE_MODE",
46    "SQUASH_MSG",
47    "AUTO_MERGE",
48    "CHERRY_PICK_HEAD",
49    "REVERT_HEAD",
50    "MERGE_RR",
51];
52
53/// Conclusion state directory copied into the shadow at preparation.
54pub const SEQUENCER_DIRECTORY: &str = "sequencer";
55
56/// Pseudorefs a reftable repository keeps in its ref store rather than as files.
57pub const STORE_HELD_PSEUDOREFS: [&str; 3] = ["AUTO_MERGE", "CHERRY_PICK_HEAD", "REVERT_HEAD"];
58
59/// Entries landing removes from the owning worktree when native Git removed them in the shadow.
60pub const REMOVED_CONCLUSION_FILES: [&str; 7] = [
61    "AUTO_MERGE",
62    "MERGE_HEAD",
63    "MERGE_MODE",
64    "MERGE_MSG",
65    "SQUASH_MSG",
66    "CHERRY_PICK_HEAD",
67    "REVERT_HEAD",
68];
69
70/// Private directory of the transaction holding the preparation-time copies.
71pub const CONCLUSION_COPY_DIRECTORY: &str = "conclusion";
72
73/// Record of the store-held pseudoref values copied at preparation.
74pub const STORE_RECORD_FILENAME: &str = "store-held.json";
75
76/// What:
77///  Whether an entry lives in the ref store under this backend.
78/// Why:
79///   `isStoreHeld`:
80///  reftable keeps three pseudorefs in the store.
81///
82/// In TS you'd write (pseudocode):
83/// ```ts
84/// isStoreHeld({ refFormat, name })
85/// ```
86pub fn is_store_held(format: RefFormat, name: &str) -> bool {
87    return format == RefFormat::Reftable && STORE_HELD_PSEUDOREFS.contains(&name);
88}
89
90/// What:
91///  Resolve a pseudoref with `git rev-parse --verify --quiet`,
92///  or nothing when absent.
93/// Why:
94///   `resolvePseudoref`.
95///
96/// In TS you'd write (pseudocode):
97/// ```ts
98/// async function resolvePseudoref({ gitPath, cwd, name }): Promise<string | typeof ENTRY_ABSENT>;
99/// ```
100pub fn resolve_pseudoref(context: &GitContext, request: GitRequest) -> Option<String> {
101    let output: super::transaction_git::GitOutput = run_git(context, &request).ok()?;
102    if !output.succeeded() {
103        return None;
104    }
105    let text: &str = std::str::from_utf8(output.stdout.as_slice()).ok()?;
106    return Some(String::from(super::js_text::trim_javascript(text)));
107}
108
109/// What:
110///  Every regular file under a directory,
111///  by path relative to it.
112/// Why:
113///   `readTree`:
114///  the sequencer copy and the real sequencer are compared whole.
115///
116/// In TS you'd write (pseudocode):
117/// ```ts
118/// async function readTree(root: string): Promise<ReadonlyMap<string, Uint8Array>>;
119/// ```
120fn read_tree(root: &Path) -> Result<BTreeMap<PathBuf, Vec<u8>>, RecoveryError> {
121    // `mut` allows collecting files while walking.
122    let mut files: BTreeMap<PathBuf, Vec<u8>> = BTreeMap::new();
123    if !path_present(root)? {
124        return Ok(files);
125    }
126    let mut pending: Vec<PathBuf> = vec![root.to_path_buf()];
127    while let Some(directory) = pending.pop() {
128        let listing: std::fs::ReadDir = match std::fs::read_dir(&directory) {
129            Ok(found) => found,
130            Err(error) => return Err(io_failure("listing", directory.as_path(), &error)),
131        };
132        for item in listing {
133            let entry: std::fs::DirEntry = match item {
134                Ok(found) => found,
135                Err(error) => return Err(io_failure("listing", directory.as_path(), &error)),
136            };
137            let kind: std::fs::FileType = match entry.file_type() {
138                Ok(found) => found,
139                Err(error) => return Err(io_failure("inspecting", entry.path().as_path(), &error)),
140            };
141            let path: PathBuf = entry.path();
142            if kind.is_dir() {
143                pending.push(path);
144            } else if kind.is_file() {
145                let bytes: Vec<u8> = match std::fs::read(&path) {
146                    Ok(read) => read,
147                    Err(error) => return Err(io_failure("reading", path.as_path(), &error)),
148                };
149                let relative: PathBuf = path
150                    .strip_prefix(root)
151                    .unwrap_or(path.as_path())
152                    .to_path_buf();
153                files.insert(relative, bytes);
154            }
155        }
156    }
157    return Ok(files);
158}
159
160/// What:
161///  Whether two directory trees hold the same regular files with the same bytes,
162///  and are
163///       not empty.
164/// Why:
165///   `sameTree`:
166///  the sequencer is removed only while it is exactly the copied state.
167///
168/// In TS you'd write (pseudocode):
169/// ```ts
170/// async function sameTree({ left, right }): Promise<boolean>;
171/// ```
172pub fn same_tree(left: &Path, right: &Path) -> Result<bool, RecoveryError> {
173    let first: BTreeMap<PathBuf, Vec<u8>> = read_tree(left)?;
174    let second: BTreeMap<PathBuf, Vec<u8>> = read_tree(right)?;
175    return Ok(!first.is_empty() && first == second);
176}
177
178/// What:
179///  The store-held values copied at preparation,
180///  from `store-held.json`.
181/// Why:
182///   A value that is not text is no copy;
183///  the record itself must be valid UTF-8 JSON.
184///
185/// In TS you'd write (pseudocode):
186/// ```ts
187/// JSON.parse(decode(await readFile(join(copies, STORE_RECORD_FILENAME))))
188/// ```
189fn read_store_record(copies: &Path) -> Result<serde_json::Value, RecoveryError> {
190    let path: PathBuf = copies.join(STORE_RECORD_FILENAME);
191    let bytes: Vec<u8> = match std::fs::read(&path) {
192        Ok(read) => read,
193        Err(error) => return Err(io_failure("reading", path.as_path(), &error)),
194    };
195    let Some(text) = super::json_record::decode_fatal(bytes.as_slice()) else {
196        return Err(RecoveryError(format!("{} is not UTF-8.", path.display())));
197    };
198    match serde_json::from_str::<serde_json::Value>(text) {
199        Ok(value) => return Ok(value),
200        Err(_) => return Err(RecoveryError(format!("{} is not JSON.", path.display()))),
201    }
202}
203
204/// What:
205///  Remove one store-held pseudoref from the owning worktree when the shadow no longer
206///       holds it and the worktree still holds the copied value.
207/// Why:
208///   A reftable repository deletes it with `git update-ref -d <name> <copied value>`.
209///
210/// In TS you'd write (pseudocode):
211/// ```ts
212/// if (inShadow.exitCode !== 0 && inReal === copied) await runTransactionGit({ args: ['update-ref', '-d', name, copied] });
213/// ```
214fn clean_store_held(
215    context: &GitContext,
216    cwd: &Path,
217    shadow: &Path,
218    name: &str,
219    copied: &str,
220) -> Result<(), RecoveryError> {
221    let in_shadow: bool = match run_git(
222        context,
223        &shadow_request(shadow, shadow, &["rev-parse", "--verify", "--quiet", name]),
224    ) {
225        Ok(output) => output.succeeded(),
226        Err(_) => false,
227    };
228    let in_real: Option<String> = resolve_pseudoref(
229        context,
230        GitRequest::new(cwd, &["rev-parse", "--verify", "--quiet", name]),
231    );
232    if !in_shadow
233        && in_real.as_deref() == Some(copied)
234        && let Err(failure) = run_git_checked(
235            context,
236            &GitRequest::new(cwd, &["update-ref", "-d", name, copied]),
237        )
238    {
239        return Err(RecoveryError(failure.0));
240    }
241    return Ok(());
242}
243
244/// What:
245///  Reproduce native conclusion cleanup in the owning worktree's Git directory.
246/// Why:
247///   `reproduceConclusionCleanup`:
248///  removed entries,
249///  `MERGE_RR` copied back,
250///  `ORIG_HEAD`
251///       kept,
252///  and `sequencer/` removed only when native Git removed the shadow copy.
253///
254/// In TS you'd write (pseudocode):
255/// ```ts
256/// async function reproduceConclusionCleanup({ gitPath, cwd, gitDir, shadowPath, transactionDirectory, refFormat }): Promise<void>;
257/// ```
258pub fn reproduce_conclusion_cleanup(
259    context: &GitContext,
260    cwd: &Path,
261    git_dir: &Path,
262    shadow: &Path,
263    transaction_directory: &Path,
264    format: RefFormat,
265) -> Result<(), RecoveryError> {
266    let copies: PathBuf = transaction_directory.join(CONCLUSION_COPY_DIRECTORY);
267    if !path_present(copies.as_path())? {
268        debug(
269            "reproduceConclusionCleanup",
270            format!(
271                "no conclusion state was copied: {}",
272                transaction_directory.display()
273            )
274            .as_str(),
275        );
276        return Ok(());
277    }
278    let store: serde_json::Value = read_store_record(copies.as_path())?;
279    for name in REMOVED_CONCLUSION_FILES {
280        if is_store_held(format, name) {
281            if let Some(copied) = store.get(name).and_then(serde_json::Value::as_str) {
282                clean_store_held(context, cwd, shadow, name, copied)?;
283            }
284            continue;
285        }
286        let copy: Option<Vec<u8>> = read_optional(copies.join(name).as_path())?;
287        let in_shadow: Option<Vec<u8>> = read_optional(shadow.join(name).as_path())?;
288        let real: Option<Vec<u8>> = read_optional(git_dir.join(name).as_path())?;
289        if copy.is_some() && in_shadow.is_none() && copy == real {
290            remove_file_if_present(git_dir.join(name).as_path())?;
291            debug(
292                "reproduceConclusionCleanup",
293                format!("removed conclusion entry {name} as native Git did").as_str(),
294            );
295        }
296    }
297    if let Some(merge_rr) = read_optional(shadow.join("MERGE_RR").as_path())? {
298        let temporary: PathBuf = git_dir.join(format!("MERGE_RR.cli-git-{}", std::process::id()));
299        if let Err(error) = std::fs::write(&temporary, merge_rr.as_slice()) {
300            return Err(io_failure("writing", temporary.as_path(), &error));
301        }
302        let destination: PathBuf = git_dir.join("MERGE_RR");
303        if let Err(error) = std::fs::rename(&temporary, &destination) {
304            return Err(io_failure("replacing", destination.as_path(), &error));
305        }
306    }
307    let sequencer_copy: PathBuf = copies.join(SEQUENCER_DIRECTORY);
308    let removed_in_shadow: bool = path_present(sequencer_copy.as_path())?
309        && !path_present(shadow.join(SEQUENCER_DIRECTORY).as_path())?;
310    let real_sequencer: PathBuf = git_dir.join(SEQUENCER_DIRECTORY);
311    if removed_in_shadow && same_tree(sequencer_copy.as_path(), real_sequencer.as_path())? {
312        if let Err(error) = remove_tree(real_sequencer.as_path()) {
313            return Err(io_failure("removing", real_sequencer.as_path(), &error));
314        }
315        debug(
316            "reproduceConclusionCleanup",
317            "removed sequencer state as native Git did after the last pick",
318        );
319    }
320    return Ok(());
321}
322
323/// Cleanup controls stay out of the release executable.
324#[cfg(test)]
325#[path = "conclusion_cleanup_tests.rs"]
326mod tests;