use std::{
fs,
path::{Path, PathBuf},
sync::Arc,
time::{Duration, Instant},
};
use color_eyre::eyre::{Result, eyre};
use repon_core::{AutoUpdateAttempt, DeleteRisk, EntityKey, EntityState, Kind, OwnWork};
use crate::config::repo_entry::{self, Edit};
use crate::selection::{RunScope, Targets};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum Operation {
Ignore,
Delete,
Sync,
}
pub(crate) const OPERATIONS: [Operation; 3] =
[Operation::Ignore, Operation::Delete, Operation::Sync];
impl Operation {
pub(crate) fn name(self) -> &'static str {
match self {
Operation::Ignore => "ignore",
Operation::Delete => "delete",
Operation::Sync => "sync",
}
}
pub(crate) fn description(self) -> &'static str {
match self {
Operation::Ignore => "Hide the selected entities, or show them again",
Operation::Delete => "Remove the selected working trees, permanently",
Operation::Sync => "Fast-forward the selected Repos to their tracked upstream",
}
}
pub(crate) fn from_name(name: &str) -> Option<Operation> {
OPERATIONS
.into_iter()
.find(|operation| operation.name() == name)
}
pub(crate) fn widens_to_every_visible_row_when_selection_is_empty(self) -> bool {
match self {
Operation::Sync => true,
Operation::Ignore | Operation::Delete => false,
}
}
pub(crate) fn eligibility(self, entity: &EntityState) -> Eligibility {
match (self, entity.kind) {
(Operation::Ignore, Kind::Repo | Kind::Worktree) => Eligibility::Eligible,
(Operation::Ignore, Kind::Submodule) => {
Eligibility::Refused(Refusal::SubmoduleHasNoEntryOfItsOwn)
}
(Operation::Delete, Kind::Repo | Kind::Worktree) => Eligibility::Eligible,
(Operation::Delete, Kind::Submodule) => {
Eligibility::Refused(Refusal::SubmoduleCannotBeDeleted)
}
(Operation::Sync, Kind::Repo) => Eligibility::Eligible,
(Operation::Sync, Kind::Worktree) => {
Eligibility::Refused(Refusal::WorktreeSyncsThroughItsRepo)
}
(Operation::Sync, Kind::Submodule) => {
Eligibility::Refused(Refusal::SubmoduleCannotSync)
}
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum Eligibility {
Eligible,
Refused(Refusal),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum Refusal {
SubmoduleCannotBeDeleted,
SubmoduleHasNoEntryOfItsOwn,
WorktreeSyncsThroughItsRepo,
SubmoduleCannotSync,
}
impl Refusal {
pub(crate) fn reason(self) -> &'static str {
match self {
Refusal::SubmoduleCannotBeDeleted => {
"a Submodule's git dir lives in its parent; deleting it corrupts the parent"
}
Refusal::SubmoduleHasNoEntryOfItsOwn => {
"a Submodule shares its parent's `[[repo]]` entry and has none of its own"
}
Refusal::WorktreeSyncsThroughItsRepo => {
"sync acts on a Repo's own branch; a Worktree shares it and is not itself \
the target"
}
Refusal::SubmoduleCannotSync => {
"a Submodule tracks a pinned commit, not a branch, so there is nothing to \
fast-forward"
}
}
}
}
#[derive(Debug, Clone)]
pub(crate) struct Target {
pub(crate) key: EntityKey,
pub(crate) name: Arc<str>,
pub(crate) kind: Kind,
pub(crate) common_dir: Arc<Path>,
pub(crate) eligibility: Eligibility,
pub(crate) excluded: bool,
pub(crate) risk: Option<Result<DeleteRisk, String>>,
}
#[derive(Debug, Clone)]
pub(crate) struct Plan {
pub(crate) operation: Operation,
pub(crate) targets: Vec<Target>,
pub(crate) scope: RunScope,
}
impl Plan {
pub(crate) fn new(operation: Operation, entities: &[EntityState], targets: Targets) -> Self {
let mut plan_targets: Vec<Target> = targets
.keys
.iter()
.filter_map(|key| entities.iter().find(|entity| &entity.key == key))
.map(|entity| Target {
key: entity.key.clone(),
name: Arc::clone(&entity.name),
kind: entity.kind,
common_dir: Arc::clone(&entity.common_dir),
eligibility: operation.eligibility(entity),
excluded: entity.excluded,
risk: None,
})
.collect();
if operation == Operation::Delete {
drop_worktrees_covered_by_their_own_selected_parent(&mut plan_targets);
}
Plan {
operation,
targets: plan_targets,
scope: targets.scope,
}
}
pub(crate) fn with_risk(
mut self,
read: impl Fn(&EntityKey) -> std::result::Result<DeleteRisk, String>,
) -> Self {
if self.operation != Operation::Delete {
return self;
}
for target in &mut self.targets {
if target.eligibility == Eligibility::Eligible {
target.risk = Some(read(&target.key));
}
}
self
}
pub(crate) fn eligible_count(&self) -> usize {
self.targets
.iter()
.filter(|target| target.eligibility == Eligibility::Eligible)
.count()
}
pub(crate) fn refused_count(&self) -> usize {
self.targets.len() - self.eligible_count()
}
pub(crate) fn confirm_lines(&self) -> Vec<String> {
let mut lines = vec![headline(
self.operation,
self.scope,
self.eligible_count(),
self.refused_count(),
)];
for target in &self.targets {
lines.push(target_line(self.operation, target));
}
if self.operation == Operation::Delete {
lines.push(NO_UNDO.to_string());
}
lines
}
}
fn drop_worktrees_covered_by_their_own_selected_parent(targets: &mut Vec<Target>) {
let selected_repos: std::collections::HashSet<Arc<Path>> = targets
.iter()
.filter(|target| target.kind == Kind::Repo)
.map(|target| Arc::clone(&target.common_dir))
.collect();
targets.retain(|target| {
target.kind != Kind::Worktree || !selected_repos.contains(&target.common_dir)
});
}
pub(crate) const NO_UNDO: &str = "there is no undo and no trash";
pub(crate) fn running_notice(operation: Operation, scope: RunScope, eligible: usize) -> String {
format!(
"{}: running on {eligible} {}",
operation.name(),
scope.word()
)
}
pub(crate) fn row_notice(
operation: Operation,
name: &str,
position: usize,
total: usize,
) -> String {
format!("{}: {name} ({position}/{total})", operation.name())
}
fn headline(operation: Operation, scope: RunScope, eligible: usize, refused: usize) -> String {
let name = operation.name();
let scope = scope.word();
if refused == 0 {
format!("{name} on {eligible} {scope}?")
} else {
let total = eligible + refused;
format!("{name} on {eligible} of {total} {scope}, {refused} refused?")
}
}
fn target_line(operation: Operation, target: &Target) -> String {
match target.eligibility {
Eligibility::Refused(refusal) => {
format!("{}: refused, {}", target.name, refusal.reason())
}
Eligibility::Eligible => match (operation, &target.risk) {
(Operation::Delete, Some(Ok(risk))) => match risk_phrases(risk, target.kind) {
phrases if phrases.is_empty() => target.name.to_string(),
phrases => format!("{}: {}", target.name, phrases.join(", ")),
},
(Operation::Delete, Some(Err(error))) => {
format!(
"{}: what it would destroy could not be read, {error}",
target.name
)
}
(Operation::Delete, None) | (Operation::Ignore | Operation::Sync, _) => {
target.name.to_string()
}
},
}
}
fn risk_phrases(risk: &DeleteRisk, kind: Kind) -> Vec<String> {
let DeleteRisk {
uncommitted,
unpushed_commits,
unpushed_branches,
linked_worktrees,
} = *risk;
let mut phrases = Vec::new();
if uncommitted {
phrases.push("uncommitted changes".to_string());
}
if unpushed_commits > 0 {
phrases.push(format!(
"{unpushed_commits} {} unpushed on {unpushed_branches} {}",
plural(unpushed_commits, "commit", "commits"),
plural(unpushed_branches, "branch", "branches"),
));
}
if kind == Kind::Repo && linked_worktrees > 0 {
phrases.push(format!(
"{linked_worktrees} linked {}",
plural(linked_worktrees, "worktree", "worktrees")
));
}
phrases
}
fn plural(count: u32, one: &'static str, many: &'static str) -> &'static str {
if count == 1 { one } else { many }
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum Removal {
WorkingTree,
Worktree,
Directory,
}
impl Removal {
fn said(self) -> &'static str {
match self {
Removal::WorkingTree => "working tree removed",
Removal::Worktree => "worktree removed",
Removal::Directory => "directory removed, its parent Repo was unreadable",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum ConfigCleanup {
EntryRemoved,
NoEntryOfItsOwn,
Failed(String),
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum Outcome {
Ignored,
Unignored,
ExcludedByAnInheritedEntry,
Removed {
removal: Removal,
config: ConfigCleanup,
problems: Vec<String>,
},
Synced,
NotEligibleToSync(SyncIneligibility),
Refused(Refusal),
Failed(String),
BeforeSyncHookFailed(String),
SyncedAfterHookFailed(String),
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum HookOutcome {
Passed,
Failed(String),
}
pub(crate) fn hook_outcome_from_receipt(receipt: &repon_core::ActionReceipt) -> HookOutcome {
match receipt.steps.iter().find(|step| step.outcome.is_failure()) {
Some(step) => HookOutcome::Failed(describe_step_failure(step)),
None => HookOutcome::Passed,
}
}
fn describe_step_failure(step: &repon_core::StepResult) -> String {
match &step.outcome {
repon_core::StepOutcome::Failed(code) => format!("`{}` exited {code}", step.label),
repon_core::StepOutcome::OwnWork(own_work) => {
format!("`{}` {}", step.label, own_work.said())
}
repon_core::StepOutcome::Ok
| repon_core::StepOutcome::NotRun
| repon_core::StepOutcome::Cancelled => {
format!("`{}` did not run to a failing exit", step.label)
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum SyncIneligibility {
NotClean,
NoUpstream,
NotBehind,
NotFastForward,
}
impl SyncIneligibility {
pub(crate) fn reason(self) -> &'static str {
match self {
SyncIneligibility::NotClean => "the working tree or index carries a change of its own",
SyncIneligibility::NoUpstream => "no branch, no remote, or no upstream configured",
SyncIneligibility::NotBehind => "already level with its upstream",
SyncIneligibility::NotFastForward => {
"the local branch has a commit its upstream does not"
}
}
}
}
pub(crate) fn own_work(outcome: &Outcome) -> OwnWork {
match outcome {
Outcome::Ignored => OwnWork::Did(Arc::from("ignored")),
Outcome::Unignored => OwnWork::Did(Arc::from("no longer ignored")),
Outcome::Removed {
removal,
config,
problems,
} => OwnWork::Did(Arc::from(removed_words(*removal, config, problems))),
Outcome::ExcludedByAnInheritedEntry => OwnWork::Refused(Arc::from(
"still ignored: the `[[repo]]` entry excluding it names another path",
)),
Outcome::Synced => OwnWork::Did(Arc::from("fast-forwarded to its upstream")),
Outcome::NotEligibleToSync(reason) => OwnWork::Refused(Arc::from(format!(
"not eligible to sync, {}",
reason.reason()
))),
Outcome::Refused(refusal) => {
OwnWork::Refused(Arc::from(format!("refused, {}", refusal.reason())))
}
Outcome::Failed(error) => OwnWork::CouldNotAct(Arc::from(format!("failed, {error}"))),
Outcome::BeforeSyncHookFailed(error) => OwnWork::CouldNotAct(Arc::from(format!(
"before_sync hook failed, sync was not attempted: {error}"
))),
Outcome::SyncedAfterHookFailed(error) => OwnWork::Did(Arc::from(format!(
"fast-forwarded to its upstream; after_sync hook failed: {error}"
))),
}
}
fn removed_words(removal: Removal, config: &ConfigCleanup, problems: &[String]) -> String {
let said = match config {
ConfigCleanup::EntryRemoved => format!("{}, `[[repo]]` entry removed", removal.said()),
ConfigCleanup::NoEntryOfItsOwn => {
format!("{}, no `[[repo]]` entry of its own", removal.said())
}
ConfigCleanup::Failed(error) => format!(
"{}; its `[[repo]]` entry could not be removed: {error}",
removal.said()
),
};
with_problems(said, problems)
}
fn with_problems(mut said: String, problems: &[String]) -> String {
for problem in problems {
said.push_str("; ");
said.push_str(problem);
}
said
}
pub(crate) fn describe(outcome: &Outcome) -> String {
own_work(outcome).said().to_string()
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct Record {
pub(crate) key: EntityKey,
pub(crate) name: Arc<str>,
pub(crate) outcome: Outcome,
pub(crate) removed: Vec<EntityKey>,
pub(crate) elapsed: Duration,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct Report {
pub(crate) operation: Operation,
pub(crate) records: Vec<Record>,
}
impl Report {
pub(crate) fn own_work_records(&self) -> Vec<(EntityKey, OwnWork, Duration)> {
self.records
.iter()
.map(|record| {
(
record.key.clone(),
own_work(&record.outcome),
record.elapsed,
)
})
.collect()
}
pub(crate) fn removed_keys(&self) -> Vec<EntityKey> {
self.records
.iter()
.flat_map(|record| record.removed.iter().cloned())
.collect()
}
pub(crate) fn summary(&self) -> String {
let mut done = 0usize;
let mut refused = 0usize;
let mut unchanged = 0usize;
let mut not_eligible = 0usize;
let mut failed = 0usize;
let mut after_hook_failed = 0usize;
let mut cleanup_unfinished = 0usize;
for record in &self.records {
if matches!(record.outcome, Outcome::SyncedAfterHookFailed(_)) {
after_hook_failed += 1;
}
match &record.outcome {
Outcome::Ignored
| Outcome::Unignored
| Outcome::Synced
| Outcome::SyncedAfterHookFailed(_) => done += 1,
Outcome::Removed {
config, problems, ..
} => {
done += 1;
if matches!(config, ConfigCleanup::Failed(_)) || !problems.is_empty() {
cleanup_unfinished += 1;
}
}
Outcome::ExcludedByAnInheritedEntry => unchanged += 1,
Outcome::NotEligibleToSync(_) => not_eligible += 1,
Outcome::Refused(_) => refused += 1,
Outcome::Failed(_) | Outcome::BeforeSyncHookFailed(_) => failed += 1,
}
}
let mut parts = vec![format!("{done} done")];
if refused > 0 {
parts.push(format!("{refused} refused"));
}
if unchanged > 0 {
parts.push(format!("{unchanged} still ignored by another entry"));
}
if not_eligible > 0 {
parts.push(format!("{not_eligible} not eligible to sync"));
}
if failed > 0 {
parts.push(format!("{failed} failed"));
}
if after_hook_failed > 0 {
parts.push(format!("{after_hook_failed} after_sync hook failed"));
}
if cleanup_unfinished > 0 {
parts.push(format!(
"{cleanup_unfinished} removed with cleanup unfinished"
));
}
format!("{}: {}", self.operation.name(), parts.join(", "))
}
}
pub(crate) fn cancelled_summary(report: &Report, total: usize) -> String {
format!(
"{}, cancelled after {}/{total}",
report.summary(),
report.records.len()
)
}
#[allow(clippy::too_many_arguments)]
pub(crate) fn run_one_record(
plan: &Plan,
target: &Target,
config_file: &Path,
worktree_admin_dir: impl Fn(&EntityKey) -> Option<PathBuf>,
linked_worktree_paths: impl Fn(&EntityKey) -> Vec<PathBuf>,
ignored_directories_for_deletion: impl Fn(&Path) -> Vec<PathBuf>,
attempt_sync: impl Fn(&EntityKey) -> AutoUpdateAttempt,
run_before_sync_hook: impl Fn(&EntityKey) -> Option<HookOutcome>,
run_after_sync_hook: impl Fn(&EntityKey) -> Option<HookOutcome>,
) -> Record {
let started = Instant::now();
let (outcome, removed) = match target.eligibility {
Eligibility::Refused(refusal) => (Outcome::Refused(refusal), Vec::new()),
Eligibility::Eligible => run_one(
plan.operation,
target,
config_file,
&worktree_admin_dir,
&linked_worktree_paths,
&ignored_directories_for_deletion,
&attempt_sync,
&run_before_sync_hook,
&run_after_sync_hook,
)
.unwrap_or_else(|err| (Outcome::Failed(format!("{err:#}")), Vec::new())),
};
Record {
key: target.key.clone(),
name: Arc::clone(&target.name),
outcome,
removed,
elapsed: started.elapsed(),
}
}
#[allow(clippy::too_many_arguments)]
#[allow(dead_code)] pub(crate) fn run(
plan: &Plan,
config_file: &Path,
worktree_admin_dir: impl Fn(&EntityKey) -> Option<PathBuf>,
linked_worktree_paths: impl Fn(&EntityKey) -> Vec<PathBuf>,
ignored_directories_for_deletion: impl Fn(&Path) -> Vec<PathBuf>,
attempt_sync: impl Fn(&EntityKey) -> AutoUpdateAttempt,
run_before_sync_hook: impl Fn(&EntityKey) -> Option<HookOutcome>,
run_after_sync_hook: impl Fn(&EntityKey) -> Option<HookOutcome>,
) -> Report {
let records = plan
.targets
.iter()
.map(|target| {
run_one_record(
plan,
target,
config_file,
&worktree_admin_dir,
&linked_worktree_paths,
&ignored_directories_for_deletion,
&attempt_sync,
&run_before_sync_hook,
&run_after_sync_hook,
)
})
.collect();
Report {
operation: plan.operation,
records,
}
}
#[allow(clippy::too_many_arguments)]
fn run_one(
operation: Operation,
target: &Target,
config_file: &Path,
worktree_admin_dir: &impl Fn(&EntityKey) -> Option<PathBuf>,
linked_worktree_paths: &impl Fn(&EntityKey) -> Vec<PathBuf>,
ignored_directories_for_deletion: &impl Fn(&Path) -> Vec<PathBuf>,
attempt_sync: &impl Fn(&EntityKey) -> AutoUpdateAttempt,
run_before_sync_hook: &impl Fn(&EntityKey) -> Option<HookOutcome>,
run_after_sync_hook: &impl Fn(&EntityKey) -> Option<HookOutcome>,
) -> Result<(Outcome, Vec<EntityKey>)> {
match operation {
Operation::Ignore if target.excluded => {
if repo_entry::write(config_file, target.key.path(), Edit::Unexclude)?.changed {
Ok((Outcome::Unignored, Vec::new()))
} else {
Ok((Outcome::ExcludedByAnInheritedEntry, Vec::new()))
}
}
Operation::Ignore => {
repo_entry::write(config_file, target.key.path(), Edit::Exclude)?;
Ok((Outcome::Ignored, Vec::new()))
}
Operation::Delete => delete_one(
target,
config_file,
worktree_admin_dir,
linked_worktree_paths,
ignored_directories_for_deletion,
),
Operation::Sync => Ok((
sync_one(
target,
attempt_sync,
run_before_sync_hook,
run_after_sync_hook,
),
Vec::new(),
)),
}
}
fn sync_one(
target: &Target,
attempt_sync: &impl Fn(&EntityKey) -> AutoUpdateAttempt,
run_before_sync_hook: &impl Fn(&EntityKey) -> Option<HookOutcome>,
run_after_sync_hook: &impl Fn(&EntityKey) -> Option<HookOutcome>,
) -> Outcome {
if let Some(HookOutcome::Failed(error)) = run_before_sync_hook(&target.key) {
return Outcome::BeforeSyncHookFailed(error);
}
let outcome = match attempt_sync(&target.key) {
AutoUpdateAttempt::Updated => Outcome::Synced,
AutoUpdateAttempt::NotClean => Outcome::NotEligibleToSync(SyncIneligibility::NotClean),
AutoUpdateAttempt::NoUpstream => Outcome::NotEligibleToSync(SyncIneligibility::NoUpstream),
AutoUpdateAttempt::NotBehind => Outcome::NotEligibleToSync(SyncIneligibility::NotBehind),
AutoUpdateAttempt::NotFastForward => {
Outcome::NotEligibleToSync(SyncIneligibility::NotFastForward)
}
AutoUpdateAttempt::Failed(error) => Outcome::Failed(error),
};
if matches!(outcome, Outcome::Synced)
&& let Some(HookOutcome::Failed(error)) = run_after_sync_hook(&target.key)
{
return Outcome::SyncedAfterHookFailed(error);
}
outcome
}
const IGNORED_DIRECTORY_DELETE_WORKERS: usize = 4;
fn delete_ignored_directories(directories: Vec<PathBuf>) -> Vec<PathBuf> {
use rayon::iter::{IntoParallelIterator, ParallelIterator};
if directories.is_empty() {
return Vec::new();
}
let pool = rayon::ThreadPoolBuilder::new()
.num_threads(IGNORED_DIRECTORY_DELETE_WORKERS)
.build()
.expect("build the ignored-directory delete pool");
pool.install(|| {
directories
.into_par_iter()
.filter(|dir| fs::remove_dir_all(dir).is_err())
.collect()
})
}
fn delete_one(
target: &Target,
config_file: &Path,
worktree_admin_dir: &impl Fn(&EntityKey) -> Option<PathBuf>,
linked_worktree_paths: &impl Fn(&EntityKey) -> Vec<PathBuf>,
ignored_directories_for_deletion: &impl Fn(&Path) -> Vec<PathBuf>,
) -> Result<(Outcome, Vec<EntityKey>)> {
match target.kind {
Kind::Repo => {
let mut removed = Vec::new();
let mut problems = Vec::new();
for worktree in linked_worktree_paths(&target.key) {
let key = worktree_key(&worktree);
delete_ignored_directories(ignored_directories_for_deletion(&worktree));
match remove_working_tree(&worktree) {
Ok(()) => removed.push(key),
Err(err) => problems.push(format!(
"its linked Worktree at {} would not remove: {err:#}",
worktree.display()
)),
}
}
delete_ignored_directories(ignored_directories_for_deletion(target.key.path()));
if let Err(err) = remove_working_tree(target.key.path()) {
return Ok((
Outcome::Failed(with_problems(format!("{err:#}"), &problems)),
removed,
));
}
removed.push(target.key.clone());
let config = clean_up_config(config_file, target.key.path());
Ok((
Outcome::Removed {
removal: Removal::WorkingTree,
config,
problems,
},
removed,
))
}
Kind::Worktree => {
let admin_dir = worktree_admin_dir(&target.key);
delete_ignored_directories(ignored_directories_for_deletion(target.key.path()));
remove_working_tree(target.key.path())?;
let mut problems = Vec::new();
if let Some(admin_dir) = &admin_dir
&& let Err(err) = fs::remove_dir_all(admin_dir)
&& err.kind() != std::io::ErrorKind::NotFound
{
problems.push(format!(
"its administrative entry under its parent Repo would not clear: {err}"
));
}
let config = clean_up_config(config_file, target.key.path());
let removal = if admin_dir.is_some() {
Removal::Worktree
} else {
Removal::Directory
};
Ok((
Outcome::Removed {
removal,
config,
problems,
},
vec![target.key.clone()],
))
}
Kind::Submodule => {
unreachable!("a Submodule is always refused before `delete` reaches a row")
}
}
}
fn worktree_key(path: &Path) -> EntityKey {
let resolved = path.canonicalize();
EntityKey::new(Arc::from(resolved.as_deref().unwrap_or(path)))
}
fn clean_up_config(config_file: &Path, path: &Path) -> ConfigCleanup {
match repo_entry::write(config_file, path, Edit::Remove) {
Ok(written) if written.removed_repo_entry => ConfigCleanup::EntryRemoved,
Ok(_) => ConfigCleanup::NoEntryOfItsOwn,
Err(err) => ConfigCleanup::Failed(format!("{err:#}")),
}
}
fn remove_working_tree(path: &Path) -> Result<()> {
if !path.is_absolute() {
return Err(eyre!(
"refusing to delete a relative path: {}",
path.display()
));
}
if !path.join(".git").exists() {
return Err(eyre!(
"refusing to delete {}: no `.git` there, so it is not the Repo this row named",
path.display()
));
}
fs::remove_dir_all(path).map_err(|err| eyre!("could not remove {}: {err}", path.display()))?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use repon_core::EntityKey;
use std::path::PathBuf;
fn spec_source() -> String {
std::fs::read_to_string(
std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../docs/spec/repo-management.md"),
)
.expect("read docs/spec/repo-management.md")
}
fn entity(path: &Path, name: &str, kind: Kind) -> EntityState {
let path: Arc<Path> = Arc::from(path);
EntityState::new(
EntityKey::new(Arc::clone(&path)),
Arc::from(name),
path,
kind,
)
}
fn excluded(mut entity: EntityState) -> EntityState {
entity.excluded = true;
entity
}
fn checked(entities: &[EntityState]) -> Targets {
Targets {
keys: entities.iter().map(|entity| entity.key.clone()).collect(),
scope: RunScope::CheckedRows,
}
}
fn plan(operation: Operation, entities: &[EntityState]) -> Plan {
Plan::new(operation, entities, checked(entities))
}
fn worktree_of(parent: &EntityState, path: &Path, name: &str) -> EntityState {
EntityState::new(
EntityKey::new(Arc::from(path)),
Arc::from(name),
Arc::clone(&parent.common_dir),
Kind::Worktree,
)
}
fn run_plain(plan: &Plan, config_file: &Path) -> Report {
run(
plan,
config_file,
|_| None,
|_| Vec::new(),
|_| Vec::new(),
|_| panic!("run_plain does not exercise sync"),
|_| panic!("run_plain declares no before_sync hook"),
|_| panic!("run_plain declares no after_sync hook"),
)
}
fn removed(removal: Removal, config: ConfigCleanup) -> Outcome {
Outcome::Removed {
removal,
config,
problems: Vec::new(),
}
}
#[test]
fn the_built_in_names_are_repo_management_mds_own_operations_table() {
let spec = spec_source();
let table = spec
.split("## The operations")
.nth(1)
.expect("the operations section is still there");
let declared: Vec<String> = table
.lines()
.take_while(|line| line.starts_with('|') || line.trim().is_empty())
.filter_map(|line| line.split('`').nth(1).map(str::to_string))
.collect();
assert_eq!(
declared,
OPERATIONS
.iter()
.map(|operation| operation.name().to_string())
.collect::<Vec<_>>(),
"the compiled built-ins must be exactly the specification's own operations, in \
its own order"
);
}
#[test]
fn row_notice_names_the_operation_the_row_and_its_position() {
assert_eq!(
row_notice(Operation::Delete, "manage-pr-1358", 3, 12),
"delete: manage-pr-1358 (3/12)"
);
}
#[test]
fn delete_is_refused_on_a_submodule_and_it_is_named_and_counted() {
let entities = vec![
entity(Path::new("/tmp/x/repo"), "repo", Kind::Repo),
entity(Path::new("/tmp/x/sub"), "sub", Kind::Submodule),
];
let plan = plan(Operation::Delete, &entities);
assert_eq!(plan.eligible_count(), 1, "only the Repo is eligible");
assert_eq!(plan.refused_count(), 1, "and the refusal is counted");
let lines = plan.confirm_lines();
assert!(
lines[0].contains('1') && lines[0].contains("1 refused"),
"the headline must carry both counts, got {:?}",
lines[0]
);
let line = lines
.iter()
.find(|line| line.starts_with("sub"))
.unwrap_or_else(|| panic!("no line names sub in {lines:?}"));
assert!(
line.contains("refused") && line.contains(Refusal::SubmoduleCannotBeDeleted.reason()),
"a refusal must name itself and say why, got {line:?}"
);
}
#[test]
fn a_worktree_is_eligible_for_delete_when_its_parent_is_not_also_selected() {
let repo = entity(Path::new("/tmp/x/repo"), "repo", Kind::Repo);
let tree = worktree_of(&repo, Path::new("/tmp/x/tree"), "tree");
let entities = [tree];
let plan = Plan::new(Operation::Delete, &entities, checked(&entities));
assert_eq!(
plan.eligible_count(),
1,
"the Worktree is eligible on its own"
);
assert_eq!(plan.targets.len(), 1);
assert_eq!(plan.targets[0].eligibility, Eligibility::Eligible);
}
#[test]
fn running_delete_removes_the_repo_alone_and_leaves_the_refused_submodule_on_disk() {
let dir = tempfile::tempdir().expect("temp dir");
let config_file = dir.path().join("config.toml");
let made = |name: &str| -> PathBuf {
let path = dir.path().join(name);
std::fs::create_dir_all(path.join(".git")).expect("create a fixture directory");
path
};
let repo = made("repo");
let sub = made("sub");
let entities = vec![
entity(&repo, "repo", Kind::Repo),
entity(&sub, "sub", Kind::Submodule),
];
let report = run_plain(&plan(Operation::Delete, &entities), &config_file);
assert!(!repo.exists(), "the Repo's working tree is gone");
assert!(sub.exists(), "a Submodule is never removed");
assert_eq!(
report
.records
.iter()
.map(|record| (record.name.to_string(), record.outcome.clone()))
.collect::<Vec<_>>(),
vec![
(
"repo".to_string(),
removed(Removal::WorkingTree, ConfigCleanup::NoEntryOfItsOwn)
),
(
"sub".to_string(),
Outcome::Refused(Refusal::SubmoduleCannotBeDeleted)
),
],
"every row is reported, the refusal included"
);
assert!(
report.summary().contains("1 refused"),
"the summary must count the refusal rather than announce a clean run, got {:?}",
report.summary()
);
}
#[test]
fn deleting_a_repo_named_only_by_a_set_says_there_was_no_entry_of_its_own() {
let dir = tempfile::tempdir().expect("temp dir");
let config_file = dir.path().join("config.toml");
let repo = dir.path().join("repo");
std::fs::create_dir_all(repo.join(".git")).expect("create a fixture directory");
std::fs::write(
&config_file,
format!(
"[[set]]\nname = \"one\"\nroots = [\"{root}\"]\ninclude = [\"{repo}\", \
\"**/kept/**\"]\n",
root = dir.path().display(),
repo = repo.display(),
),
)
.expect("write config.toml");
let entities = vec![entity(&repo, "repo", Kind::Repo)];
let report = run_plain(&plan(Operation::Delete, &entities), &config_file);
assert_eq!(
report.records[0].outcome,
removed(Removal::WorkingTree, ConfigCleanup::NoEntryOfItsOwn),
"the Set naming it is not a `[[repo]]` entry of its own"
);
let written = std::fs::read_to_string(&config_file).expect("read config.toml back");
assert!(
!written.contains(&repo.display().to_string()) && written.contains("**/kept/**"),
"the Set stops naming the deleted path and keeps its glob: {written:?}"
);
}
#[test]
fn deleting_a_worktree_removes_its_admin_dir_and_its_own_directory() {
let dir = tempfile::tempdir().expect("temp dir");
let config_file = dir.path().join("config.toml");
let tree = dir.path().join("tree");
std::fs::create_dir_all(tree.join(".git")).expect("create the worktree fixture");
let admin_dir = dir.path().join("admin");
std::fs::create_dir_all(&admin_dir).expect("create the admin dir fixture");
let entities = vec![entity(&tree, "tree", Kind::Worktree)];
let report = run(
&plan(Operation::Delete, &entities),
&config_file,
|_| Some(admin_dir.clone()),
|_| Vec::new(),
|_| Vec::new(),
|_| panic!("this test does not exercise sync"),
|_| panic!("this test declares no before_sync hook"),
|_| panic!("this test declares no after_sync hook"),
);
assert!(!tree.exists(), "the Worktree's own directory is gone");
assert!(!admin_dir.exists(), "its administrative entry is gone too");
assert_eq!(
report.records[0].outcome,
removed(Removal::Worktree, ConfigCleanup::NoEntryOfItsOwn)
);
}
#[test]
fn a_worktree_whose_admin_entry_would_not_clear_is_told_apart_from_a_clean_removal() {
let dir = tempfile::tempdir().expect("temp dir");
let config_file = dir.path().join("config.toml");
let tree = dir.path().join("tree");
std::fs::create_dir_all(tree.join(".git")).expect("create the worktree fixture");
let admin_dir = dir.path().join("admin-that-is-a-file");
std::fs::write(&admin_dir, "not a directory").expect("create the admin fixture");
let entities = vec![entity(&tree, "tree", Kind::Worktree)];
let report = run(
&plan(Operation::Delete, &entities),
&config_file,
|_| Some(admin_dir.clone()),
|_| Vec::new(),
|_| Vec::new(),
|_| panic!("this test does not exercise sync"),
|_| panic!("this test declares no before_sync hook"),
|_| panic!("this test declares no after_sync hook"),
);
assert!(!tree.exists(), "the Worktree's own directory is still gone");
assert!(
matches!(
&report.records[0].outcome,
Outcome::Removed { removal, problems, .. }
if *removal == Removal::Worktree && problems.len() == 1
),
"a removal whose administrative entry would not clear carries what it left, got \
{:?}",
report.records[0].outcome
);
let said = describe(&report.records[0].outcome);
assert!(
said.contains("its administrative entry under its parent Repo would not clear"),
"and the receipt says what was left behind, got {said:?}"
);
assert_eq!(
report.removed_keys(),
vec![entities[0].key.clone()],
"the directory went, so the row still leaves the table"
);
}
#[test]
fn a_removal_whose_config_write_failed_names_the_write_after_the_removal() {
let dir = tempfile::tempdir().expect("temp dir");
let config_file = dir.path().join("config.toml");
std::fs::create_dir(&config_file).expect("put a directory where the config file goes");
let repo = dir.path().join("repo");
std::fs::create_dir_all(repo.join(".git")).expect("create the repo fixture");
let entities = vec![entity(&repo, "repo", Kind::Repo)];
let report = run_plain(&plan(Operation::Delete, &entities), &config_file);
assert!(!repo.exists(), "the working tree is genuinely gone");
let said = describe(&report.records[0].outcome);
assert!(
said.starts_with("working tree removed; its `[[repo]]` entry could not be removed: "),
"the receipt names the removal, then the write that did not finish, got {said:?}"
);
assert_eq!(
report.removed_keys(),
vec![entities[0].key.clone()],
"the row leaves on the removal, never on the write"
);
assert_eq!(
report.summary(),
"delete: 1 done, 1 removed with cleanup unfinished",
"and the completion carries both halves"
);
}
#[test]
fn a_worktree_whose_admin_entry_was_already_pruned_reads_as_a_clean_removal() {
let dir = tempfile::tempdir().expect("temp dir");
let config_file = dir.path().join("config.toml");
let tree = dir.path().join("tree");
std::fs::create_dir_all(tree.join(".git")).expect("create the worktree fixture");
let admin_dir = dir.path().join("admin-that-was-never-there");
let entities = vec![entity(&tree, "tree", Kind::Worktree)];
let report = run(
&plan(Operation::Delete, &entities),
&config_file,
|_| Some(admin_dir.clone()),
|_| Vec::new(),
|_| Vec::new(),
|_| panic!("this test does not exercise sync"),
|_| panic!("this test declares no before_sync hook"),
|_| panic!("this test declares no after_sync hook"),
);
assert!(!tree.exists(), "the Worktree's own directory is gone");
assert_eq!(
report.records[0].outcome,
removed(Removal::Worktree, ConfigCleanup::NoEntryOfItsOwn),
"nothing was left behind, so nothing is named"
);
assert_eq!(
report.summary(),
"delete: 1 done",
"and the completion counts one plain removal"
);
}
#[test]
fn deleting_a_worktree_removes_the_working_tree_before_the_admin_dir() {
let dir = tempfile::tempdir().expect("temp dir");
let config_file = dir.path().join("config.toml");
let tree = dir.path().join("tree");
std::fs::create_dir_all(&tree).expect("create a worktree fixture with no .git marker");
let admin_dir = dir.path().join("admin");
std::fs::create_dir_all(&admin_dir).expect("create the admin dir fixture");
let entities = vec![entity(&tree, "tree", Kind::Worktree)];
let report = run(
&plan(Operation::Delete, &entities),
&config_file,
|_| Some(admin_dir.clone()),
|_| Vec::new(),
|_| Vec::new(),
|_| panic!("this test does not exercise sync"),
|_| panic!("this test declares no before_sync hook"),
|_| panic!("this test declares no after_sync hook"),
);
assert!(
matches!(report.records[0].outcome, Outcome::Failed(_)),
"the working tree's own removal must fail first, got {:?}",
report.records[0].outcome
);
assert!(
admin_dir.exists(),
"the admin dir must still be there: removing the working tree comes first, and \
it never got the chance to succeed"
);
}
#[test]
fn deleting_a_worktree_whose_parent_is_unreachable_falls_back_to_a_directory_removal() {
let dir = tempfile::tempdir().expect("temp dir");
let config_file = dir.path().join("config.toml");
let tree = dir.path().join("tree");
std::fs::create_dir_all(tree.join(".git")).expect("create the worktree fixture");
let entities = vec![entity(&tree, "tree", Kind::Worktree)];
let report = run(
&plan(Operation::Delete, &entities),
&config_file,
|_| None,
|_| Vec::new(),
|_| Vec::new(),
|_| panic!("this test does not exercise sync"),
|_| panic!("this test declares no before_sync hook"),
|_| panic!("this test declares no after_sync hook"),
);
assert!(!tree.exists(), "the Worktree's own directory is still gone");
assert_eq!(
report.records[0].outcome,
removed(Removal::Directory, ConfigCleanup::NoEntryOfItsOwn)
);
}
#[test]
fn deleting_a_repo_removes_every_linked_worktrees_own_directory_too() {
let dir = tempfile::tempdir().expect("temp dir");
let config_file = dir.path().join("config.toml");
let repo = dir.path().join("repo");
std::fs::create_dir_all(repo.join(".git")).expect("create the repo fixture");
let sibling_one = dir.path().join("sibling-one");
let sibling_two = dir.path().join("sibling-two");
std::fs::create_dir_all(sibling_one.join(".git")).expect("create sibling one");
std::fs::create_dir_all(sibling_two.join(".git")).expect("create sibling two");
let entities = vec![entity(&repo, "repo", Kind::Repo)];
let siblings = [sibling_one.clone(), sibling_two.clone()];
let report = run(
&plan(Operation::Delete, &entities),
&config_file,
|_| None,
|_| siblings.to_vec(),
|_| Vec::new(),
|_| panic!("this test does not exercise sync"),
|_| panic!("this test declares no before_sync hook"),
|_| panic!("this test declares no after_sync hook"),
);
assert!(!repo.exists(), "the Repo's own working tree is gone");
assert!(!sibling_one.exists(), "the first linked Worktree is gone");
assert!(!sibling_two.exists(), "the second linked Worktree is gone");
assert_eq!(
report.records[0].outcome,
removed(Removal::WorkingTree, ConfigCleanup::NoEntryOfItsOwn)
);
}
#[test]
fn a_repo_whose_own_removal_fails_still_reports_what_its_cascade_took() {
let dir = tempfile::tempdir().expect("temp dir");
let root = dir
.path()
.canonicalize()
.expect("canonicalize the temp dir");
let config_file = root.join("config.toml");
let repo = root.join("repo");
std::fs::create_dir_all(&repo).expect("create the repo fixture");
let sibling = root.join("sibling");
std::fs::create_dir_all(sibling.join(".git")).expect("create the linked Worktree");
let entities = vec![entity(&repo, "repo", Kind::Repo)];
let siblings = [sibling.clone()];
let report = run(
&plan(Operation::Delete, &entities),
&config_file,
|_| None,
|_| siblings.to_vec(),
|_| Vec::new(),
|_| panic!("this test does not exercise sync"),
|_| panic!("this test declares no before_sync hook"),
|_| panic!("this test declares no after_sync hook"),
);
assert!(!sibling.exists(), "the cascade's own removal is a fact");
assert!(
repo.exists(),
"and the Repo's own working tree is still on disk"
);
assert!(
matches!(report.records[0].outcome, Outcome::Failed(_)),
"the selected row itself failed, got {:?}",
report.records[0].outcome
);
assert_eq!(
report.removed_keys(),
vec![EntityKey::new(Arc::from(sibling.as_path()))],
"and the directory that did go is still what the run dismisses"
);
}
#[test]
fn a_cascade_reports_the_resolved_key_for_a_worktree_git_records_unresolved() {
let dir = tempfile::tempdir().expect("temp dir");
let root = dir
.path()
.canonicalize()
.expect("canonicalize the temp dir");
let config_file = root.join("config.toml");
let repo = root.join("repo");
std::fs::create_dir_all(repo.join(".git")).expect("create the repo fixture");
let sibling = root.join("sibling");
std::fs::create_dir_all(sibling.join(".git")).expect("create the linked Worktree");
let as_recorded = repo.join("..").join("sibling");
let entities = vec![entity(&repo, "repo", Kind::Repo)];
let report = run(
&plan(Operation::Delete, &entities),
&config_file,
|_| None,
|_| vec![as_recorded.clone()],
|_| Vec::new(),
|_| panic!("this test does not exercise sync"),
|_| panic!("this test declares no before_sync hook"),
|_| panic!("this test declares no after_sync hook"),
);
assert!(!sibling.exists(), "the linked Worktree is gone");
assert_eq!(
report.removed_keys(),
vec![
EntityKey::new(Arc::from(sibling.as_path())),
entities[0].key.clone(),
],
"each key names the directory discovery would have keyed the row by"
);
}
#[test]
fn delete_ignored_directories_removes_each_directory_it_is_given() {
let dir = tempfile::tempdir().expect("temp dir");
let node_modules = dir.path().join("node_modules");
std::fs::create_dir_all(node_modules.join("a-package")).expect("create node_modules");
std::fs::write(node_modules.join("a-package").join("index.js"), "x")
.expect("write nested file");
let target = dir.path().join("target");
std::fs::create_dir_all(&target).expect("create target");
let failed = delete_ignored_directories(vec![node_modules.clone(), target.clone()]);
assert!(
failed.is_empty(),
"both directories should remove cleanly, got {failed:?}"
);
assert!(!node_modules.exists());
assert!(!target.exists());
}
#[test]
fn delete_ignored_directories_reports_a_directory_that_will_not_remove_without_stopping_the_rest()
{
let dir = tempfile::tempdir().expect("temp dir");
let missing = dir.path().join("already-gone");
let present = dir.path().join("present");
std::fs::create_dir_all(&present).expect("create present");
let failed = delete_ignored_directories(vec![missing.clone(), present.clone()]);
assert_eq!(failed, vec![missing]);
assert!(
!present.exists(),
"a failure removing one directory must not hold back the rest"
);
}
#[test]
fn delete_ignored_directories_does_nothing_when_given_nothing() {
assert_eq!(
delete_ignored_directories(Vec::new()),
Vec::<PathBuf>::new()
);
}
#[test]
fn deleting_a_worktree_asks_for_ignored_directories_at_the_worktrees_own_path() {
let dir = tempfile::tempdir().expect("temp dir");
let config_file = dir.path().join("config.toml");
let tree = dir.path().join("tree");
std::fs::create_dir_all(tree.join(".git")).expect("create the worktree fixture");
let entities = vec![entity(&tree, "tree", Kind::Worktree)];
let asked = std::cell::RefCell::new(Vec::new());
let report = run(
&plan(Operation::Delete, &entities),
&config_file,
|_| None,
|_| Vec::new(),
|path| {
asked.borrow_mut().push(path.to_path_buf());
Vec::new()
},
|_| panic!("this test does not exercise sync"),
|_| panic!("this test declares no before_sync hook"),
|_| panic!("this test declares no after_sync hook"),
);
assert_eq!(asked.into_inner(), vec![tree.clone()]);
assert_eq!(
report.records[0].outcome,
removed(Removal::Directory, ConfigCleanup::NoEntryOfItsOwn)
);
}
#[test]
fn deleting_a_repo_asks_for_ignored_directories_at_its_own_path_and_every_linked_worktrees() {
let dir = tempfile::tempdir().expect("temp dir");
let config_file = dir.path().join("config.toml");
let repo = dir.path().join("repo");
std::fs::create_dir_all(repo.join(".git")).expect("create the repo fixture");
let sibling = dir.path().join("sibling");
std::fs::create_dir_all(sibling.join(".git")).expect("create the sibling");
let entities = vec![entity(&repo, "repo", Kind::Repo)];
let siblings = [sibling.clone()];
let asked = std::cell::RefCell::new(Vec::new());
let report = run(
&plan(Operation::Delete, &entities),
&config_file,
|_| None,
|_| siblings.to_vec(),
|path| {
asked.borrow_mut().push(path.to_path_buf());
Vec::new()
},
|_| panic!("this test does not exercise sync"),
|_| panic!("this test declares no before_sync hook"),
|_| panic!("this test declares no after_sync hook"),
);
let mut asked = asked.into_inner();
asked.sort();
let mut expected = vec![repo.clone(), sibling.clone()];
expected.sort();
assert_eq!(asked, expected);
assert_eq!(
report.records[0].outcome,
removed(Removal::WorkingTree, ConfigCleanup::NoEntryOfItsOwn)
);
}
#[test]
fn phase_two_and_phase_three_together_remove_the_whole_working_tree() {
let dir = tempfile::tempdir().expect("temp dir");
let config_file = dir.path().join("config.toml");
let tree = dir.path().join("tree");
std::fs::create_dir_all(tree.join(".git")).expect("create the worktree fixture");
let node_modules = tree.join("node_modules");
std::fs::create_dir_all(node_modules.join("a-package")).expect("create node_modules");
std::fs::write(node_modules.join("a-package").join("index.js"), "x")
.expect("write nested file");
std::fs::write(tree.join("source.rs"), "fn main() {}\n").expect("write a tracked file");
let entities = vec![entity(&tree, "tree", Kind::Worktree)];
let report = run(
&plan(Operation::Delete, &entities),
&config_file,
|_| None,
|_| Vec::new(),
|path| vec![path.join("node_modules")],
|_| panic!("this test does not exercise sync"),
|_| panic!("this test declares no before_sync hook"),
|_| panic!("this test declares no after_sync hook"),
);
assert!(
!tree.exists(),
"phase 2 draining node_modules must not stop phase 3 from removing the rest"
);
assert_eq!(
report.records[0].outcome,
removed(Removal::Directory, ConfigCleanup::NoEntryOfItsOwn)
);
}
#[test]
fn a_working_tree_left_behind_after_phase_two_alone_still_deletes_cleanly_on_a_re_run() {
let dir = tempfile::tempdir().expect("temp dir");
let config_file = dir.path().join("config.toml");
let tree = dir.path().join("tree");
std::fs::create_dir_all(tree.join(".git")).expect("create the worktree fixture");
let node_modules = tree.join("node_modules");
std::fs::create_dir_all(node_modules.join("a-package")).expect("create node_modules");
let failed = delete_ignored_directories(vec![node_modules.clone()]);
assert!(failed.is_empty());
assert!(!node_modules.exists());
assert!(
tree.exists(),
"the crash this stands in for happens before phase 3 ever runs"
);
let entities = vec![entity(&tree, "tree", Kind::Worktree)];
let report = run(
&plan(Operation::Delete, &entities),
&config_file,
|_| None,
|_| Vec::new(),
|_| Vec::new(),
|_| panic!("this test does not exercise sync"),
|_| panic!("this test declares no before_sync hook"),
|_| panic!("this test declares no after_sync hook"),
);
assert!(
!tree.exists(),
"the re-run finishes the removal the interrupted first run left half done"
);
assert_eq!(
report.records[0].outcome,
removed(Removal::Directory, ConfigCleanup::NoEntryOfItsOwn)
);
}
#[test]
fn a_worktree_selected_alongside_its_parent_repo_is_not_named_as_its_own_target() {
let repo = entity(Path::new("/tmp/x/repo"), "repo", Kind::Repo);
let tree = worktree_of(&repo, Path::new("/tmp/x/tree"), "tree");
let entities = vec![repo, tree];
let plan = plan(Operation::Delete, &entities);
assert_eq!(
plan.targets.len(),
1,
"the Worktree covered by its selected parent must not be its own target"
);
assert_eq!(plan.targets[0].name.as_ref(), "repo");
assert_eq!(plan.eligible_count(), 1);
}
#[test]
fn a_worktree_whose_parent_is_not_selected_keeps_its_own_target() {
let repo = entity(Path::new("/tmp/x/repo"), "repo", Kind::Repo);
let tree = worktree_of(&repo, Path::new("/tmp/x/tree"), "tree");
let entities = vec![tree.clone()];
let plan = Plan::new(Operation::Delete, &entities, checked(&entities));
assert_eq!(plan.targets.len(), 1);
assert_eq!(plan.targets[0].name.as_ref(), "tree");
}
fn delete_plan_with(risk: DeleteRisk) -> Plan {
let entities = vec![entity(Path::new("/tmp/x/repo"), "repo", Kind::Repo)];
plan(Operation::Delete, &entities).with_risk(|_| Ok(risk))
}
#[test]
fn a_repo_with_none_of_the_three_risks_is_listed_plainly() {
let plan = delete_plan_with(DeleteRisk {
uncommitted: false,
unpushed_commits: 0,
unpushed_branches: 0,
linked_worktrees: 0,
});
let lines = plan.confirm_lines();
assert_eq!(
lines[1], "repo",
"a Repo with nothing to lose is its name and nothing else, got {lines:?}"
);
}
#[test]
fn each_risk_line_appears_only_when_its_own_fact_is_true() {
let none = DeleteRisk {
uncommitted: false,
unpushed_commits: 0,
unpushed_branches: 0,
linked_worktrees: 0,
};
let uncommitted = delete_plan_with(DeleteRisk {
uncommitted: true,
..none
})
.confirm_lines()[1]
.clone();
assert_eq!(uncommitted, "repo: uncommitted changes");
let unpushed = delete_plan_with(DeleteRisk {
unpushed_commits: 3,
unpushed_branches: 2,
..none
})
.confirm_lines()[1]
.clone();
assert_eq!(unpushed, "repo: 3 commits unpushed on 2 branches");
let worktrees = delete_plan_with(DeleteRisk {
linked_worktrees: 1,
..none
})
.confirm_lines()[1]
.clone();
assert_eq!(worktrees, "repo: 1 linked worktree");
let all_three = delete_plan_with(DeleteRisk {
uncommitted: true,
unpushed_commits: 1,
unpushed_branches: 1,
linked_worktrees: 2,
})
.confirm_lines()[1]
.clone();
assert_eq!(
all_three,
"repo: uncommitted changes, 1 commit unpushed on 1 branch, 2 linked worktrees"
);
}
#[test]
fn a_worktrees_own_gate_line_never_names_a_linked_worktree_count() {
let tree = entity(Path::new("/tmp/x/tree"), "tree", Kind::Worktree);
let plan = plan(Operation::Delete, &[tree]).with_risk(|_| {
Ok(DeleteRisk {
uncommitted: true,
unpushed_commits: 0,
unpushed_branches: 0,
linked_worktrees: 3,
})
});
let line = plan.confirm_lines()[1].clone();
assert_eq!(
line, "tree: uncommitted changes",
"a Worktree's own family size is not this row's own risk, got {line:?}"
);
}
#[test]
fn a_risk_that_could_not_be_read_is_said_rather_than_reported_as_nothing() {
let entities = vec![entity(Path::new("/tmp/x/repo"), "repo", Kind::Repo)];
let plan =
plan(Operation::Delete, &entities).with_risk(|_| Err("the refs would not list".into()));
let line = plan.confirm_lines()[1].clone();
assert!(
line.contains("could not be read") && line.contains("the refs would not list"),
"got {line:?}"
);
}
#[test]
fn the_no_undo_sentence_is_repo_management_mds_own_words() {
let spec = spec_source();
let sentence = spec
.split("A Repo with none of the three is listed plainly. ")
.nth(1)
.and_then(|rest| rest.split(", which the gate says in as many words").next())
.expect("repo-management.md still names the sentence the gate must say");
let mut characters = sentence.chars();
let lowercased = match characters.next() {
Some(first) => first.to_lowercase().to_string() + characters.as_str(),
None => String::new(),
};
assert_eq!(
NO_UNDO, lowercased,
"the constant must be the specification's own sentence"
);
}
#[test]
fn the_delete_gate_says_there_is_no_undo_and_ignore_adds_no_lines_at_all() {
let entities = vec![entity(Path::new("/tmp/x/repo"), "repo", Kind::Repo)];
let deleting = plan(Operation::Delete, &entities).confirm_lines();
assert_eq!(
deleting.last().map(String::as_str),
Some(NO_UNDO),
"the gate has to say it in as many words, got {deleting:?}"
);
let ignoring = plan(Operation::Ignore, &entities).confirm_lines();
assert_eq!(
ignoring,
vec!["ignore on 1 selected?".to_string(), "repo".to_string()],
"neither destroys anything, so neither gets an additional line"
);
}
#[test]
fn ignore_is_eligible_on_an_excluded_row_and_a_listed_one_alike() {
let plain = entity(Path::new("/tmp/x/a"), "a", Kind::Repo);
let already = excluded(entity(Path::new("/tmp/x/b"), "b", Kind::Repo));
assert_eq!(Operation::Ignore.eligibility(&plain), Eligibility::Eligible);
assert_eq!(
Operation::Ignore.eligibility(&already),
Eligibility::Eligible
);
}
#[test]
fn a_worktree_is_eligible_to_ignore_and_a_submodule_is_not() {
let worktree = entity(Path::new("/tmp/x/tree"), "tree", Kind::Worktree);
let submodule = entity(Path::new("/tmp/x/sub"), "sub", Kind::Submodule);
assert_eq!(
Operation::Ignore.eligibility(&worktree),
Eligibility::Eligible
);
assert_eq!(
Operation::Ignore.eligibility(&submodule),
Eligibility::Refused(Refusal::SubmoduleHasNoEntryOfItsOwn)
);
}
#[test]
fn sync_is_eligible_on_a_repo() {
let repo = entity(Path::new("/tmp/x/repo"), "repo", Kind::Repo);
let eligibility = Operation::Sync.eligibility(&repo);
assert_eq!(eligibility, Eligibility::Eligible);
}
#[test]
fn sync_is_refused_on_a_worktree_and_named_and_counted() {
let worktree = entity(Path::new("/tmp/x/tree"), "tree", Kind::Worktree);
let eligibility = Operation::Sync.eligibility(&worktree);
assert_eq!(
eligibility,
Eligibility::Refused(Refusal::WorktreeSyncsThroughItsRepo)
);
let entities = vec![worktree];
let plan = plan(Operation::Sync, &entities);
assert_eq!(
plan.eligible_count(),
0,
"a Worktree is never eligible for sync"
);
assert_eq!(plan.refused_count(), 1, "and the refusal is counted");
}
#[test]
fn sync_is_refused_on_a_submodule() {
let submodule = entity(Path::new("/tmp/x/sub"), "sub", Kind::Submodule);
let eligibility = Operation::Sync.eligibility(&submodule);
assert_eq!(
eligibility,
Eligibility::Refused(Refusal::SubmoduleCannotSync)
);
}
fn run_with_sync(plan: &Plan, attempt: AutoUpdateAttempt) -> Report {
run(
plan,
Path::new("/tmp/unused-config.toml"),
|_| None,
|_| Vec::new(),
|_| Vec::new(),
move |_| attempt.clone(),
|_| None,
|_| None,
)
}
fn run_with_sync_and_hooks(
plan: &Plan,
attempt: AutoUpdateAttempt,
before_sync: Option<HookOutcome>,
after_sync: Option<HookOutcome>,
) -> Report {
run(
plan,
Path::new("/tmp/unused-config.toml"),
|_| None,
|_| Vec::new(),
|_| Vec::new(),
move |_| attempt.clone(),
move |_| before_sync.clone(),
move |_| after_sync.clone(),
)
}
#[test]
fn sync_updated_becomes_synced() {
let entities = vec![entity(Path::new("/tmp/x/repo"), "repo", Kind::Repo)];
let built = plan(Operation::Sync, &entities);
let report = run_with_sync(&built, AutoUpdateAttempt::Updated);
assert_eq!(report.records[0].outcome, Outcome::Synced);
assert!(
report.summary().contains("1 done"),
"got {:?}",
report.summary()
);
}
#[test]
fn every_auto_update_ineligible_reason_reaches_the_report_as_a_reason() {
let entities = vec![entity(Path::new("/tmp/x/repo"), "repo", Kind::Repo)];
let cases = [
(AutoUpdateAttempt::NotClean, SyncIneligibility::NotClean),
(AutoUpdateAttempt::NoUpstream, SyncIneligibility::NoUpstream),
(AutoUpdateAttempt::NotBehind, SyncIneligibility::NotBehind),
(
AutoUpdateAttempt::NotFastForward,
SyncIneligibility::NotFastForward,
),
];
for (attempt, expected) in cases {
let built = plan(Operation::Sync, &entities);
let report = run_with_sync(&built, attempt.clone());
assert_eq!(
report.records[0].outcome,
Outcome::NotEligibleToSync(expected),
"attempt {attempt:?} must surface as a reason, never silently"
);
assert!(
own_work(&report.records[0].outcome)
.said()
.contains(expected.reason()),
"the receipt's own words must carry the reason"
);
assert!(
report.summary().contains("1 not eligible to sync"),
"got {:?}",
report.summary()
);
}
}
#[test]
fn sync_failed_becomes_a_failure_never_an_ineligible_reason() {
let entities = vec![entity(Path::new("/tmp/x/repo"), "repo", Kind::Repo)];
let built = plan(Operation::Sync, &entities);
let report = run_with_sync(&built, AutoUpdateAttempt::Failed("git said no".to_string()));
assert!(
matches!(&report.records[0].outcome, Outcome::Failed(message) if message == "git said no")
);
}
#[test]
fn a_failing_before_sync_hook_stops_sync_from_being_attempted() {
let entities = vec![entity(Path::new("/tmp/x/repo"), "repo", Kind::Repo)];
let built = plan(Operation::Sync, &entities);
let report = run(
&built,
Path::new("/tmp/unused-config.toml"),
|_| None,
|_| Vec::new(),
|_| Vec::new(),
|_| panic!("a failing pre-hook must stop sync before attempt_sync is ever called"),
|_| Some(HookOutcome::Failed("exit 1".to_string())),
|_| panic!("a before_sync failure must never reach the after_sync hook either"),
);
assert_eq!(
report.records[0].outcome,
Outcome::BeforeSyncHookFailed("exit 1".to_string())
);
assert!(
matches!(
own_work(&report.records[0].outcome),
OwnWork::CouldNotAct(_)
),
"sync never ran, so this row could not act rather than merely being refused"
);
}
#[test]
fn a_passing_before_sync_hook_still_lets_sync_run() {
let entities = vec![entity(Path::new("/tmp/x/repo"), "repo", Kind::Repo)];
let built = plan(Operation::Sync, &entities);
let report = run_with_sync_and_hooks(
&built,
AutoUpdateAttempt::Updated,
Some(HookOutcome::Passed),
None,
);
assert_eq!(report.records[0].outcome, Outcome::Synced);
}
#[test]
fn a_failing_after_sync_hook_never_undoes_the_fast_forward_it_already_did() {
let entities = vec![entity(Path::new("/tmp/x/repo"), "repo", Kind::Repo)];
let built = plan(Operation::Sync, &entities);
let report = run_with_sync_and_hooks(
&built,
AutoUpdateAttempt::Updated,
None,
Some(HookOutcome::Failed("exit 1".to_string())),
);
assert_eq!(
report.records[0].outcome,
Outcome::SyncedAfterHookFailed("exit 1".to_string())
);
assert!(
matches!(own_work(&report.records[0].outcome), OwnWork::Did(message) if message.contains("fast-forwarded")),
"the fast-forward already happened and must still read as done, got {:?}",
own_work(&report.records[0].outcome)
);
assert!(
report.summary().contains("1 after_sync hook failed"),
"the summary must flag the hook failure rather than folding it silently into \
'done', got {:?}",
report.summary()
);
}
#[test]
fn after_sync_hook_is_not_consulted_when_sync_did_not_fast_forward() {
let entities = vec![entity(Path::new("/tmp/x/repo"), "repo", Kind::Repo)];
let built = plan(Operation::Sync, &entities);
let report = run(
&built,
Path::new("/tmp/unused-config.toml"),
|_| None,
|_| Vec::new(),
|_| Vec::new(),
|_| AutoUpdateAttempt::NotBehind,
|_| None,
|_| panic!("after_sync must never be consulted when sync did not fast-forward"),
);
assert_eq!(
report.records[0].outcome,
Outcome::NotEligibleToSync(SyncIneligibility::NotBehind)
);
}
#[test]
fn a_passing_after_sync_hook_leaves_the_outcome_as_plain_synced() {
let entities = vec![entity(Path::new("/tmp/x/repo"), "repo", Kind::Repo)];
let built = plan(Operation::Sync, &entities);
let report = run_with_sync_and_hooks(
&built,
AutoUpdateAttempt::Updated,
None,
Some(HookOutcome::Passed),
);
assert_eq!(report.records[0].outcome, Outcome::Synced);
}
#[test]
fn hook_outcome_from_receipt_reads_the_first_failing_step() {
use repon_core::{ActionReceipt, StepOutcome, StepResult};
use std::sync::Arc as StdArc;
use std::time::Duration;
let passing = ActionReceipt {
label: StdArc::from("hook"),
steps: StdArc::from(vec![StepResult {
label: StdArc::from("true"),
outcome: StepOutcome::Ok,
output: StdArc::from(&b""[..]),
elapsed: Duration::ZERO,
elision: None,
shell: false,
interactive: false,
}]),
skip: None,
finished_at: repon_core::Timestamp::now(),
running: None,
};
assert_eq!(hook_outcome_from_receipt(&passing), HookOutcome::Passed);
let failing = ActionReceipt {
steps: StdArc::from(vec![StepResult {
label: StdArc::from("false"),
outcome: StepOutcome::Failed(1),
output: StdArc::from(&b""[..]),
elapsed: Duration::ZERO,
elision: None,
shell: false,
interactive: false,
}]),
..passing
};
assert_eq!(
hook_outcome_from_receipt(&failing),
HookOutcome::Failed("`false` exited 1".to_string())
);
}
#[test]
fn running_ignore_twice_returns_the_config_file_byte_for_byte() {
let dir = tempfile::tempdir().expect("temp dir");
let config_file = dir.path().join("config.toml");
let before = "# a comment worth keeping\ntheme = \"default\"\n";
std::fs::write(&config_file, before).expect("write the config file");
let plain = entity(&dir.path().join("repo"), "repo", Kind::Repo);
run_plain(
&plan(Operation::Ignore, std::slice::from_ref(&plain)),
&config_file,
);
let ignored = std::fs::read_to_string(&config_file).expect("read it back");
assert!(ignored.contains("exclude = true"), "got {ignored:?}");
run_plain(&plan(Operation::Ignore, &[excluded(plain)]), &config_file);
assert_eq!(
std::fs::read_to_string(&config_file).expect("read it back"),
before,
"the second `ignore` removes the key the first one wrote"
);
}
#[test]
fn ignore_on_a_row_excluded_by_an_inherited_entry_writes_nothing_and_says_so() {
let dir = tempfile::tempdir().expect("temp dir");
let config_file = dir.path().join("config.toml");
let before = "[[repo]]\npath = \"/somewhere/else\"\nexclude = true\n";
std::fs::write(&config_file, before).expect("write the config file");
let inheriting = excluded(entity(&dir.path().join("tree"), "tree", Kind::Worktree));
let report = run_plain(&plan(Operation::Ignore, &[inheriting]), &config_file);
assert_eq!(
report.records[0].outcome,
Outcome::ExcludedByAnInheritedEntry
);
assert_eq!(
std::fs::read_to_string(&config_file).expect("read it back"),
before,
"the entry naming another path must be left alone"
);
assert!(
report.summary().contains("still ignored by another entry"),
"got {:?}",
report.summary()
);
}
#[test]
fn deleting_refuses_a_relative_path_and_a_directory_that_is_not_a_repository() {
let relative = remove_working_tree(Path::new("relative/repo"))
.expect_err("a relative path must never be removed");
assert!(relative.to_string().contains("relative"));
let dir = tempfile::tempdir().expect("temp dir");
let not_a_repo = dir.path().join("plain-directory");
std::fs::create_dir_all(¬_a_repo).expect("create it");
let refused = remove_working_tree(¬_a_repo)
.expect_err("a directory with no `.git` must never be removed");
assert!(refused.to_string().contains(".git"));
assert!(not_a_repo.exists(), "and it is still there");
}
#[test]
fn every_outcome_maps_to_a_grade_of_own_work_whose_words_the_spec_carries() {
let spec = spec_source();
let receipts = spec
.split("## Receipts")
.nth(1)
.expect("repo-management.md still carries a Receipts section");
let cases = [
(Outcome::Ignored, "Did"),
(Outcome::Unignored, "Did"),
(
removed(Removal::WorkingTree, ConfigCleanup::EntryRemoved),
"Did",
),
(
removed(Removal::WorkingTree, ConfigCleanup::NoEntryOfItsOwn),
"Did",
),
(
removed(Removal::Worktree, ConfigCleanup::EntryRemoved),
"Did",
),
(
removed(Removal::Worktree, ConfigCleanup::NoEntryOfItsOwn),
"Did",
),
(
removed(Removal::Directory, ConfigCleanup::EntryRemoved),
"Did",
),
(
removed(Removal::Directory, ConfigCleanup::NoEntryOfItsOwn),
"Did",
),
(Outcome::ExcludedByAnInheritedEntry, "Refused"),
(
Outcome::Refused(Refusal::SubmoduleHasNoEntryOfItsOwn),
"Refused",
),
(Outcome::Failed("boom".to_string()), "CouldNotAct"),
(Outcome::Synced, "Did"),
(
Outcome::BeforeSyncHookFailed("boom".to_string()),
"CouldNotAct",
),
(Outcome::SyncedAfterHookFailed("boom".to_string()), "Did"),
];
for (outcome, grade) in cases {
let work = own_work(&outcome);
let named = match &work {
OwnWork::Did(_) => "Did",
OwnWork::Refused(_) => "Refused",
OwnWork::CouldNotAct(_) => "CouldNotAct",
};
assert_eq!(named, grade, "{outcome:?} took the wrong grade");
assert_eq!(
describe(&outcome),
work.said().to_string(),
"the log line and the receipt must read the same words for {outcome:?}"
);
assert!(
receipts.contains(&format!("`{grade}`")),
"repo-management.md's Receipts section no longer names the `{grade}` grade"
);
}
}
#[test]
fn the_receipts_own_words_are_repo_management_mds_own() {
let spec = spec_source();
let receipts = spec
.split("## Receipts")
.nth(1)
.expect("repo-management.md still carries a Receipts section");
for outcome in [
Outcome::Ignored,
Outcome::Unignored,
removed(Removal::WorkingTree, ConfigCleanup::EntryRemoved),
removed(Removal::WorkingTree, ConfigCleanup::NoEntryOfItsOwn),
removed(Removal::Worktree, ConfigCleanup::EntryRemoved),
removed(Removal::Worktree, ConfigCleanup::NoEntryOfItsOwn),
removed(Removal::Directory, ConfigCleanup::EntryRemoved),
removed(Removal::Directory, ConfigCleanup::NoEntryOfItsOwn),
Outcome::ExcludedByAnInheritedEntry,
] {
let said = describe(&outcome);
assert!(
receipts.contains(&said),
"repo-management.md's Receipts table does not carry {said:?}"
);
}
}
#[test]
fn every_row_of_a_run_including_a_refusal_becomes_an_own_work_record_for_its_own_entity() {
let dir = tempfile::tempdir().expect("temp dir");
let config_file = dir.path().join("config.toml");
let repo = dir.path().join("repo");
let sub = dir.path().join("sub");
let entities = vec![
entity(&repo, "repo", Kind::Repo),
entity(&sub, "sub", Kind::Submodule),
];
let report = run_plain(&plan(Operation::Ignore, &entities), &config_file);
let records = report.own_work_records();
assert_eq!(records.len(), 2, "every row is recorded, refusals included");
assert_eq!(
records[0].0, entities[0].key,
"in the Selection's own order"
);
assert_eq!(records[1].0, entities[1].key);
assert!(matches!(records[0].1, OwnWork::Did(_)));
assert!(
matches!(&records[1].1, OwnWork::Refused(said) if said.contains("Submodule")),
"the refused row carries the gate's own reason, got {:?}",
records[1].1
);
}
#[test]
fn repo_management_md_records_the_receipt_and_the_register_no_longer_carries_the_gap() {
let spec = spec_source();
assert!(
!spec.contains("Not built."),
"repo-management.md still records the receipt as not built"
);
assert!(
spec.contains("## Receipts"),
"repo-management.md must still own the Receipts section"
);
let manifest_dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR"));
let register = std::fs::read_to_string(manifest_dir.join("../../docs/open-questions.md"))
.expect("read docs/open-questions.md");
assert!(
!register.contains("## A management result has no receipt of its own"),
"the register keeps an entry its owning document has now answered"
);
let actions = std::fs::read_to_string(manifest_dir.join("../../docs/spec/actions.md"))
.expect("read docs/spec/actions.md");
assert!(
actions.contains("A closed set of five."),
"actions.md owns the outcome set and must declare the fifth"
);
assert!(
actions.contains("Why the set grew from four to five"),
"actions.md must say why the set grew, not only that it did"
);
}
#[test]
fn no_match_over_a_step_outcome_anywhere_in_either_crate_has_a_catch_all_arm() {
let mut offending = Vec::new();
let mut matches_checked = 0usize;
for dir in crate::test_support::workspace_crate_src_dirs() {
for path in crate::test_support::rust_source_files(&dir) {
let source = crate::test_support::production_source_at(&path);
for arms in step_outcome_match_arms(&source) {
matches_checked += 1;
for arm in arms {
if is_catch_all(&arm) {
offending.push(format!("{}: {arm}", path.display()));
}
}
}
}
}
assert_eq!(
matches_checked, 7,
"the seven matches over a Step outcome this workspace holds are `is_failure`, \
`is_refusal`, `OwnWork::said`, `step_outcome_word`, `step_outcome_meaning`, \
`finished_step_line` and `describe_step_failure`; a different count means the \
scan has stopped finding them, or an eighth landed and belongs on this list"
);
assert!(
offending.is_empty(),
"a match over a Step outcome reaches a fifth variant through a catch-all rather \
than naming it: {offending:?}"
);
}
fn step_outcome_match_arms(source: &str) -> Vec<Vec<String>> {
let lines: Vec<&str> = source.lines().collect();
let mut blocks = Vec::new();
for (index, line) in lines.iter().enumerate() {
let trimmed = line.trim_start();
if !trimmed.starts_with("match ") || !line.trim_end().ends_with('{') {
continue;
}
let Some(block) = crate::test_support::block_at(source, index) else {
continue;
};
let indent = line.len() - trimmed.len();
let arm_indent = " ".repeat(indent + 4);
let arms: Vec<String> = block
.lines()
.skip(1)
.filter(|arm| {
arm.starts_with(&arm_indent) && !arm[arm_indent.len()..].starts_with(' ')
})
.map(|arm| match arm.split_once("=>") {
Some((pattern, _)) => pattern.trim().to_string(),
None => arm.trim().to_string(),
})
.collect();
if arms
.iter()
.any(|arm| arm.contains("StepOutcome::") || arm.contains("OwnWork::"))
{
blocks.push(arms);
}
}
blocks
}
fn is_catch_all(pattern: &str) -> bool {
pattern
.split('|')
.map(str::trim)
.any(|alternative| alternative == "_" || alternative.starts_with("_ if"))
}
}