Skip to main content

git_policy_cli/
worktree_copy_install_log.rs

1//! What:
2//!  The append-only install log inside one transaction's private stage:
3//!  one synced JSON line per batch of intents and one per batch of proven creations.
4//! Why:
5//!  The journal header changes only with the phase, so the per-batch records live here;
6//!  bytes written then grow with the entry count instead of with the square of it.
7//!
8//! In TS you'd write (pseudocode):
9//! ```ts
10//! await using opened = await openInstallLog(stageContainer);
11//! await opened.log.appendIntents(paths);
12//! ```
13
14use super::json_record::quote;
15use super::private_storage::sync_directory;
16use super::worktree_copy_error::{WorktreeCopyError, failure_with_cause, raw_failure};
17use super::worktree_copy_install_entry::InstalledPath;
18use super::worktree_copy_journal::{
19    Role, assert_private_path, encode_installed_entry, installed_entry, string_array,
20};
21use super::worktree_copy_selection::assert_safe_repository_path;
22use std::io::Write;
23use std::os::unix::fs::OpenOptionsExt;
24use std::path::{Path, PathBuf};
25
26/// What:
27///  The install-log file name inside a stage container.
28/// Why:
29///   Recovery finds the log by this name, so both wrappers must agree on it.
30///
31/// In TS you'd write (pseudocode):
32/// ```ts
33/// const INSTALL_LOG_NAME = 'install-log.jsonl';
34/// ```
35pub const INSTALL_LOG_NAME: &str = "install-log.jsonl";
36
37/// What:
38///  The records an existing log holds.
39/// Why:
40///   An interrupted installation resumes from exactly these claims and creations.
41///
42/// In TS you'd write (pseudocode):
43/// ```ts
44/// type InstallLogRecords = { intendedEntries: string[]; createdEntries: InstalledWorktreePath[] };
45/// ```
46#[derive(Clone, Debug, Default, Eq, PartialEq)]
47pub struct InstallLogRecords {
48    /// Selected paths claimed before destination mutation, in claim order.
49    pub intended: Vec<String>,
50    /// Paths proven created, in creation order.
51    pub created: Vec<InstalledPath>,
52}
53
54/// What:
55///  The append handle of one log.
56/// Why:
57///   Each append is synced before the caller mutates the destination, so a crash leaves the claim on disk.
58///
59/// In TS you'd write (pseudocode):
60/// ```ts
61/// type InstallLog = { appendIntents(paths): Promise<void>; appendCreations(entries): Promise<void> };
62/// ```
63#[derive(Debug)]
64pub struct InstallLog {
65    /// The log file, opened for appending without following links.
66    file: std::fs::File,
67    /// The log's path, named in every diagnostic.
68    path: PathBuf,
69}
70
71/// What:
72///  An open log together with what it already held.
73/// Why:
74///   The caller seeds its claim and creation sets from the recorded part before it installs anything.
75///
76/// In TS you'd write (pseudocode):
77/// ```ts
78/// { log: InstallLog; recorded: InstallLogRecords }
79/// ```
80#[derive(Debug)]
81pub struct OpenedLog {
82    /// The append handle.
83    pub log: InstallLog,
84    /// The records already in the log.
85    pub recorded: InstallLogRecords,
86}
87
88/// What:
89///  One line's records.
90/// Why:
91///   A line carries either intents or creations, never both.
92///
93/// In TS you'd write (pseudocode):
94/// ```ts
95/// function parseLine({ line, path }): InstallLogRecords;
96/// ```
97fn parse_line(line: &str, path: &Path) -> Result<InstallLogRecords, WorktreeCopyError> {
98    let corrupt = || {
99        return failure_with_cause(
100            format!(
101                "cli-git: worktree-copy install log is corrupt: {}.",
102                quote(path.to_string_lossy().as_ref())
103            )
104            .as_str(),
105            None,
106        );
107    };
108    let value: serde_json::Value = serde_json::from_str(line).map_err(|_| return corrupt())?;
109    let object = value.as_object().ok_or_else(corrupt)?;
110    if let Some(intended) = object.get("intended").and_then(serde_json::Value::as_array) {
111        let names: Vec<String> = intended
112            .iter()
113            .map(|item| return item.as_str().map(str::to_string))
114            .collect::<Option<Vec<String>>>()
115            .ok_or_else(corrupt)?;
116        for name in &names {
117            assert_safe_repository_path(name.as_str())?;
118        }
119        return Ok(InstallLogRecords {
120            intended: names,
121            created: Vec::new(),
122        });
123    }
124    if let Some(created) = object.get("created").and_then(serde_json::Value::as_array) {
125        let entries: Vec<InstalledPath> = created
126            .iter()
127            .map(installed_entry)
128            .collect::<Option<Vec<InstalledPath>>>()
129            .ok_or_else(corrupt)?;
130        for entry in &entries {
131            assert_safe_repository_path(entry.relative_path.as_str())?;
132        }
133        return Ok(InstallLogRecords {
134            intended: Vec::new(),
135            created: entries,
136        });
137    }
138    return Err(corrupt());
139}
140
141/// What:
142///  The records of every complete line, and the byte length those lines span.
143/// Why:
144///   A trailing fragment without its line feed is an append the owner never finished; it is not a record.
145///
146/// In TS you'd write (pseudocode):
147/// ```ts
148/// async function readCompleteRecords(path): Promise<{ records; completeLength; totalLength }>;
149/// ```
150fn read_complete_records(path: &Path) -> Result<(InstallLogRecords, u64, u64), WorktreeCopyError> {
151    assert_private_path(path, Role::JournalFile)?;
152    let content: Vec<u8> = std::fs::read(path).map_err(|error| return raw_failure(&error))?;
153    let complete_length: usize = content
154        .iter()
155        .rposition(|byte| return *byte == b'\n')
156        .map_or(0, |index| return index + 1);
157    let text: String = String::from_utf8_lossy(&content[..complete_length]).into_owned();
158    let mut records: InstallLogRecords = InstallLogRecords::default();
159    for line in text.split('\n').filter(|line| return !line.is_empty()) {
160        let parsed: InstallLogRecords = parse_line(line, path)?;
161        records.intended.extend(parsed.intended);
162        records.created.extend(parsed.created);
163    }
164    return Ok((records, complete_length as u64, content.len() as u64));
165}
166
167/// What:
168///  Opens the log of one stage, returning what it already records and truncating any unfinished append.
169/// Why:
170///   Resumption must see every claim and creation, and new records must start on a fresh line.
171///
172/// In TS you'd write (pseudocode):
173/// ```ts
174/// await using opened = await openInstallLog(stageContainer);
175/// ```
176pub fn open_install_log(stage_container: &Path) -> Result<OpenedLog, WorktreeCopyError> {
177    let path: PathBuf = stage_container.join(INSTALL_LOG_NAME);
178    let existed: bool = std::fs::symlink_metadata(path.as_path()).is_ok();
179    let (recorded, complete_length, total_length) = if existed {
180        read_complete_records(path.as_path())?
181    } else {
182        (InstallLogRecords::default(), 0, 0)
183    };
184    let opened: std::io::Result<std::fs::File> = std::fs::OpenOptions::new()
185        .create(true)
186        .append(true)
187        .custom_flags(libc::O_NOFOLLOW)
188        .mode(0o600)
189        .open(path.as_path());
190    let file: std::fs::File = match opened {
191        Ok(file) => file,
192        Err(error) => {
193            let message: String = format!(
194                "cli-git: could not open worktree-copy install log {}.",
195                quote(path.to_string_lossy().as_ref())
196            );
197            return Err(failure_with_cause(message.as_str(), Some(&error)));
198        }
199    };
200    if complete_length < total_length {
201        file.set_len(complete_length)
202            .map_err(|error| return raw_failure(&error))?;
203        file.sync_all()
204            .map_err(|error| return raw_failure(&error))?;
205    }
206    if !existed {
207        sync_directory(stage_container).map_err(|error| return raw_failure(&error))?;
208    }
209    return Ok(OpenedLog {
210        log: InstallLog { file, path },
211        recorded,
212    });
213}
214
215impl InstallLog {
216    /// Appends one line and waits until it is durable; an empty batch writes nothing.
217    fn append_line(&mut self, line: &str) -> Result<(), WorktreeCopyError> {
218        let written: std::io::Result<()> = self
219            .file
220            .write_all(line.as_bytes())
221            .and_then(|()| return self.file.sync_all());
222        return written.map_err(|error| {
223            let message: String = format!(
224                "cli-git: could not persist worktree-copy install log {}.",
225                quote(self.path.to_string_lossy().as_ref())
226            );
227            return failure_with_cause(message.as_str(), Some(&error));
228        });
229    }
230
231    /// Durably appends the selected paths one batch is about to create.
232    pub fn append_intents(&mut self, relative_paths: &[String]) -> Result<(), WorktreeCopyError> {
233        if relative_paths.is_empty() {
234            return Ok(());
235        }
236        let line: String = format!("{{\"intended\":{}}}\n", string_array(relative_paths));
237        return self.append_line(line.as_str());
238    }
239
240    /// Durably appends the identities of the paths one batch created.
241    pub fn append_creations(&mut self, entries: &[InstalledPath]) -> Result<(), WorktreeCopyError> {
242        if entries.is_empty() {
243            return Ok(());
244        }
245        let encoded: Vec<String> = entries.iter().map(encode_installed_entry).collect();
246        let line: String = format!("{{\"created\":[{}]}}\n", encoded.join(","));
247        return self.append_line(line.as_str());
248    }
249}
250
251/// Install-log controls stay out of the release executable.
252#[cfg(test)]
253#[path = "worktree_copy_install_log_tests.rs"]
254mod tests;