use crate::diagnostic_log::{debug, warn};
use crate::private_storage::{create_private_directory_all, write_private_file_unsynced};
use crate::process_identity::{current_birth_identity, process_birth_identity};
use crate::push_failure_notice::{ORPHAN_NOTICE_DIRECTORY, notice_directory, record_lost_push};
use crate::service_instance::instance_of;
use crate::work_item::{CLAIMED, PENDING, RECOVERING, WORK_DIRECTORY, WorkItem, WorkKind, find_claim, id_of_entry};
use std::path::{Path, PathBuf};
const TAG: &str = "worktreeRemovalRescue";
pub const SNAPSHOT_DIRECTORY: &str = "cli-git/removal-snapshots";
const GIT_DIR_FILE: &str = "gitdir";
const NOTICES: &str = "notices";
const WORK: &str = "work";
const ATTEMPTS: &str = "attempts";
const LOOK_ROUNDS: usize = 2;
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum Removal {
Done,
Refused,
Undecided,
}
fn files_of(directory: &Path) -> Vec<PathBuf> {
let Ok(listing) = std::fs::read_dir(directory) else { return Vec::new() };
let mut paths: Vec<PathBuf> = listing.flatten().map(|entry| { return entry.path(); }).filter(|path| { return path.is_file(); }).collect();
paths.sort();
return paths;
}
fn name_of(path: &Path) -> String {
return path.file_name().map(|name| { return name.to_string_lossy().into_owned(); }).unwrap_or_default();
}
fn save_once(path: &Path, bytes: &[u8]) -> std::io::Result<()> {
return match write_private_file_unsynced(path, bytes) {
Err(error) if error.kind() != std::io::ErrorKind::AlreadyExists => Err(error),
Ok(()) | Err(_) => Ok(()),
};
}
fn copy_into(directory: &Path, name: &str, source: &Path) -> std::io::Result<bool> {
let bytes: Vec<u8> = match std::fs::read(source) {
Ok(read) => read,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(false),
Err(error) => return Err(error),
};
create_private_directory_all(directory)?;
save_once(directory.join(name).as_path(), bytes.as_slice())?;
return Ok(true);
}
fn push_record_names(work_directory: &Path) -> Vec<String> {
let prefix: String = format!("{}-", WorkKind::Push.name());
let mut names: Vec<String> = [PENDING, CLAIMED, RECOVERING]
.into_iter()
.flat_map(|state| { return files_of(work_directory.join(state).as_path()); })
.map(|path| { return id_of_entry(name_of(path.as_path()).as_str()).to_owned(); })
.filter(|name| { return name.starts_with(prefix.as_str()); })
.collect();
names.sort();
names.dedup();
return names;
}
fn locate_push_record(work_directory: &Path, name: &str) -> std::io::Result<Option<(&'static str, Vec<u8>)>> {
for _ in 0..LOOK_ROUNDS {
for state in [PENDING, CLAIMED] {
let path: PathBuf = if state == PENDING {
work_directory.join(PENDING).join(name)
} else {
match find_claim(work_directory, name) {
Some(found) => found,
None => continue,
}
};
match std::fs::read(path) {
Ok(bytes) => return Ok(Some((state, bytes))),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
Err(error) => return Err(error),
}
}
}
return Ok(None);
}
fn register_attempt(snapshot: &Path) -> std::io::Result<()> {
let identity: String = current_birth_identity().map_err(|error| { return std::io::Error::other(error.to_string()); })?;
let directory: PathBuf = snapshot.join(ATTEMPTS);
create_private_directory_all(directory.as_path())?;
let own: PathBuf = directory.join(std::process::id().to_string());
match std::fs::remove_file(own.as_path()) {
Err(error) if error.kind() != std::io::ErrorKind::NotFound => return Err(error),
Ok(()) | Err(_) => {}
}
return write_private_file_unsynced(own.as_path(), identity.as_bytes());
}
fn attempt_may_be_running(snapshot: &Path) -> bool {
return files_of(snapshot.join(ATTEMPTS).as_path()).iter().any(|path| {
let Ok(pid) = name_of(path.as_path()).parse::<i64>() else { return false };
let Ok(recorded) = std::fs::read_to_string(path) else { return true };
if recorded.is_empty() {
return true;
}
return match process_birth_identity(pid) {
Ok(Some(now)) => now == recorded,
Ok(None) => false,
Err(error) => {
warn(TAG, format!("the process {pid} of a worktree removal cannot be inspected: {error}").as_str());
true
}
};
});
}
fn checkout_present(git_dir: &Path) -> Option<bool> {
let text: String = std::fs::read_to_string(git_dir.join("gitdir")).ok()?;
let marker: &Path = Path::new(text.trim_end());
if marker.as_os_str().is_empty() {
return None;
}
return Some(git_dir.join(marker).exists());
}
fn removal_of(git_dir: &Path, snapshot: &Path) -> Removal {
match std::fs::symlink_metadata(git_dir) {
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Removal::Done,
Err(_) => return Removal::Undecided,
Ok(_) => {}
}
if attempt_may_be_running(snapshot) {
return Removal::Undecided;
}
return if checkout_present(git_dir) == Some(true) { Removal::Refused } else { Removal::Undecided };
}
pub fn snapshot_linked_worktrees(common_dir: &Path) -> Vec<String> {
let mut problems: Vec<String> = Vec::new();
let Ok(listing) = std::fs::read_dir(common_dir.join("worktrees")) else { return problems };
for entry in listing.flatten() {
let git_dir: PathBuf = entry.path();
if !git_dir.is_dir() {
continue;
}
let notices: Vec<PathBuf> = files_of(notice_directory(git_dir.as_path()).as_path());
let work_directory: PathBuf = git_dir.join(WORK_DIRECTORY);
let pushes: Vec<String> = push_record_names(work_directory.as_path());
if notices.is_empty() && pushes.is_empty() {
continue;
}
let snapshot: PathBuf = common_dir.join(SNAPSHOT_DIRECTORY).join(instance_of(git_dir.as_path()));
let mut failures: Vec<String> = Vec::new();
match create_private_directory_all(snapshot.as_path()) {
Ok(()) => {
if let Err(error) = register_attempt(snapshot.as_path()) {
failures.push(format!("the command could not be recorded as the one removing it ({error})"));
}
}
Err(error) => failures.push(format!("{} could not be created ({error})", snapshot.display())),
}
if failures.is_empty() {
for path in ¬ices {
if let Err(error) = copy_into(snapshot.join(NOTICES).as_path(), name_of(path.as_path()).as_str(), path.as_path()) {
failures.push(format!("the notice {} could not be copied ({error})", name_of(path.as_path())));
}
}
for name in &pushes {
match locate_push_record(work_directory.as_path(), name.as_str()) {
Ok(Some((state, bytes))) => {
let copied: std::io::Result<()> = create_private_directory_all(snapshot.join(WORK).as_path())
.and_then(|()| { return save_once(snapshot.join(WORK).join(format!("{state}-{name}")).as_path(), bytes.as_slice()); });
if let Err(error) = copied {
failures.push(format!("the push record {name} could not be copied ({error})"));
}
}
Ok(None) => {}
Err(error) => failures.push(format!("the push record {name} could not be read ({error})")),
}
}
if let Err(error) = save_once(snapshot.join(GIT_DIR_FILE).as_path(), git_dir.to_string_lossy().as_bytes()) {
failures.push(format!("the snapshot could not be completed ({error})"));
}
}
if !failures.is_empty() {
let said: String = format!(
"Before this removal, the unshown push failures and unfinished pushes of the worktree {} could not all be saved: {}. \
The command goes on, so whatever was not saved is lost if Git removes that worktree; what was saved is kept in {}.",
git_dir.display(),
failures.join("; "),
snapshot.display()
);
debug(TAG, said.as_str());
problems.push(said);
}
}
return problems;
}
fn promote_notice(orphans: &Path, path: &Path) -> std::io::Result<()> {
create_private_directory_all(orphans)?;
return match std::fs::rename(path, orphans.join(name_of(path))) {
Err(error) if error.kind() != std::io::ErrorKind::NotFound => Err(error),
Ok(()) | Err(_) => Ok(()),
};
}
fn promote(common_dir: &Path, snapshot: &Path, problems: &mut Vec<String>) -> bool {
let orphans: PathBuf = common_dir.join(ORPHAN_NOTICE_DIRECTORY);
let mut complete: bool = true;
for path in files_of(snapshot.join(NOTICES).as_path()) {
if let Err(error) = promote_notice(orphans.as_path(), path.as_path()) {
complete = false;
let said: String = format!(
"The push failure notice {} of a removed worktree could not be kept for you ({error}). It stays in {}; once that is fixed, any git command shows it.",
name_of(path.as_path()),
snapshot.display()
);
debug(TAG, said.as_str());
problems.push(said);
}
}
for path in files_of(snapshot.join(WORK).as_path()) {
let text: String = match std::fs::read_to_string(path.as_path()) {
Ok(read) => read,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue,
Err(error) => {
complete = false;
let said: String = format!("The saved record {} of a removed worktree's unfinished push could not be read ({error}); it stays in {}.", name_of(path.as_path()), snapshot.display());
debug(TAG, said.as_str());
problems.push(said);
continue;
}
};
match WorkItem::decode(text.as_str()) {
Ok(found) => match record_lost_push(common_dir, found.branch_ref.as_str(), found.oid.as_str()) {
Ok(()) => {
if let Err(error) = std::fs::remove_file(path.as_path()) {
warn(TAG, format!("removing the promoted record {} failed: {error}", path.display()).as_str());
}
}
Err(reason) => {
complete = false;
let said: String = format!(
"The backup push of {} for commit {} had not finished when its worktree was removed, and noting that failed ({reason}). \
The record stays in {}; once that is fixed, any git command shows it.",
found.branch_ref,
found.oid,
snapshot.display()
);
debug(TAG, said.as_str());
problems.push(said);
}
},
Err(reason) => {
let said: String = format!("A saved record of an unfinished push of a removed worktree is unreadable ({reason}), so that push is not reported: {}", path.display());
debug(TAG, said.as_str());
problems.push(said);
let _dropped = std::fs::remove_file(path.as_path());
}
}
}
return complete;
}
pub fn settle_removals(common_dir: &Path) -> Vec<String> {
let mut problems: Vec<String> = Vec::new();
let Ok(listing) = std::fs::read_dir(common_dir.join(SNAPSHOT_DIRECTORY)) else { return problems };
for entry in listing.flatten() {
let snapshot: PathBuf = entry.path();
let Ok(named) = std::fs::read_to_string(snapshot.join(GIT_DIR_FILE)) else {
continue;
};
match removal_of(Path::new(named.as_str()), snapshot.as_path()) {
Removal::Undecided => {}
Removal::Refused => {
let _discarded = std::fs::remove_dir_all(snapshot.as_path());
}
Removal::Done => {
if promote(common_dir, snapshot.as_path(), &mut problems) {
let _settled = std::fs::remove_dir_all(snapshot.as_path());
}
}
}
}
return problems;
}
#[cfg(test)]
#[path = "worktree_removal_rescue_tests.rs"]
mod tests;