Skip to main content

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