use super::*;
use crate::test_support::{fixture, git, git_context, remove, repository};
use crate::worktree_copy_install_entry::InstalledPath;
use crate::worktree_copy_journal::{
Journal, create_journal, ensure_journal_root, read_pending_journals, write_journal,
};
use crate::worktree_copy_lock::{SETTLEMENT_LOCK_NAME, acquire_worktree_copy_lock};
use crate::worktree_copy_selection::CopySelection;
use crate::worktree_copy_stage::{new_stage_container, stage_ignored_snapshot};
use crate::worktree_copy_stage_claim::{claim_stage, stage_claims_exist};
use std::os::unix::fs::PermissionsExt;
fn linked_source(root: &Path) -> (PathBuf, PathBuf) {
let source: PathBuf = repository(root, "source");
std::fs::write(source.join(".gitignore"), b"cache/\n").expect("gitignore");
std::fs::create_dir_all(source.join("cache")).expect("cache");
std::fs::write(source.join("cache/data"), b"payload").expect("data");
let topic: PathBuf = root.join("topic");
git(
source.as_path(),
&[
"worktree",
"add",
"--quiet",
"-b",
"topic",
topic.to_str().expect("utf8"),
],
);
return (
std::fs::canonicalize(source).expect("canonical source"),
std::fs::canonicalize(topic).expect("canonical topic"),
);
}
fn staged_journal(source: &Path, destination: &Path) -> PendingJournal {
let common: PathBuf = std::fs::canonicalize(source.join(".git")).expect("common");
let snapshot: StagedSnapshot =
stage_ignored_snapshot(&git_context(), source, new_stage_container(destination).expect("container").as_path(), &[], &CopySelection::Ignored).expect("staged");
return create_journal(common.as_path(), destination, &snapshot).expect("journal");
}
#[cfg(unix)]
#[test]
fn registration_follows_the_linked_pointer() {
let root: PathBuf = fixture("recovery-registration");
let (source, topic) = linked_source(root.as_path());
let common: PathBuf = std::fs::canonicalize(source.join(".git")).expect("common");
assert_eq!(
destination_registration(common.as_path(), topic.as_path()).expect("registered"),
Registration::Registered
);
let absent: PathBuf = root.join("absent");
assert_eq!(
destination_registration(common.as_path(), absent.as_path()).expect("absent"),
Registration::Unregistered
);
let other: PathBuf = root.join("other");
std::fs::create_dir(other.as_path()).expect("other");
std::fs::write(other.join(".git"), b"gitdir: /nonexistent/admin\n").expect("dangling pointer");
assert_eq!(
destination_registration(common.as_path(), other.as_path()).expect("dangling"),
Registration::Unregistered
);
let plain: PathBuf = root.join("plain");
std::fs::create_dir(plain.as_path()).expect("plain");
std::fs::write(plain.join(".git"), b"not a pointer\n").expect("plain pointer");
assert_eq!(
destination_registration(common.as_path(), plain.as_path()).expect("plain"),
Registration::Unregistered
);
remove(root.as_path());
}
#[cfg(unix)]
#[test]
fn unregistered_destination_is_discarded_with_notice() {
let root: PathBuf = fixture("recovery-unregistered");
let (source, topic) = linked_source(root.as_path());
let pending: PendingJournal = staged_journal(source.as_path(), topic.as_path());
let stage: PathBuf = PathBuf::from(pending.journal.stage_container.as_str());
git(
source.as_path(),
&[
"worktree",
"remove",
"--force",
topic.to_str().expect("utf8"),
],
);
let common: PathBuf = std::fs::canonicalize(source.join(".git")).expect("common");
let report: RecoveryReport =
recover_worktree_copy_transactions(common.as_path()).expect("recovered");
assert_eq!(report.recovered, 0);
assert_eq!(
report.notices,
vec![format!(
"cli-git: discarded an interrupted ignored-state copy for {}, which is no longer a registered worktree.",
quote(topic.to_string_lossy().as_ref())
)]
);
assert!(!stage.exists());
assert!(
read_pending_journals(common.as_path())
.expect("pending")
.is_empty()
);
remove(root.as_path());
}
#[cfg(unix)]
#[test]
fn missing_stage_is_discarded_with_notice() {
let root: PathBuf = fixture("recovery-missing-stage");
let (source, topic) = linked_source(root.as_path());
let pending: PendingJournal = staged_journal(source.as_path(), topic.as_path());
std::fs::remove_dir_all(pending.journal.stage_container.as_str()).expect("stage removed");
let common: PathBuf = std::fs::canonicalize(source.join(".git")).expect("common");
let report: RecoveryReport =
recover_worktree_copy_transactions(common.as_path()).expect("recovered");
assert_eq!(
report.notices,
vec![format!(
"cli-git: discarded an interrupted ignored-state copy into {} because its private stage is missing; ignored files there may be incomplete.",
quote(topic.to_string_lossy().as_ref())
)]
);
assert!(
read_pending_journals(common.as_path())
.expect("pending")
.is_empty()
);
remove(root.as_path());
}
#[cfg(unix)]
#[test]
fn interrupted_installation_is_resumed() {
let root: PathBuf = fixture("recovery-resume");
let (source, topic) = linked_source(root.as_path());
let pending: PendingJournal = staged_journal(source.as_path(), topic.as_path());
let installing: Journal = Journal {
phase: Phase::Installing,
..pending.journal.clone()
};
write_journal(pending.path.as_path(), &installing).expect("installing phase");
let common: PathBuf = std::fs::canonicalize(source.join(".git")).expect("common");
let report: RecoveryReport =
recover_worktree_copy_transactions(common.as_path()).expect("recovered");
assert_eq!(report.recovered, 1);
assert!(report.notices.is_empty(), "{:?}", report.notices);
assert_eq!(
std::fs::read(topic.join("cache/data")).expect("resumed copy"),
b"payload"
);
assert!(
read_pending_journals(common.as_path())
.expect("pending")
.is_empty()
);
remove(root.as_path());
}
#[cfg(unix)]
#[test]
fn completed_journal_only_cleans_up() {
let root: PathBuf = fixture("recovery-complete");
let (source, topic) = linked_source(root.as_path());
let pending: PendingJournal = staged_journal(source.as_path(), topic.as_path());
write_journal(
pending.path.as_path(),
&Journal {
phase: Phase::Complete,
..pending.journal.clone()
},
)
.expect("complete phase");
let common: PathBuf = std::fs::canonicalize(source.join(".git")).expect("common");
let report: RecoveryReport =
recover_worktree_copy_transactions(common.as_path()).expect("recovered");
assert_eq!(report.recovered, 1);
assert!(!topic.join("cache").exists());
assert!(
read_pending_journals(common.as_path())
.expect("pending")
.is_empty()
);
remove(root.as_path());
}
#[cfg(unix)]
#[test]
fn pending_check_takes_the_lock_only_for_a_journal() {
let root: PathBuf = fixture("recovery-pending-check");
let (source, topic) = linked_source(root.as_path());
let common: PathBuf = std::fs::canonicalize(source.join(".git")).expect("common");
recover_pending_worktree_copies(common.as_path(), &mut Vec::new()).expect("nothing pending");
assert!(
!ensure_journal_root(common.as_path())
.expect("root")
.join(SETTLEMENT_LOCK_NAME)
.exists()
);
let pending: PendingJournal = staged_journal(source.as_path(), topic.as_path());
let mut stderr: Vec<u8> = Vec::new();
let held = acquire_worktree_copy_lock(common.as_path(), &mut stderr).expect("held by us");
recover_pending_worktree_copies(common.as_path(), &mut Vec::new())
.expect("held by a live owner");
assert!(
pending.path.exists(),
"a live owner keeps the journal for itself"
);
held.release().expect("released");
remove(root.as_path());
}
#[cfg(unix)]
#[test]
fn recovery_removes_a_stage_no_journal_names() {
let root: PathBuf = fixture("recovery-abandoned-stage");
let (source, topic) = linked_source(root.as_path());
let common: PathBuf = std::fs::canonicalize(source.join(".git")).expect("common");
let claim = claim_stage(common.as_path(), topic.as_path()).expect("claim");
std::fs::create_dir(claim.container()).expect("stage");
std::fs::set_permissions(claim.container(), std::fs::Permissions::from_mode(0o700)).expect("private");
std::fs::write(claim.container().join("half"), b"copied so far").expect("partial copy");
let report = recover_worktree_copy_transactions(common.as_path()).expect("recovered");
assert_eq!(report.recovered, 0);
assert_eq!(report.notices.len(), 1);
assert!(report.notices[0].contains("removed the stage"), "{:?}", report.notices);
assert!(!claim.container().exists());
assert!(!stage_claims_exist(common.as_path()).expect("claims"));
remove(root.as_path());
}
#[cfg(unix)]
#[test]
fn the_pending_check_reaps_abandoned_stages_only_without_a_live_owner() {
let root: PathBuf = fixture("recovery-pending-claim");
let (source, topic) = linked_source(root.as_path());
let common: PathBuf = std::fs::canonicalize(source.join(".git")).expect("common");
let claim = claim_stage(common.as_path(), topic.as_path()).expect("claim");
std::fs::create_dir(claim.container()).expect("stage");
std::fs::set_permissions(claim.container(), std::fs::Permissions::from_mode(0o700)).expect("private");
let mut stderr: Vec<u8> = Vec::new();
let held = acquire_worktree_copy_lock(common.as_path(), &mut stderr).expect("held by us");
recover_pending_worktree_copies(common.as_path(), &mut stderr).expect("held by a live owner");
assert!(claim.container().exists(), "a live copy may still be staging");
held.release().expect("released");
let mut notices: Vec<u8> = Vec::new();
recover_pending_worktree_copies(common.as_path(), &mut notices).expect("reaped");
assert!(!claim.container().exists());
assert!(String::from_utf8_lossy(notices.as_slice()).contains("removed the stage"));
remove(root.as_path());
}
#[test]
fn report_writes_notices_then_summary() {
let mut out: Vec<u8> = Vec::new();
report_recovered(
&RecoveryReport {
recovered: 1,
notices: vec![String::from("cli-git: notice.")],
},
&mut out,
)
.expect("written");
assert_eq!(
String::from_utf8(out).expect("utf8"),
"cli-git: notice.\ncli-git: recovered ignored-state copies for 1 worktree transaction.\n"
);
let mut plural: Vec<u8> = Vec::new();
report_recovered(
&RecoveryReport {
recovered: 2,
notices: Vec::new(),
},
&mut plural,
)
.expect("written");
assert_eq!(
String::from_utf8(plural).expect("utf8"),
"cli-git: recovered ignored-state copies for 2 worktree transactions.\n"
);
}
const INCUMBENT_JOURNAL: &str = "{\"createdEntries\":[{\"device\":\"7\",\"inode\":\"42\",\"relativePath\":\"cache\",\"selected\":true}],\"intendedEntries\":[\"cache\"],\"destinationRoot\":\"@BASE@/topic\",\"phase\":\"installing\",\"selectedRoots\":[\"cache\"],\"sourceRoot\":\"@BASE@/source\",\"stageContainer\":\"@BASE@/.cli-git-worktree-copy-abc\",\"stageRoot\":\"@BASE@/.cli-git-worktree-copy-abc/payload\",\"version\":1}\n";
#[cfg(unix)]
#[test]
fn incumbent_written_journal_is_recovered_natively() {
let root: PathBuf = fixture("recovery-incumbent-journal");
let common: PathBuf = root.join("common");
std::fs::create_dir(common.as_path()).expect("common");
let journal_root: PathBuf = ensure_journal_root(common.as_path()).expect("root");
let base: String = root.to_string_lossy().into_owned();
let stage: PathBuf = root.join(".cli-git-worktree-copy-abc");
{
use std::os::unix::fs::DirBuilderExt;
std::fs::DirBuilder::new()
.recursive(true)
.mode(0o700)
.create(stage.join("payload"))
.expect("private stage");
}
let text: String = INCUMBENT_JOURNAL.replace("@BASE@", base.as_str());
let path: PathBuf = journal_root.join("6ff4c81f-f579-4616-b5d7-4c0694f77b6c.json");
crate::private_storage::write_private_file(path.as_path(), text.as_bytes())
.expect("incumbent journal");
let pending: Vec<PendingJournal> = read_pending_journals(common.as_path()).expect("read");
assert_eq!(pending.len(), 1);
assert_eq!(pending[0].journal.phase, Phase::Installing);
assert_eq!(
pending[0].journal.created_entries,
vec![InstalledPath {
device: 7,
inode: 42,
relative_path: String::from("cache"),
selected: true,
}]
);
let report: RecoveryReport =
recover_worktree_copy_transactions(common.as_path()).expect("recovered");
assert_eq!(
report.notices,
vec![format!(
"cli-git: discarded an interrupted ignored-state copy for {}, which is no longer a registered worktree.",
quote(format!("{base}/topic").as_str())
)]
);
assert!(!stage.exists());
assert!(
read_pending_journals(common.as_path())
.expect("none left")
.is_empty()
);
remove(root.as_path());
}
#[cfg(unix)]
#[test]
fn registration_tells_a_missing_destination_from_an_unreadable_one() {
let root: PathBuf = std::fs::canonicalize(fixture("recovery-registration-faults")).expect("canonical");
let (source, _topic) = linked_source(root.as_path());
let common: PathBuf = std::fs::canonicalize(source.join(".git")).expect("common");
let file: PathBuf = root.join("a-file");
std::fs::write(file.as_path(), b"not a directory").expect("file");
assert_eq!(
destination_registration(common.as_path(), file.join("below").as_path()).expect("under a file"),
Registration::Unregistered
);
let unreadable: PathBuf = root.join("unreadable");
std::fs::create_dir_all(unreadable.join(".git")).expect("a directory where the pointer file belongs");
let failure: WorktreeCopyError =
destination_registration(common.as_path(), unreadable.as_path()).expect_err("a directory is not a pointer");
assert!(failure.0.contains("could not read worktree registration"), "{}", failure.0);
remove(root.as_path());
}
#[cfg(unix)]
#[test]
fn a_stage_that_cannot_be_examined_is_a_fault_and_not_a_missing_stage() {
let root: PathBuf = fixture("recovery-stage-blocked");
let (source, topic) = linked_source(root.as_path());
let mut pending: PendingJournal = staged_journal(source.as_path(), topic.as_path());
let blocker: PathBuf = root.join("blocker");
std::fs::write(blocker.as_path(), b"a file").expect("blocker");
pending.journal.stage_container = blocker.join("stage").to_string_lossy().into_owned();
let failure: WorktreeCopyError = validate_stage(&pending).expect_err("the stage cannot be examined");
assert!(
failure.0.contains("could not validate worktree-copy recovery state"),
"{}",
failure.0
);
remove(root.as_path());
}