Skip to main content

git_policy_cli/
push_failure_notice.rs

1//! What:
2//!  The record of a backup push that failed after its commit was saved, and the rule for when the wrapper shows it:
3//!  the resident service writes one record per failed push under the Git directory of the worktree whose commit it was, a
4//!  later wrapper command in that worktree shows each record once on standard error, and a later push of the same branch
5//!  that succeeds removes the records of that branch.
6//! Why:
7//!  The push runs in the service after the commit command returned, so nothing is attached to the command that could
8//!  print its failure; without a record the person would never learn that their backup push was refused
9//!  (`doc/planning/cli-git-resident-service.md`, "After-commit work"). Records are plain files in the worktree's
10//!  own Git directory so the check on every command is one directory read that finds nothing when nothing failed. A record holds
11//!  the branch, the whole commit name and Git's words with credentials already masked; the wrapper words the notice when it
12//!  shows it, so the commit name is abbreviated the way Git abbreviates it in the repository as it is then. Records are
13//!  shown on standard error only, and not at all to a command whose output a program reads (`--porcelain`, `-z`,
14//!  plumbing), so that program neither sees the text in its output nor consumes the record before a person can read it.
15//!
16//! In TS you'd write (pseudocode):
17//! ```ts
18//! await recordPushFailure(gitDir, { branchRef, oid, reason }); stderr.write(await takePushFailures(gitDir));
19//! ```
20
21use super::auto_push::PUSH_FAILED_NOTE;
22use super::auto_push_branch_key::encode_branch_key;
23use super::lfs_output_mask::mask_userinfo_text;
24use super::private_storage::{create_private_directory_all, write_private_file_unsynced};
25use super::service_messages::RESTART_COMMAND;
26use std::ffi::OsString;
27use std::path::{Path, PathBuf};
28
29/// Directory under the worktree's own Git directory that holds the records for the person.
30pub const NOTICE_DIRECTORY: &str = "cli-git/notices";
31
32/// Directory under the repository's common Git directory that holds the records of removed worktrees, which any worktree's
33///  command shows because the worktree they were made for is gone.
34pub const ORPHAN_NOTICE_DIRECTORY: &str = "cli-git/orphaned-notices";
35
36/// First line of a record of a push that was lost with its worktree; its fields are those of a failure record, without words.
37const LOST_MAGIC: &str = "cli-git push lost 1";
38
39/// Separates the parts of a record's name; it is not a character a branch key can contain.
40const NAME_SEPARATOR: char = '+';
41
42/// The longest branch key a record name carries whole; a longer key is cut and tagged with a hash of the whole key, so the
43///  name stays a valid file name and two long branch names do not share records.
44const LONGEST_KEY: usize = 100;
45
46/// The most bytes of Git's own words a record keeps.
47const LONGEST_REASON: usize = 4000;
48
49/// First line of a record that holds its parts as fields; a file without it is the finished text an earlier release wrote.
50const RECORD_MAGIC: &str = "cli-git push notice 1";
51
52/// The field names of a record's header lines.
53const BRANCH_FIELD: &str = "branch: ";
54const COMMIT_FIELD: &str = "commit: ";
55
56/// Said when Git gave no words at all.
57const NO_REASON: &str = "Git gave no reason.";
58
59/// The directory that holds the records of a repository.
60pub fn notice_directory(git_dir: &Path) -> PathBuf {
61    return git_dir.join(NOTICE_DIRECTORY);
62}
63
64/// A 64-bit FNV-1a hash of the text, in hexadecimal.
65fn fnv_hex(text: &str) -> String {
66    let mut hash: u64 = 0xcbf2_9ce4_8422_2325;
67    for byte in text.bytes() {
68        hash ^= u64::from(byte);
69        hash = hash.wrapping_mul(0x0100_0000_01b3);
70    }
71    return format!("{hash:016x}");
72}
73
74/// The part of every record name that belongs to one branch.
75fn branch_prefix(branch_ref: &str) -> String {
76    let key: String = encode_branch_key(branch_ref);
77    let shown: String = if key.len() > LONGEST_KEY { format!("{}~{}", &key[..LONGEST_KEY], fnv_hex(key.as_str())) } else { key };
78    return format!("push{NAME_SEPARATOR}{shown}{NAME_SEPARATOR}");
79}
80
81/// The file name of the record of one failed push.
82pub fn record_name(branch_ref: &str, commit: &str) -> String {
83    return format!("{}{commit}.txt", branch_prefix(branch_ref));
84}
85
86/// What:
87///  Git's words for the person: the user name and password of every URL masked, control characters dropped, and the
88///  length bounded.
89/// Why:
90///   The text came from a remote and from Git, and is printed on a terminal; a URL with a user name and password, or an
91///  escape sequence, must not reach the screen or a log of it. Only the credentials are masked, never the line around them,
92///  because the line often says why the push failed (the wrapper's own Git LFS failure line names the endpoint setting and
93///  carries the exit status).
94///
95/// In TS you'd write (pseudocode):
96/// ```ts
97/// cleanReason(said)
98/// ```
99pub fn clean_reason(said: &str) -> String {
100    let masked: String = mask_userinfo_text(said);
101    let mut kept: Vec<String> = Vec::new();
102    for line in masked.lines() {
103        let plain: String = line.chars().filter(|character| { return *character == '\t' || !character.is_control(); }).collect();
104        let trimmed: &str = plain.trim_end();
105        if !trimmed.is_empty() {
106            kept.push(trimmed.to_string());
107        }
108    }
109    let mut text: String = kept.join("\n");
110    if text.len() > LONGEST_REASON {
111        let mut cut: usize = LONGEST_REASON;
112        while !text.is_char_boundary(cut) {
113            cut -= 1;
114        }
115        text.truncate(cut);
116        text.push_str("\n(Git's output is cut here.)");
117    }
118    return text;
119}
120
121/// What:
122///  The text shown for a failed backup push, with the commit named as given and Git's words already cleaned.
123/// Why:
124///   It names the branch and the commit, gives Git's own reason, says the commit is safe on this computer, and says what to
125///  do; the service runs with its own settings, which is the first thing to check when a push that works in a terminal
126///  fails here. The command that shows it may be anyone's, so it never calls the commit the reader's own.
127///
128/// In TS you'd write (pseudocode):
129/// ```ts
130/// worded({ branchRef, shown, reason })
131/// ```
132fn worded(branch_ref: &str, shown: &str, reason: &str) -> String {
133    let branch: &str = branch_ref.strip_prefix("refs/heads/").unwrap_or(branch_ref);
134    let detail: String = if reason.is_empty() {
135        NO_REASON.to_string()
136    } else {
137        format!("Git said:\n{}", reason.lines().map(|line| { return format!("    {line}"); }).collect::<Vec<String>>().join("\n"))
138    };
139    return format!(
140        "The backup push of branch {branch} failed for commit {shown}, so that commit exists only on this computer for now. \
141         It is saved in this repository and nothing was lost.\n{detail}\n\
142         Fix what Git names, then run `git push`. If it says the remote has commits you do not have, run `git pull` first. \
143         The commit service runs with its own settings, not your terminal's, so a push that works in your terminal can \
144         fail here when it needs a variable only your terminal has; give the service such a variable with \
145         `systemctl --user set-environment NAME=value` and then `{RESTART_COMMAND}`.\n{PUSH_FAILED_NOTE}"
146    );
147}
148
149/// What:
150///  The text shown for a failed backup push, from Git's raw words.
151/// Why:
152///   The commit is named as the caller gives it, so a caller that has the repository passes Git's own abbreviation.
153///
154/// In TS you'd write (pseudocode):
155/// ```ts
156/// pushFailureText({ branchRef, commit, said })
157/// ```
158pub fn push_failure_text(branch_ref: &str, commit: &str, said: &str) -> String {
159    return worded(branch_ref, commit, clean_reason(said).as_str());
160}
161
162/// What:
163///  Leaves the record of one failed push, replacing an earlier record of the same commit on the same branch.
164/// Why:
165///   The name is derived from the branch and the commit, so a push that is tried again and fails again leaves one record.
166///  The reason is cleaned before it is written, so no credential reaches the disk.
167///
168/// In TS you'd write (pseudocode):
169/// ```ts
170/// await recordPushFailure(gitDir, { branchRef, commit, said })
171/// ```
172pub fn record_push_failure(git_dir: &Path, branch_ref: &str, commit: &str, said: &str) -> Result<(), String> {
173    let directory: PathBuf = notice_directory(git_dir);
174    create_private_directory_all(directory.as_path()).map_err(|error| { return format!("creating the notice directory failed: {error}"); })?;
175    let path: PathBuf = directory.join(record_name(branch_ref, commit));
176    // A repeat of the same failure replaces its record.
177    let _removed = std::fs::remove_file(path.as_path());
178    let text: String = format!("{RECORD_MAGIC}\n{BRANCH_FIELD}{branch_ref}\n{COMMIT_FIELD}{commit}\n\n{}", clean_reason(said));
179    return write_private_file_unsynced(path.as_path(), text.as_bytes()).map_err(|error| { return format!("writing the notice failed: {error}"); });
180}
181
182/// What:
183///  The text shown for a push that had not finished when the worktree that made the commit was removed.
184/// Why:
185///   Nothing says whether the push ran, failed or never started, so the text says only what is known: the commit is saved here
186///  and the branch may not be on the remote.
187fn worded_lost(branch_ref: &str, shown: &str) -> String {
188    let branch: &str = branch_ref.strip_prefix("refs/heads/").unwrap_or(branch_ref);
189    return format!(
190        "The backup push of branch {branch} for commit {shown} had not finished when the worktree that made the commit was \
191         removed, so that commit may exist only on this computer for now. It is saved in this repository and nothing was lost. \
192         Run `git push` in a worktree that has branch {branch} checked out, or `git push <remote> {branch}` from any worktree."
193    );
194}
195
196/// What:
197///  Leaves the record of a push that was lost with its worktree, unless a record of that commit on that branch is already
198///  there.
199/// Why:
200///   An existing record holds Git's own words about a failed push, which say more than this one does.
201///
202/// In TS you'd write (pseudocode):
203/// ```ts
204/// await recordLostPush(commonDir, { branchRef, commit })
205/// ```
206pub fn record_lost_push(common_dir: &Path, branch_ref: &str, commit: &str) -> Result<(), String> {
207    let directory: PathBuf = common_dir.join(ORPHAN_NOTICE_DIRECTORY);
208    create_private_directory_all(directory.as_path()).map_err(|error| { return format!("creating the notice directory failed: {error}"); })?;
209    let path: PathBuf = directory.join(record_name(branch_ref, commit));
210    if path.exists() {
211        return Ok(());
212    }
213    let text: String = format!("{LOST_MAGIC}\n{BRANCH_FIELD}{branch_ref}\n{COMMIT_FIELD}{commit}\n\n");
214    return match write_private_file_unsynced(path.as_path(), text.as_bytes()) {
215        Err(error) if error.kind() != std::io::ErrorKind::AlreadyExists => Err(format!("writing the notice failed: {error}")),
216        Ok(()) | Err(_) => Ok(()),
217    };
218}
219
220/// What a record says besides its branch and commit.
221enum RecordKind<'a> {
222    /// A failed push, with Git's cleaned words.
223    Failed(&'a str),
224    /// A push lost with its worktree.
225    Lost,
226}
227
228/// The branch, the commit and the kind of a record written by this release.
229fn parse_record(text: &str) -> Option<(&str, &str, RecordKind<'_>)> {
230    let mut lines = text.splitn(5, '\n');
231    let lost: bool = match lines.next()? {
232        RECORD_MAGIC => false,
233        LOST_MAGIC => true,
234        _ => return None,
235    };
236    let branch: &str = lines.next()?.strip_prefix(BRANCH_FIELD)?;
237    let commit: &str = lines.next()?.strip_prefix(COMMIT_FIELD)?;
238    if !lines.next()?.is_empty() {
239        return None;
240    }
241    let reason: &str = lines.next().unwrap_or("");
242    return Some((branch, commit, if lost { RecordKind::Lost } else { RecordKind::Failed(reason) }));
243}
244
245/// What:
246///  Removes the records of one branch, after a push of that branch succeeded.
247/// Why:
248///   A successful push of the branch carries every earlier commit of it, so an earlier failure is no longer true.
249///
250/// In TS you'd write (pseudocode):
251/// ```ts
252/// await clearPushFailures(gitDir, branchRef)
253/// ```
254pub fn clear_push_failures(git_dir: &Path, branch_ref: &str) {
255    let Ok(listing) = std::fs::read_dir(notice_directory(git_dir)) else { return };
256    let prefix: String = branch_prefix(branch_ref);
257    for entry in listing.flatten() {
258        if entry.file_name().to_string_lossy().starts_with(prefix.as_str()) {
259            let _removed = std::fs::remove_file(entry.path());
260        }
261    }
262}
263
264/// Where the worktrees of a repository stand: the branch the asking worktree has checked out and every branch any
265///  worktree has checked out.
266#[derive(Clone, Debug, Default, Eq, PartialEq)]
267pub struct Worktrees {
268    /// The branch reference the command's worktree has checked out; nothing for a detached head or an unknown worktree.
269    pub here: Option<String>,
270    /// The branch references checked out in every worktree of the repository.
271    pub all: Vec<String>,
272}
273
274/// What:
275///  The branch reference a `HEAD` file names, or nothing for a detached head or an unreadable file.
276/// Why:
277///   Only the plain `ref: refs/heads/<name>` form names a branch.
278///
279/// In TS you'd write (pseudocode):
280/// ```ts
281/// branchOfHead(gitDir)
282/// ```
283pub fn branch_of_head(git_dir: &Path) -> Option<String> {
284    let text: String = std::fs::read_to_string(git_dir.join("HEAD")).ok()?;
285    let target: &str = text.lines().next()?.strip_prefix("ref: ")?.trim();
286    return target.starts_with("refs/heads/").then(|| { return target.to_string(); });
287}
288
289/// What:
290///  The branches checked out in the repository's worktrees: the main one and each linked one.
291/// Why:
292///   A linked worktree keeps its own `HEAD` under `worktrees/<name>`; the main worktree's is the common directory's.
293///
294/// In TS you'd write (pseudocode):
295/// ```ts
296/// checkedOutBranches(commonDir)
297/// ```
298pub fn checked_out_branches(common_dir: &Path) -> Vec<String> {
299    let mut branches: Vec<String> = branch_of_head(common_dir).into_iter().collect();
300    if let Ok(listing) = std::fs::read_dir(common_dir.join("worktrees")) {
301        for entry in listing.flatten() {
302            branches.extend(branch_of_head(entry.path().as_path()));
303        }
304    }
305    return branches;
306}
307
308/// Whether a record of `branch_ref` is for the asking worktree: it has the branch checked out, or no worktree has.
309fn is_for(worktrees: &Worktrees, branch_ref: &str) -> bool {
310    return worktrees.here.as_deref() == Some(branch_ref) || !worktrees.all.iter().any(|held| { return held == branch_ref; });
311}
312
313/// What:
314///  Reads and removes the records that are for the asking worktree (all of them when no worktree is given), oldest name
315///  first, worded with the commit named by `abbreviate`.
316/// Why:
317///   A record is shown once, to the worktree whose commit it is about; another worktree's command leaves it where it is.
318///  When nothing failed the notice directory does not exist, and this is one directory read.
319///
320/// In TS you'd write (pseudocode):
321/// ```ts
322/// const texts = await takePushFailuresFor(gitDir, worktrees, abbreviate)
323/// ```
324pub fn take_push_failures_for(git_dir: &Path, worktrees: Option<&Worktrees>, abbreviate: &dyn Fn(&str) -> String) -> Vec<String> {
325    return take_records_from(notice_directory(git_dir).as_path(), worktrees, abbreviate);
326}
327
328/// What:
329///  Reads and removes the records of removed worktrees, which belong to no worktree and so are shown to the first command
330///  that asks.
331/// Why:
332///   The worktree a record was made for is gone, so nobody else could ever be shown it; one directory read when none exist.
333///
334/// In TS you'd write (pseudocode):
335/// ```ts
336/// const texts = await takeOrphanedFailures(commonDir, abbreviate)
337/// ```
338pub fn take_orphaned_failures(common_dir: &Path, abbreviate: &dyn Fn(&str) -> String) -> Vec<String> {
339    return take_records_from(common_dir.join(ORPHAN_NOTICE_DIRECTORY).as_path(), None, abbreviate);
340}
341
342/// Reads and removes the records in one directory that are for the asking worktree, oldest name first.
343fn take_records_from(directory: &Path, worktrees: Option<&Worktrees>, abbreviate: &dyn Fn(&str) -> String) -> Vec<String> {
344    let Ok(listing) = std::fs::read_dir(directory) else { return Vec::new() };
345    let mut paths: Vec<PathBuf> = listing.flatten().map(|entry| { return entry.path(); }).collect();
346    paths.sort();
347    let mut texts: Vec<String> = Vec::new();
348    for path in paths {
349        let Ok(text) = std::fs::read_to_string(path.as_path()) else { continue };
350        let parsed: Option<(&str, &str, RecordKind<'_>)> = parse_record(text.as_str());
351        if let (Some((branch, _, _)), Some(asking)) = (&parsed, worktrees)
352            && !is_for(asking, branch)
353        {
354            continue;
355        }
356        // The record is removed before its text is used, so two commands that start together cannot both show it.
357        if std::fs::remove_file(path.as_path()).is_err() {
358            continue;
359        }
360        texts.push(match parsed {
361            Some((branch, commit, RecordKind::Failed(reason))) => worded(branch, abbreviate(commit).as_str(), reason),
362            Some((branch, commit, RecordKind::Lost)) => worded_lost(branch, abbreviate(commit).as_str()),
363            // The finished text an earlier release wrote.
364            None => text,
365        });
366    }
367    return texts;
368}
369
370/// What:
371///  Reads and removes every record, with each commit named in full.
372/// Why:
373///   The service's own controls and a repository with no worktree to ask need all of them.
374///
375/// In TS you'd write (pseudocode):
376/// ```ts
377/// const texts = await takePushFailures(gitDir)
378/// ```
379pub fn take_push_failures(git_dir: &Path) -> Vec<String> {
380    return take_push_failures_for(git_dir, None, &|commit| { return commit.to_string(); });
381}
382
383/// Commands that a program reads the output of, or that carry a protocol on their streams.
384const MACHINE_COMMANDS: &[&[u8]] = &[
385    b"cat-file", b"check-attr", b"check-ignore", b"check-ref-format", b"commit-tree", b"config", b"credential", b"diff-files",
386    b"diff-index", b"diff-tree", b"fast-export", b"fast-import", b"for-each-ref", b"hash-object", b"index-pack", b"ls-files",
387    b"ls-tree", b"merge-base", b"mktree", b"name-rev", b"pack-objects", b"read-tree", b"receive-pack", b"rev-list",
388    b"rev-parse", b"show-ref", b"symbolic-ref", b"unpack-objects", b"update-ref", b"upload-archive", b"upload-pack",
389    b"var", b"write-tree", b"update-index", b"verify-pack",
390];
391
392/// Option spellings that ask a command for output made to be read by a program.
393fn asks_for_machine_output(option: &[u8]) -> bool {
394    return option == b"-z"
395        || option == b"--null"
396        || option.starts_with(b"--porcelain")
397        || option.starts_with(b"--format")
398        || option.starts_with(b"--pretty");
399}
400
401/// What:
402///  Whether a command's output is read by a program, so that no record may be shown to it or taken from it.
403/// Why:
404///   A prompt, an editor or a script runs `git status --porcelain` or `git rev-parse` often and reads every byte; the
405///  record is meant for the person, and the first such call would otherwise take it.
406///
407/// In TS you'd write (pseudocode):
408/// ```ts
409/// isMachineRead(commandWord, region)
410/// ```
411pub fn is_machine_read(command: &[u8], region: &[OsString]) -> bool {
412    if MACHINE_COMMANDS.contains(&command) {
413        return true;
414    }
415    for argument in region {
416        let bytes: &[u8] = argument.as_encoded_bytes();
417        // Everything after the separator is a name, not an option.
418        if bytes == b"--" {
419            break;
420        }
421        if asks_for_machine_output(bytes) {
422            return true;
423        }
424    }
425    return false;
426}
427
428/// Record controls stay out of the release executable.
429#[cfg(test)]
430#[path = "push_failure_notice_tests.rs"]
431mod tests;