use std::fs;
use std::path::{Path, PathBuf};
use std::time::{SystemTime, UNIX_EPOCH};
pub const DEFAULT_PERMIT_TTL_SECS: u64 = 120;
pub const MAX_PERMIT_TTL_SECS: u64 = 600;
pub const RECOVERY_PATTERNS: &[&str] = &[
"checkout-discard",
"checkout-ref-discard",
"restore-worktree",
"restore-worktree-explicit",
];
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum RecoveryReason {
RebaseInProgress,
ActivePermit(u64),
}
impl RecoveryReason {
#[must_use]
pub fn label(&self) -> String {
match self {
Self::RebaseInProgress => "rebase in progress".to_string(),
Self::ActivePermit(secs) => format!("active rebase-recovery permit ({secs}s left)"),
}
}
}
#[must_use]
pub fn should_allow_recovery(
cwd: &Path,
pack_id: Option<&str>,
pattern_name: Option<&str>,
) -> Option<RecoveryReason> {
if pack_id != Some("core.git") {
return None;
}
let name = pattern_name?;
if !RECOVERY_PATTERNS.contains(&name) {
return None;
}
if is_rebase_in_progress(cwd) {
return Some(RecoveryReason::RebaseInProgress);
}
if let Some(remaining) = permit_seconds_remaining(cwd) {
return Some(RecoveryReason::ActivePermit(remaining));
}
None
}
#[must_use]
pub fn is_rebase_in_progress(cwd: &Path) -> bool {
let Some(git_dir) = resolve_git_dir(cwd) else {
return false;
};
git_dir.join("rebase-merge").is_dir() || git_dir.join("rebase-apply").is_dir()
}
fn resolve_git_dir(cwd: &Path) -> Option<PathBuf> {
let mut current = cwd.to_path_buf();
loop {
let dot_git = current.join(".git");
if dot_git.is_dir() {
return Some(dot_git);
}
if dot_git.is_file() {
if let Ok(contents) = fs::read_to_string(&dot_git) {
for line in contents.lines() {
if let Some(rest) = line.strip_prefix("gitdir:") {
let path = PathBuf::from(rest.trim());
if path.is_absolute() {
return Some(path);
}
return Some(current.join(path));
}
}
}
return None;
}
if !current.pop() {
return None;
}
}
}
fn permit_path(cwd: &Path) -> PathBuf {
let anchor = resolve_git_dir(cwd)
.and_then(|g| g.parent().map(std::path::Path::to_path_buf))
.unwrap_or_else(|| cwd.to_path_buf());
anchor.join(".dcg").join("rebase-recovery-permit")
}
fn now_epoch_secs() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0)
}
pub fn set_permit(cwd: &Path, ttl_secs: u64) -> std::io::Result<PathBuf> {
let ttl = ttl_secs.clamp(1, MAX_PERMIT_TTL_SECS);
let path = permit_path(cwd);
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
let expires_at = now_epoch_secs().saturating_add(ttl);
fs::write(&path, format!("{expires_at}\n"))?;
Ok(path)
}
#[must_use]
pub fn permit_seconds_remaining(cwd: &Path) -> Option<u64> {
let path = permit_path(cwd);
let contents = fs::read_to_string(&path).ok()?;
let first_line = contents.lines().next()?.trim();
let expires_at: u64 = first_line.parse().ok()?;
let now = now_epoch_secs();
if expires_at > now {
Some(expires_at - now)
} else {
let _ = fs::remove_file(&path);
None
}
}
pub fn consume_permit(cwd: &Path) {
let path = permit_path(cwd);
let _ = fs::remove_file(path);
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
struct FakeRepo {
root: PathBuf,
}
impl FakeRepo {
fn new(label: &str) -> Self {
let base = std::env::temp_dir().join(format!(
"dcg-rebase-recovery-{}-{}-{}",
label,
std::process::id(),
now_epoch_secs()
));
fs::create_dir_all(base.join(".git")).unwrap();
Self { root: base }
}
fn start_rebase_merge(&self) {
fs::create_dir_all(self.root.join(".git").join("rebase-merge")).unwrap();
}
fn start_rebase_apply(&self) {
fs::create_dir_all(self.root.join(".git").join("rebase-apply")).unwrap();
}
}
impl Drop for FakeRepo {
fn drop(&mut self) {
let _ = fs::remove_dir_all(&self.root);
}
}
#[test]
fn is_rebase_in_progress_false_for_clean_repo() {
let repo = FakeRepo::new("clean");
assert!(!is_rebase_in_progress(&repo.root));
}
#[test]
fn is_rebase_in_progress_true_for_rebase_merge() {
let repo = FakeRepo::new("merge");
repo.start_rebase_merge();
assert!(is_rebase_in_progress(&repo.root));
}
#[test]
fn is_rebase_in_progress_true_for_rebase_apply() {
let repo = FakeRepo::new("apply");
repo.start_rebase_apply();
assert!(is_rebase_in_progress(&repo.root));
}
#[test]
fn is_rebase_in_progress_false_outside_repo() {
let dir = std::env::temp_dir().join(format!(
"dcg-no-repo-{}-{}",
std::process::id(),
now_epoch_secs()
));
fs::create_dir_all(&dir).unwrap();
assert!(!is_rebase_in_progress(&dir));
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn should_allow_recovery_blocks_outside_rebase() {
let repo = FakeRepo::new("block-outside");
assert!(
should_allow_recovery(&repo.root, Some("core.git"), Some("checkout-discard")).is_none(),
"recovery must NOT fire outside an active rebase or permit"
);
assert!(
should_allow_recovery(&repo.root, Some("core.git"), Some("restore-worktree")).is_none()
);
}
#[test]
fn should_allow_recovery_fires_during_rebase() {
let repo = FakeRepo::new("allow-rebase");
repo.start_rebase_merge();
assert_eq!(
should_allow_recovery(&repo.root, Some("core.git"), Some("checkout-discard")),
Some(RecoveryReason::RebaseInProgress)
);
assert_eq!(
should_allow_recovery(&repo.root, Some("core.git"), Some("restore-worktree")),
Some(RecoveryReason::RebaseInProgress)
);
}
#[test]
fn should_allow_recovery_ignores_non_recovery_patterns() {
let repo = FakeRepo::new("non-recovery");
repo.start_rebase_merge();
assert!(should_allow_recovery(&repo.root, Some("core.git"), Some("reset-hard")).is_none());
assert!(should_allow_recovery(&repo.root, Some("core.git"), Some("clean-force")).is_none());
assert!(
should_allow_recovery(&repo.root, Some("core.filesystem"), Some("rm-rf-general"))
.is_none()
);
}
#[test]
fn permit_valid_within_ttl() {
let repo = FakeRepo::new("permit-valid");
set_permit(&repo.root, 60).unwrap();
let remaining = permit_seconds_remaining(&repo.root);
assert!(remaining.is_some(), "permit should be active");
let secs = remaining.unwrap();
assert!(secs > 0 && secs <= 60, "remaining={secs}, expected <= 60");
}
#[test]
fn permit_allows_recovery_when_not_in_rebase() {
let repo = FakeRepo::new("permit-allows");
assert!(!is_rebase_in_progress(&repo.root));
assert!(
should_allow_recovery(&repo.root, Some("core.git"), Some("restore-worktree")).is_none()
);
set_permit(&repo.root, 60).unwrap();
let reason = should_allow_recovery(&repo.root, Some("core.git"), Some("restore-worktree"));
assert!(matches!(reason, Some(RecoveryReason::ActivePermit(_))));
}
#[test]
fn permit_expires_correctly() {
let repo = FakeRepo::new("permit-expires");
let path = permit_path(&repo.root);
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).unwrap();
}
let expired_at = now_epoch_secs().saturating_sub(10);
fs::write(&path, format!("{expired_at}\n")).unwrap();
assert!(
permit_seconds_remaining(&repo.root).is_none(),
"expired permit must not be honored"
);
assert!(!path.exists(), "expired permit should be auto-removed");
}
#[test]
fn permit_can_be_consumed() {
let repo = FakeRepo::new("permit-consume");
set_permit(&repo.root, 60).unwrap();
assert!(permit_seconds_remaining(&repo.root).is_some());
consume_permit(&repo.root);
assert!(
permit_seconds_remaining(&repo.root).is_none(),
"consumed permit must not remain valid"
);
}
#[test]
fn permit_ttl_is_clamped() {
let repo = FakeRepo::new("permit-clamp");
set_permit(&repo.root, 60_000).unwrap();
let remaining = permit_seconds_remaining(&repo.root).unwrap();
assert!(
remaining <= MAX_PERMIT_TTL_SECS,
"remaining={remaining} > MAX={MAX_PERMIT_TTL_SECS}"
);
}
#[test]
fn malformed_permit_is_ignored() {
let repo = FakeRepo::new("permit-malformed");
let path = permit_path(&repo.root);
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).unwrap();
}
fs::write(&path, "not-a-number\n").unwrap();
assert!(permit_seconds_remaining(&repo.root).is_none());
}
#[test]
fn recovery_reason_labels_are_informative() {
assert_eq!(
RecoveryReason::RebaseInProgress.label(),
"rebase in progress"
);
let label = RecoveryReason::ActivePermit(45).label();
assert!(label.contains("45"), "label must include seconds: {label}");
assert!(
label.contains("permit"),
"label must mention permit: {label}"
);
}
}