use crate::auto_push::PUSH_FAILED_NOTE;
use crate::auto_push_branch_key::encode_branch_key;
use crate::lfs_output_mask::mask_userinfo_text;
use crate::private_storage::{create_private_directory_all, write_private_file_unsynced};
use crate::service_messages::RESTART_COMMAND;
use std::ffi::OsString;
use std::path::{Path, PathBuf};
pub const NOTICE_DIRECTORY: &str = "cli-git/notices";
pub const ORPHAN_NOTICE_DIRECTORY: &str = "cli-git/orphaned-notices";
const LOST_MAGIC: &str = "cli-git push lost 1";
const NAME_SEPARATOR: char = '+';
const LONGEST_KEY: usize = 100;
const LONGEST_REASON: usize = 4000;
const RECORD_MAGIC: &str = "cli-git push notice 1";
const SKIPPED_MAGIC: &str = "cli-git 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 git_dir.join(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 plain: String = line.chars().filter(|character| { return *character == '\t' || !character.is_control(); }).collect();
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{}", reason.lines().map(|line| { return format!(" {line}"); }).collect::<Vec<String>>().join("\n"))
};
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{}", reason.lines().map(|line| { return format!(" {line}"); }).collect::<Vec<String>>().join("\n"))
};
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);
create_private_directory_all(directory.as_path()).map_err(|error| { return 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 write_private_file_unsynced(path.as_path(), text.as_bytes()).map_err(|error| { return 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);
create_private_directory_all(directory.as_path()).map_err(|error| { return 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 write_private_file_unsynced(path.as_path(), text.as_bytes()).map_err(|error| { return 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);
create_private_directory_all(directory.as_path()).map_err(|error| { return 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 parse_record(text: &str) -> Option<(&str, &str, RecordKind<'_>)> {
let mut lines = text.splitn(5, '\n');
let magic: &str = lines.next()?;
if ![RECORD_MAGIC, SKIPPED_MAGIC, LOST_MAGIC].contains(&magic) {
return None;
}
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 = entry.metadata().and_then(|meta| { return meta.modified(); }).is_ok_and(|written| { return written <= cutoff; });
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();
return target.starts_with("refs/heads/").then(|| { return target.to_string(); });
}
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 {
return worktrees.here.as_deref() == Some(branch_ref) || !worktrees.all.iter().any(|held| { return held == branch_ref; });
}
pub fn take_push_failures_for(git_dir: &Path, worktrees: Option<&Worktrees>, abbreviate: &dyn Fn(&str) -> String) -> Vec<String> {
return take_records_from(notice_directory(git_dir).as_path(), worktrees, abbreviate);
}
pub fn take_orphaned_failures(common_dir: &Path, abbreviate: &dyn Fn(&str) -> String) -> 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 Fn(&str) -> String) -> Vec<String> {
let Ok(listing) = std::fs::read_dir(directory) else { return Vec::new() };
let mut paths: Vec<PathBuf> = listing.flatten().map(|entry| { return entry.path(); }).collect();
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(commit).as_str(), reason),
Some((branch, commit, RecordKind::Skipped(reason))) => worded_skipped(branch, abbreviate(commit).as_str(), reason),
Some((branch, commit, RecordKind::Lost)) => worded_lost(branch, abbreviate(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, &|commit| { return commit.to_string(); });
}
const MACHINE_COMMANDS: &[&[u8]] = &[
b"cat-file", b"check-attr", b"check-ignore", b"check-ref-format", b"commit-tree", b"config", b"credential", b"diff-files",
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",
b"ls-tree", b"merge-base", b"mktree", b"name-rev", b"pack-objects", b"read-tree", b"receive-pack", b"rev-list",
b"rev-parse", b"show-ref", b"symbolic-ref", b"unpack-objects", b"update-ref", b"upload-archive", b"upload-pack",
b"var", b"write-tree", b"update-index", b"verify-pack",
];
fn asks_for_machine_output(option: &[u8]) -> bool {
return option == b"-z"
|| option == b"--null"
|| option.starts_with(b"--porcelain")
|| option.starts_with(b"--format")
|| option.starts_with(b"--pretty");
}
pub fn is_machine_read(command: &[u8], region: &[OsString]) -> bool {
if MACHINE_COMMANDS.contains(&command) {
return true;
}
for argument in region {
let bytes: &[u8] = argument.as_encoded_bytes();
if bytes == b"--" {
break;
}
if asks_for_machine_output(bytes) {
return true;
}
}
return false;
}
#[cfg(test)]
#[path = "push_failure_notice_tests.rs"]
mod tests;