use crate::kept_names_in_the_git_directories::path_for_reading;
use crate::auto_push::PUSH_FAILED_NOTE;
use crate::auto_push_branch_key::encode_branch_key;
use crate::lfs_output_mask::mask_userinfo_text;
pub use crate::push_failure_notice_machine_output::is_machine_read;
use crate::private_storage::{create_private_directory_all, write_private_file_unsynced};
use crate::service_messages::RESTART_COMMAND;
use crate::wrapper_names_old_and_new::DATA_FOLDER;
use std::path::{Path, PathBuf};
pub const NOTICE_DIRECTORY: &str = "monochromatic-git-policy-cli/notices";
pub const ORPHAN_NOTICE_DIRECTORY: &str = "monochromatic-git-policy-cli/orphaned-notices";
const LOST_MAGIC: &str = "monochromatic-git-policy-cli push lost 1";
const NAME_SEPARATOR: char = '+';
const LONGEST_KEY: usize = 100;
const LONGEST_REASON: usize = 4000;
const RECORD_MAGIC: &str = "monochromatic-git-policy-cli push notice 1";
const SKIPPED_MAGIC: &str = "monochromatic-git-policy-cli push skipped 1";
const BRANCH_FIELD: &str = "branch: ";
const COMMIT_FIELD: &str = "commit: ";
const NO_REASON: &str = "Git gave no reason.";
pub fn notice_directory(git_dir: &Path) -> PathBuf {
return path_for_reading(git_dir, NOTICE_DIRECTORY);
}
pub fn fnv_hex(text: &str) -> String {
let mut hash: u64 = 0xcbf2_9ce4_8422_2325;
for byte in text.bytes() {
hash ^= u64::from(byte);
hash = hash.wrapping_mul(0x0100_0000_01b3);
}
return format!("{hash:016x}");
}
fn branch_prefix(branch_ref: &str) -> String {
let key: String = encode_branch_key(branch_ref);
let shown: String = if key.len() > LONGEST_KEY { format!("{}~{}", &key[..LONGEST_KEY], fnv_hex(key.as_str())) } else { key };
return format!("push{NAME_SEPARATOR}{shown}{NAME_SEPARATOR}");
}
pub fn record_name(branch_ref: &str, commit: &str) -> String {
return format!("{}{commit}.txt", branch_prefix(branch_ref));
}
pub fn clean_reason(said: &str) -> String {
let masked: String = mask_userinfo_text(said);
let mut kept: Vec<String> = Vec::new();
for line in masked.lines() {
let mut plain: String = String::new();
for character in line.chars() {
if character == '\t' || !character.is_control() {
plain.push(character);
}
}
let trimmed: &str = plain.trim_end();
if !trimmed.is_empty() {
kept.push(trimmed.to_string());
}
}
let mut text: String = kept.join("\n");
if text.len() > LONGEST_REASON {
let mut cut: usize = LONGEST_REASON;
while !text.is_char_boundary(cut) {
cut -= 1;
}
text.truncate(cut);
text.push_str("\n(Git's output is cut here.)");
}
return text;
}
fn worded(branch_ref: &str, shown: &str, reason: &str) -> String {
let branch: &str = branch_ref.strip_prefix("refs/heads/").unwrap_or(branch_ref);
let detail: String = if reason.is_empty() {
NO_REASON.to_string()
} else {
format!("Git said:\n{}", indented(reason))
};
return format!(
"The backup push of branch {branch} failed for commit {shown}, so that commit exists only on this computer for now. \
It is saved in this repository and nothing was lost.\n{detail}\n\
Fix what Git names, then run `git push`. If it says the remote has commits you do not have, run `git pull` first. \
The commit service runs with its own settings, not your terminal's, so a push that works in your terminal can \
fail here when it needs a variable only your terminal has; give the service such a variable with \
`systemctl --user set-environment NAME=value` and then `{RESTART_COMMAND}`.\n{PUSH_FAILED_NOTE}"
);
}
pub fn push_failure_text(branch_ref: &str, commit: &str, said: &str) -> String {
return worded(branch_ref, commit, clean_reason(said).as_str());
}
fn worded_skipped(branch_ref: &str, shown: &str, reason: &str) -> String {
let branch: &str = branch_ref.strip_prefix("refs/heads/").unwrap_or(branch_ref);
let detail: String = if reason.is_empty() {
String::from("The commit service gave no reason.")
} else {
format!("The commit service said:\n{}", indented(reason))
};
return format!(
"The backup push of branch {branch} for commit {shown} was not made, so that commit exists only on this computer for now. \
It is saved in this repository and nothing was lost.\n{detail}\n\
Either give the branch a destination the commit service can name (the `push.default` setting of `git help config` says \
which values do), or push it yourself with `git push <remote> {branch}`."
);
}
pub fn record_push_skip(git_dir: &Path, branch_ref: &str, commit: &str, said: &str) -> Result<(), String> {
let directory: PathBuf = notice_directory(git_dir);
if let Err(error) = create_private_directory_all(directory.as_path()) {
return Err(format!("creating the notice directory failed: {error}"));
}
let path: PathBuf = directory.join(record_name(branch_ref, commit));
let _removed = std::fs::remove_file(path.as_path());
let text: String = format!("{SKIPPED_MAGIC}\n{BRANCH_FIELD}{branch_ref}\n{COMMIT_FIELD}{commit}\n\n{}", clean_reason(said));
return match write_private_file_unsynced(path.as_path(), text.as_bytes()) {
Ok(()) => Ok(()),
Err(error) => Err(format!("writing the notice failed: {error}")),
};
}
pub fn record_push_failure(git_dir: &Path, branch_ref: &str, commit: &str, said: &str) -> Result<(), String> {
let directory: PathBuf = notice_directory(git_dir);
if let Err(error) = create_private_directory_all(directory.as_path()) {
return Err(format!("creating the notice directory failed: {error}"));
}
let path: PathBuf = directory.join(record_name(branch_ref, commit));
let _removed = std::fs::remove_file(path.as_path());
let text: String = format!("{RECORD_MAGIC}\n{BRANCH_FIELD}{branch_ref}\n{COMMIT_FIELD}{commit}\n\n{}", clean_reason(said));
return match write_private_file_unsynced(path.as_path(), text.as_bytes()) {
Ok(()) => Ok(()),
Err(error) => Err(format!("writing the notice failed: {error}")),
};
}
fn worded_lost(branch_ref: &str, shown: &str) -> String {
let branch: &str = branch_ref.strip_prefix("refs/heads/").unwrap_or(branch_ref);
return format!(
"The backup push of branch {branch} for commit {shown} had not finished when the worktree that made the commit was \
removed, so that commit may exist only on this computer for now. It is saved in this repository and nothing was lost. \
Run `git push` in a worktree that has branch {branch} checked out, or `git push <remote> {branch}` from any worktree."
);
}
pub fn record_lost_push(common_dir: &Path, branch_ref: &str, commit: &str) -> Result<(), String> {
let directory: PathBuf = common_dir.join(ORPHAN_NOTICE_DIRECTORY);
if let Err(error) = create_private_directory_all(directory.as_path()) {
return Err(format!("creating the notice directory failed: {error}"));
}
let path: PathBuf = directory.join(record_name(branch_ref, commit));
if path.exists() {
return Ok(());
}
let text: String = format!("{LOST_MAGIC}\n{BRANCH_FIELD}{branch_ref}\n{COMMIT_FIELD}{commit}\n\n");
return match write_private_file_unsynced(path.as_path(), text.as_bytes()) {
Err(error) if error.kind() != std::io::ErrorKind::AlreadyExists => Err(format!("writing the notice failed: {error}")),
Ok(()) | Err(_) => Ok(()),
};
}
enum RecordKind<'a> {
Failed(&'a str),
Skipped(&'a str),
Lost,
}
fn current_magic_of(line: &str) -> Option<&'static str> {
for current in [RECORD_MAGIC, SKIPPED_MAGIC, LOST_MAGIC] {
let earlier: Option<String> = current.strip_prefix(DATA_FOLDER.current).map(|rest| { return format!("{}{rest}", DATA_FOLDER.earlier); });
if line == current || earlier.as_deref() == Some(line) {
return Some(current);
}
}
return None;
}
fn parse_record(text: &str) -> Option<(&str, &str, RecordKind<'_>)> {
let mut lines = text.splitn(5, '\n');
let magic: &str = current_magic_of(lines.next()?)?;
let branch: &str = lines.next()?.strip_prefix(BRANCH_FIELD)?;
let commit: &str = lines.next()?.strip_prefix(COMMIT_FIELD)?;
if !lines.next()?.is_empty() {
return None;
}
let reason: &str = lines.next().unwrap_or("");
return Some((branch, commit, match magic {
LOST_MAGIC => RecordKind::Lost,
SKIPPED_MAGIC => RecordKind::Skipped(reason),
_ => RecordKind::Failed(reason),
}));
}
pub fn clear_push_failures(git_dir: &Path, branch_ref: &str) {
clear_push_failures_before(git_dir, branch_ref, std::time::SystemTime::now());
}
pub fn clear_push_failures_before(git_dir: &Path, branch_ref: &str, cutoff: std::time::SystemTime) {
let Ok(listing) = std::fs::read_dir(notice_directory(git_dir)) else { return };
let prefix: String = branch_prefix(branch_ref);
for entry in listing.flatten() {
let older: bool = match entry.metadata() {
Ok(meta) => match meta.modified() {
Ok(written) => written <= cutoff,
Err(_) => false,
},
Err(_) => false,
};
if older && entry.file_name().to_string_lossy().starts_with(prefix.as_str()) {
let _removed = std::fs::remove_file(entry.path());
}
}
}
#[derive(Clone, Debug, Default, Eq, PartialEq)]
pub struct Worktrees {
pub here: Option<String>,
pub all: Vec<String>,
}
pub fn branch_of_head(git_dir: &Path) -> Option<String> {
let text: String = std::fs::read_to_string(git_dir.join("HEAD")).ok()?;
let target: &str = text.lines().next()?.strip_prefix("ref: ")?.trim();
if target.starts_with("refs/heads/") {
return Some(target.to_string());
}
return None;
}
pub fn checked_out_branches(common_dir: &Path) -> Vec<String> {
let mut branches: Vec<String> = branch_of_head(common_dir).into_iter().collect();
if let Ok(listing) = std::fs::read_dir(common_dir.join("worktrees")) {
for entry in listing.flatten() {
branches.extend(branch_of_head(entry.path().as_path()));
}
}
return branches;
}
fn is_for(worktrees: &Worktrees, branch_ref: &str) -> bool {
if worktrees.here.as_deref() == Some(branch_ref) {
return true;
}
for held in &worktrees.all {
if held == branch_ref {
return false;
}
}
return true;
}
pub trait CommitNamer {
fn name_of(&self, commit: &str) -> String;
}
pub fn take_push_failures_for(git_dir: &Path, worktrees: Option<&Worktrees>, abbreviate: &dyn CommitNamer) -> Vec<String> {
return take_records_from(notice_directory(git_dir).as_path(), worktrees, abbreviate);
}
pub fn take_orphaned_failures(common_dir: &Path, abbreviate: &dyn CommitNamer) -> Vec<String> {
return take_records_from(common_dir.join(ORPHAN_NOTICE_DIRECTORY).as_path(), None, abbreviate);
}
fn take_records_from(directory: &Path, worktrees: Option<&Worktrees>, abbreviate: &dyn CommitNamer) -> Vec<String> {
let Ok(listing) = std::fs::read_dir(directory) else { return Vec::new() };
let mut paths: Vec<PathBuf> = Vec::new();
for entry in listing.flatten() {
paths.push(entry.path());
}
paths.sort();
let mut texts: Vec<String> = Vec::new();
for path in paths {
let Ok(text) = std::fs::read_to_string(path.as_path()) else { continue };
let parsed: Option<(&str, &str, RecordKind<'_>)> = parse_record(text.as_str());
if let (Some((branch, _, _)), Some(asking)) = (&parsed, worktrees)
&& !is_for(asking, branch)
{
continue;
}
if std::fs::remove_file(path.as_path()).is_err() {
continue;
}
texts.push(match parsed {
Some((branch, commit, RecordKind::Failed(reason))) => worded(branch, abbreviate.name_of(commit).as_str(), reason),
Some((branch, commit, RecordKind::Skipped(reason))) => worded_skipped(branch, abbreviate.name_of(commit).as_str(), reason),
Some((branch, commit, RecordKind::Lost)) => worded_lost(branch, abbreviate.name_of(commit).as_str()),
None => text,
});
}
return texts;
}
pub fn take_push_failures(git_dir: &Path) -> Vec<String> {
return take_push_failures_for(git_dir, None, &FullCommitName);
}
struct FullCommitName;
impl CommitNamer for FullCommitName {
fn name_of(&self, commit: &str) -> String {
return commit.to_string();
}
}
fn indented(reason: &str) -> String {
let mut lines: Vec<String> = Vec::new();
for line in reason.lines() {
lines.push(format!(" {line}"));
}
return lines.join("\n");
}
#[cfg(test)]
#[path = "push_failure_notice_tests.rs"]
mod tests;