use anyhow::Context;
use color_print::cformat;
use std::fs;
use std::path::{Path, PathBuf};
use std::process::Command;
use std::process::Stdio;
use std::str::FromStr;
use strum::IntoEnumIterator;
use worktrunk::git::{HookType, Repository};
use worktrunk::path::{format_path_for_display, sanitize_for_filename};
use worktrunk::utils::epoch_now;
use crate::commands::hook_filter::HookSource;
#[derive(Debug, Clone, Copy, PartialEq, Eq, strum::EnumString, strum::Display, strum::EnumIter)]
#[strum(serialize_all = "kebab-case")]
pub enum InternalOp {
Remove,
TrashSweep,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum HookLog {
Hook {
source: HookSource,
hook_type: HookType,
name: String,
},
Internal(InternalOp),
}
impl HookLog {
pub fn hook(source: HookSource, hook_type: HookType, name: impl Into<String>) -> Self {
Self::Hook {
source,
hook_type,
name: name.into(),
}
}
pub fn internal(op: InternalOp) -> Self {
Self::Internal(op)
}
pub fn path(&self, log_dir: &Path, branch: &str) -> PathBuf {
let branch_dir = log_dir.join(sanitize_for_filename(branch));
match self {
HookLog::Hook {
source,
hook_type,
name,
} => branch_dir
.join(source.to_string())
.join(hook_type.to_string())
.join(format!("{}.log", sanitize_for_filename(name))),
HookLog::Internal(op) => branch_dir.join("internal").join(format!("{op}.log")),
}
}
pub fn to_spec(&self) -> String {
match self {
HookLog::Hook {
source,
hook_type,
name,
} => format!("{}:{}:{}", source, hook_type, name),
HookLog::Internal(op) => format!("internal:{}", op),
}
}
pub fn parse(s: &str) -> Result<Self, String> {
let parts: Vec<&str> = s.split(':').collect();
match parts.as_slice() {
["internal", op_str] => {
let op = InternalOp::from_str(op_str).map_err(|_| {
let valid: Vec<_> = InternalOp::iter().map(|o| o.to_string()).collect();
cformat!(
"Unknown internal operation: <bold>{}</>. Valid: {}",
op_str,
valid.join(", ")
)
})?;
Ok(Self::Internal(op))
}
[source_str, hook_type_str, name] if !name.is_empty() => {
let source = HookSource::from_str(source_str).map_err(|_| {
cformat!(
"Unknown source: <bold>{}</>. Valid: user, project",
source_str
)
})?;
let hook_type = HookType::from_str(hook_type_str).map_err(|_| {
let valid: Vec<_> = HookType::iter().map(|h| h.to_string()).collect();
cformat!(
"Unknown hook type: <bold>{}</>. Valid: {}",
hook_type_str,
valid.join(", ")
)
})?;
Ok(Self::Hook {
source,
hook_type,
name: (*name).to_string(),
})
}
_ => Err(cformat!(
"Invalid log spec: <bold>{}</>. Format: source:hook-type:name or internal:op",
s
)),
}
}
}
fn posix_command_separator(command: &str) -> &'static str {
if command.ends_with('\n') || command.ends_with(';') {
""
} else {
";"
}
}
fn create_detach_log(
repo: &Repository,
branch: &str,
hook_log: &HookLog,
) -> anyhow::Result<(PathBuf, fs::File)> {
let log_dir = repo.wt_logs_dir();
let log_path = hook_log.path(&log_dir, branch);
let parent = log_path
.parent()
.expect("HookLog::path always includes a parent");
fs::create_dir_all(parent).with_context(|| {
format!(
"Failed to create log directory {}",
format_path_for_display(parent)
)
})?;
let log_file = fs::File::create(&log_path).with_context(|| {
format!(
"Failed to create log file {}",
format_path_for_display(&log_path)
)
})?;
Ok((log_path, log_file))
}
pub fn spawn_detached(
repo: &Repository,
worktree_path: &Path,
command: &str,
branch: &str,
hook_log: &HookLog,
context_json: Option<&str>,
) -> anyhow::Result<std::path::PathBuf> {
let (log_path, log_file) = create_detach_log(repo, branch, hook_log)?;
log::debug!(
"$ {} (detached, logging to {})",
command,
log_path.file_name().unwrap_or_default().to_string_lossy()
);
#[cfg(unix)]
{
spawn_detached_unix(worktree_path, command, log_file, context_json)?;
}
#[cfg(windows)]
{
spawn_detached_windows(worktree_path, command, log_file, context_json)?;
}
Ok(log_path)
}
#[cfg(unix)]
fn spawn_detached_unix(
worktree_path: &Path,
command: &str,
log_file: fs::File,
context_json: Option<&str>,
) -> anyhow::Result<()> {
use std::os::unix::process::CommandExt;
let full_command = match context_json {
Some(json) => {
format!(
"printf '%s' {} | {{ {}{} }}",
shell_escape::escape(json.into()),
command,
posix_command_separator(command)
)
}
None => command.to_string(),
};
let shell_cmd = format!(
"{{ {}{} }} &",
full_command,
posix_command_separator(&full_command)
);
let mut child = Command::new("sh")
.arg("-c")
.arg(&shell_cmd)
.current_dir(worktree_path)
.stdin(Stdio::null())
.stdout(Stdio::from(
log_file
.try_clone()
.context("Failed to clone log file handle")?,
))
.stderr(Stdio::from(log_file))
.env_remove(worktrunk::shell_exec::DIRECTIVE_FILE_ENV_VAR)
.process_group(0) .spawn()
.context("Failed to spawn detached process")?;
child
.wait()
.context("Failed to wait for detachment shell")?;
Ok(())
}
#[cfg(windows)]
fn spawn_detached_windows(
worktree_path: &Path,
command: &str,
log_file: fs::File,
context_json: Option<&str>,
) -> anyhow::Result<()> {
use std::os::windows::process::CommandExt;
use worktrunk::shell_exec::ShellConfig;
const CREATE_NEW_PROCESS_GROUP: u32 = 0x00000200;
const DETACHED_PROCESS: u32 = 0x00000008;
let shell = ShellConfig::get()?;
let mut cmd = if shell.is_posix() {
let full_command = match context_json {
Some(json) => {
format!(
"printf '%s' {} | {{ {}{} }}",
shell_escape::escape(json.into()),
command,
posix_command_separator(command)
)
}
None => command.to_string(),
};
shell.command(&full_command)
} else {
let full_command = match context_json {
Some(json) => {
let escaped_json = json.replace('\'', "''");
format!("'{}' | & {{ {} }}", escaped_json, command)
}
None => command.to_string(),
};
shell.command(&full_command)
};
cmd.current_dir(worktree_path)
.stdin(Stdio::null())
.stdout(Stdio::from(
log_file
.try_clone()
.context("Failed to clone log file handle")?,
))
.stderr(Stdio::from(log_file))
.env_remove(worktrunk::shell_exec::DIRECTIVE_FILE_ENV_VAR)
.creation_flags(CREATE_NEW_PROCESS_GROUP | DETACHED_PROCESS)
.spawn()
.context("Failed to spawn detached process")?;
Ok(())
}
pub fn spawn_detached_exec(
repo: &Repository,
worktree_path: &Path,
program: &Path,
args: &[&str],
branch: &str,
hook_log: &HookLog,
stdin_bytes: &[u8],
) -> anyhow::Result<std::path::PathBuf> {
let (log_path, log_file) = create_detach_log(repo, branch, hook_log)?;
log::debug!(
"$ {} {} (detached, logging to {})",
program.display(),
args.join(" "),
log_path.file_name().unwrap_or_default().to_string_lossy()
);
#[cfg(unix)]
{
spawn_detached_exec_unix(worktree_path, program, args, log_file, stdin_bytes)?;
}
#[cfg(windows)]
{
spawn_detached_exec_windows(worktree_path, program, args, log_file, stdin_bytes)?;
}
Ok(log_path)
}
#[cfg(unix)]
fn spawn_detached_exec_unix(
worktree_path: &Path,
program: &Path,
args: &[&str],
log_file: fs::File,
stdin_bytes: &[u8],
) -> anyhow::Result<()> {
use std::io::Write;
use std::os::unix::process::CommandExt;
let mut child = Command::new(program)
.args(args)
.current_dir(worktree_path)
.stdin(Stdio::piped())
.stdout(Stdio::from(
log_file
.try_clone()
.context("Failed to clone log file handle")?,
))
.stderr(Stdio::from(log_file))
.env_remove(worktrunk::shell_exec::DIRECTIVE_FILE_ENV_VAR)
.process_group(0)
.spawn()
.context("Failed to spawn detached process")?;
if let Some(mut stdin) = child.stdin.take() {
let _ = stdin.write_all(stdin_bytes);
}
Ok(())
}
#[cfg(windows)]
fn spawn_detached_exec_windows(
worktree_path: &Path,
program: &Path,
args: &[&str],
log_file: fs::File,
stdin_bytes: &[u8],
) -> anyhow::Result<()> {
use std::io::Write;
use std::os::windows::process::CommandExt;
const CREATE_NEW_PROCESS_GROUP: u32 = 0x00000200;
const DETACHED_PROCESS: u32 = 0x00000008;
let mut child = Command::new(program)
.args(args)
.current_dir(worktree_path)
.stdin(Stdio::piped())
.stdout(Stdio::from(
log_file
.try_clone()
.context("Failed to clone log file handle")?,
))
.stderr(Stdio::from(log_file))
.env_remove(worktrunk::shell_exec::DIRECTIVE_FILE_ENV_VAR)
.creation_flags(CREATE_NEW_PROCESS_GROUP | DETACHED_PROCESS)
.spawn()
.context("Failed to spawn detached process")?;
if let Some(mut stdin) = child.stdin.take() {
let _ = stdin.write_all(stdin_bytes);
}
Ok(())
}
pub fn generate_removing_path(trash_dir: &Path, worktree_path: &Path) -> PathBuf {
let timestamp = epoch_now();
let name = worktree_path
.file_name()
.map(|n| n.to_string_lossy())
.unwrap_or_default();
trash_dir.join(format!("{}-{}", name, timestamp))
}
pub const TRASH_STALE_THRESHOLD_SECS: u64 = 24 * 60 * 60;
pub fn sweep_stale_trash(repo: &Repository) {
let trash_dir = repo.wt_trash_dir();
let stale = collect_stale_trash_entries(&trash_dir, epoch_now(), TRASH_STALE_THRESHOLD_SECS);
if stale.is_empty() {
return;
}
let escaped: Vec<String> = stale
.iter()
.map(|p| shell_escape::escape(p.to_string_lossy().as_ref().into()).into_owned())
.collect();
let command = format!("rm -rf -- {}", escaped.join(" "));
if let Err(e) = spawn_detached(
repo,
&repo.wt_dir(),
&command,
"wt",
&HookLog::internal(InternalOp::TrashSweep),
None,
) {
log::debug!("Failed to spawn stale trash sweep: {e}");
}
}
fn collect_stale_trash_entries(trash_dir: &Path, now: u64, threshold_secs: u64) -> Vec<PathBuf> {
let Ok(read_dir) = fs::read_dir(trash_dir) else {
return Vec::new();
};
read_dir
.filter_map(|entry| entry.ok())
.filter_map(|entry| {
let name = entry.file_name();
let timestamp = parse_trash_entry_timestamp(name.to_str()?)?;
let age = now.saturating_sub(timestamp);
(age >= threshold_secs).then(|| entry.path())
})
.collect()
}
fn parse_trash_entry_timestamp(name: &str) -> Option<u64> {
let (_, suffix) = name.rsplit_once('-')?;
suffix.parse::<u64>().ok()
}
pub fn build_remove_command_staged(
staged_path: &std::path::Path,
original_path: &std::path::Path,
changed_directory: bool,
) -> String {
use shell_escape::escape;
let staged_path_str = staged_path.to_string_lossy();
let staged_escaped = escape(staged_path_str.as_ref().into());
if changed_directory {
let original_path_str = original_path.to_string_lossy();
let original_escaped = escape(original_path_str.as_ref().into());
format!(
"sleep 1 && rmdir -- {} 2>/dev/null; rm -rf -- {}",
original_escaped, staged_escaped
)
} else {
format!("rm -rf -- {}", staged_escaped)
}
}
pub fn build_remove_command(
worktree_path: &std::path::Path,
branch_to_delete: Option<&str>,
force_worktree: bool,
changed_directory: bool,
) -> String {
use shell_escape::escape;
let worktree_path_str = worktree_path.to_string_lossy();
let worktree_escaped = escape(worktree_path_str.as_ref().into());
let stop_fsmonitor = format!(
"{{ git -C {} fsmonitor--daemon stop 2>/dev/null || true; }}",
worktree_escaped
);
let force_flag = if force_worktree { " --force" } else { "" };
let prefix = if changed_directory {
format!("sleep 1 && {} && ", stop_fsmonitor)
} else {
format!("{} && ", stop_fsmonitor)
};
match branch_to_delete {
Some(branch_name) => {
let branch_escaped = escape(branch_name.into());
format!(
"{}git worktree remove{} {} && git branch -D {}",
prefix, force_flag, worktree_escaped, branch_escaped
)
}
None => {
format!(
"{}git worktree remove{} {}",
prefix, force_flag, worktree_escaped
)
}
}
}
#[cfg(test)]
mod tests {
use insta::assert_snapshot;
use path_slash::PathExt as _;
use super::*;
#[test]
fn test_sanitize_for_filename() {
assert_snapshot!(
[
("path separator /", sanitize_for_filename("feature/branch")),
("path separator \\", sanitize_for_filename("feature\\branch")),
("colon", sanitize_for_filename("bug:123")),
("angle brackets", sanitize_for_filename("fix<angle>")),
("pipe", sanitize_for_filename("fix|pipe")),
("question mark", sanitize_for_filename("fix?question")),
("wildcard", sanitize_for_filename("fix*wildcard")),
("quotes", sanitize_for_filename("fix\"quotes\"")),
("multiple special", sanitize_for_filename("a/b\\c<d>e:f\"g|h?i*j")),
("already safe", sanitize_for_filename("normal-branch")),
("underscore", sanitize_for_filename("branch_with_underscore")),
("reserved prefix CONSOLE", sanitize_for_filename("CONSOLE")),
("reserved prefix COM10", sanitize_for_filename("COM10")),
]
.into_iter()
.map(|(label, val)| format!("{label}: {val}"))
.collect::<Vec<_>>()
.join("\n"),
@r"
path separator /: feature-branch-30k
path separator \: feature-branch-k37
colon: bug-123-4xh
angle brackets: fix-angle-q9m
pipe: fix-pipe-68k
question mark: fix-question-ab6
wildcard: fix-wildcard-38y
quotes: fix-quotes-2xu
multiple special: a-b-c-d-e-f-g-h-i-j-obi
already safe: normal-branch-83y
underscore: branch_with_underscore-b65
reserved prefix CONSOLE: CONSOLE-8fv
reserved prefix COM10: COM10-1s2
"
);
for name in [
"CON", "con", "PRN", "AUX", "NUL", "COM0", "COM1", "com9", "LPT0", "LPT1", "lpt9",
] {
let result = sanitize_for_filename(name);
assert!(!result.is_empty() && result.len() > 3, "{name} -> {result}");
}
let a = sanitize_for_filename("feature/x");
let b = sanitize_for_filename("feature-x");
assert_ne!(a, b, "should not collide: {a} vs {b}");
}
#[test]
fn test_posix_command_separator() {
assert_eq!(posix_command_separator("echo hello\n"), "");
assert_eq!(posix_command_separator("echo hello;"), "");
assert_eq!(posix_command_separator("echo hello"), ";");
assert_eq!(posix_command_separator(""), ";");
assert_eq!(posix_command_separator("echo\nhello"), ";");
assert_eq!(posix_command_separator("echo; hello"), ";");
}
#[test]
fn test_build_remove_command() {
use std::path::PathBuf;
let path = PathBuf::from("/tmp/test-worktree");
assert_snapshot!(build_remove_command(&path, None, false, true), @"sleep 1 && { git -C /tmp/test-worktree fsmonitor--daemon stop 2>/dev/null || true; } && git worktree remove /tmp/test-worktree");
assert_snapshot!(build_remove_command(&path, Some("feature-branch"), false, true), @"sleep 1 && { git -C /tmp/test-worktree fsmonitor--daemon stop 2>/dev/null || true; } && git worktree remove /tmp/test-worktree && git branch -D feature-branch");
assert_snapshot!(build_remove_command(&path, None, false, false), @"{ git -C /tmp/test-worktree fsmonitor--daemon stop 2>/dev/null || true; } && git worktree remove /tmp/test-worktree");
assert_snapshot!(build_remove_command(&path, Some("feature-branch"), false, false), @"{ git -C /tmp/test-worktree fsmonitor--daemon stop 2>/dev/null || true; } && git worktree remove /tmp/test-worktree && git branch -D feature-branch");
assert_snapshot!(build_remove_command(&path, None, true, true), @"sleep 1 && { git -C /tmp/test-worktree fsmonitor--daemon stop 2>/dev/null || true; } && git worktree remove --force /tmp/test-worktree");
let special_path = PathBuf::from("/tmp/test worktree");
assert_snapshot!(build_remove_command(&special_path, Some("feature/branch"), false, true), @"sleep 1 && { git -C '/tmp/test worktree' fsmonitor--daemon stop 2>/dev/null || true; } && git worktree remove '/tmp/test worktree' && git branch -D feature/branch");
}
#[test]
fn test_generate_removing_path() {
let trash_dir = PathBuf::from("/tmp/repo/.git/wt/trash");
let path = PathBuf::from("/tmp/my-project.feature");
let removing_path = generate_removing_path(&trash_dir, &path);
assert_eq!(removing_path.parent(), Some(trash_dir.as_path()));
let name = removing_path.file_name().unwrap().to_string_lossy();
assert!(name.starts_with("my-project.feature-"), "got: {}", name);
let timestamp_part = name.trim_start_matches("my-project.feature-");
assert!(
timestamp_part.chars().all(|c| c.is_ascii_digit()),
"timestamp part should be numeric: {}",
timestamp_part
);
}
#[test]
fn test_build_remove_command_staged() {
let staged_path = PathBuf::from("/tmp/repo/.git/wt/trash/my-project.feature-1234567890");
let original_path = PathBuf::from("/tmp/my-project.feature");
assert_snapshot!(build_remove_command_staged(&staged_path, &original_path, true), @"sleep 1 && rmdir -- /tmp/my-project.feature 2>/dev/null; rm -rf -- /tmp/repo/.git/wt/trash/my-project.feature-1234567890");
assert_snapshot!(build_remove_command_staged(&staged_path, &original_path, false), @"rm -rf -- /tmp/repo/.git/wt/trash/my-project.feature-1234567890");
let special_path = PathBuf::from("/tmp/repo/.git/wt/trash/test worktree-123");
let special_original = PathBuf::from("/tmp/test worktree");
assert_snapshot!(build_remove_command_staged(&special_path, &special_original, true), @"sleep 1 && rmdir -- '/tmp/test worktree' 2>/dev/null; rm -rf -- '/tmp/repo/.git/wt/trash/test worktree-123'");
}
#[test]
fn test_hook_log_path() {
use worktrunk::git::HookType;
let log_dir = Path::new("/repo/.git/wt/logs");
let log = HookLog::hook(HookSource::User, HookType::PostStart, "server");
assert_snapshot!(
log.path(log_dir, "main").to_slash_lossy(),
@"/repo/.git/wt/logs/main-vfz/user/post-start/server-f4t.log"
);
assert_snapshot!(
log.path(log_dir, "feature/auth").to_slash_lossy(),
@"/repo/.git/wt/logs/feature-auth-j34/user/post-start/server-f4t.log"
);
let log = HookLog::hook(HookSource::Project, HookType::PreStart, "build");
assert_snapshot!(
log.path(log_dir, "main").to_slash_lossy(),
@"/repo/.git/wt/logs/main-vfz/project/pre-start/build-seq.log"
);
assert_eq!(
HookLog::hook(HookSource::User, HookType::PostStart, "server").path(log_dir, "main"),
HookLog::parse("user:post-start:server")
.unwrap()
.path(log_dir, "main"),
);
assert_snapshot!(
HookLog::internal(InternalOp::Remove).path(log_dir, "main").to_slash_lossy(),
@"/repo/.git/wt/logs/main-vfz/internal/remove.log"
);
assert_snapshot!(
HookLog::internal(InternalOp::TrashSweep).path(log_dir, "wt").to_slash_lossy(),
@"/repo/.git/wt/logs/wt-boj/internal/trash-sweep.log"
);
}
#[test]
fn test_hook_log_parse_internal() {
let log = HookLog::parse("internal:remove").unwrap();
assert_eq!(log, HookLog::Internal(InternalOp::Remove));
}
#[test]
fn test_hook_log_parse_errors() {
assert_snapshot!(HookLog::parse("invalid:post-start:server").unwrap_err(), @"Unknown source: [1minvalid[22m. Valid: user, project");
assert_snapshot!(HookLog::parse("user:invalid-hook:server").unwrap_err(), @"Unknown hook type: [1minvalid-hook[22m. Valid: pre-switch, post-switch, pre-start, post-start, pre-commit, post-commit, pre-merge, post-merge, pre-remove, post-remove");
assert_snapshot!(HookLog::parse("internal:unknown").unwrap_err(), @"Unknown internal operation: [1munknown[22m. Valid: remove, trash-sweep");
assert_snapshot!(HookLog::parse("remove").unwrap_err(), @"Invalid log spec: [1mremove[22m. Format: source:hook-type:name or internal:op");
assert_snapshot!(HookLog::parse("foo:bar").unwrap_err(), @"Invalid log spec: [1mfoo:bar[22m. Format: source:hook-type:name or internal:op");
assert_snapshot!(HookLog::parse("user:").unwrap_err(), @"Invalid log spec: [1muser:[22m. Format: source:hook-type:name or internal:op");
assert_snapshot!(HookLog::parse("user:post-start:my:server").unwrap_err(), @"Invalid log spec: [1muser:post-start:my:server[22m. Format: source:hook-type:name or internal:op");
assert_snapshot!(HookLog::parse("user:post-start:").unwrap_err(), @"Invalid log spec: [1muser:post-start:[22m. Format: source:hook-type:name or internal:op");
}
#[test]
fn test_hook_log_roundtrip() {
use worktrunk::git::HookType;
let log_dir = Path::new("/repo/.git/wt/logs");
let created = HookLog::hook(HookSource::User, HookType::PostStart, "server");
let parsed = HookLog::parse("user:post-start:server").unwrap();
assert_eq!(created.path(log_dir, "main"), parsed.path(log_dir, "main"));
let created = HookLog::internal(InternalOp::Remove);
let parsed = HookLog::parse("internal:remove").unwrap();
assert_eq!(created.path(log_dir, "main"), parsed.path(log_dir, "main"));
}
#[test]
fn test_hook_log_to_spec_roundtrip() {
use worktrunk::git::HookType;
let original = HookLog::hook(HookSource::User, HookType::PostStart, "server");
let spec = original.to_spec();
assert_eq!(spec, "user:post-start:server");
let parsed = HookLog::parse(&spec).unwrap();
assert_eq!(original, parsed);
let original = HookLog::hook(HookSource::Project, HookType::PreMerge, "lint");
let spec = original.to_spec();
assert_eq!(spec, "project:pre-merge:lint");
let parsed = HookLog::parse(&spec).unwrap();
assert_eq!(original, parsed);
let original = HookLog::internal(InternalOp::Remove);
let spec = original.to_spec();
assert_eq!(spec, "internal:remove");
let parsed = HookLog::parse(&spec).unwrap();
assert_eq!(original, parsed);
let original = HookLog::internal(InternalOp::TrashSweep);
let spec = original.to_spec();
assert_eq!(spec, "internal:trash-sweep");
let parsed = HookLog::parse(&spec).unwrap();
assert_eq!(original, parsed);
}
#[test]
fn test_parse_trash_entry_timestamp() {
assert_eq!(
parse_trash_entry_timestamp("feature-1700000000"),
Some(1700000000)
);
assert_eq!(
parse_trash_entry_timestamp("my-project.feature-branch-1700000000"),
Some(1700000000)
);
assert_eq!(parse_trash_entry_timestamp("no-timestamp"), None);
assert_eq!(parse_trash_entry_timestamp("notimestamp"), None);
assert_eq!(parse_trash_entry_timestamp(""), None);
}
#[test]
fn test_collect_stale_trash_entries() {
let trash = tempfile::tempdir().unwrap();
let now: u64 = 1_700_000_000;
let day = TRASH_STALE_THRESHOLD_SECS;
let stale = trash.path().join(format!("feature-old-{}", now - 2 * day));
fs::create_dir(&stale).unwrap();
let fresh = trash.path().join(format!("feature-new-{}", now - 3600));
fs::create_dir(&fresh).unwrap();
let boundary = trash.path().join(format!("feature-edge-{}", now - day));
fs::create_dir(&boundary).unwrap();
let foreign = trash.path().join("random-folder");
fs::create_dir(&foreign).unwrap();
let mut collected = collect_stale_trash_entries(trash.path(), now, day);
collected.sort();
let mut expected = vec![stale, boundary];
expected.sort();
assert_eq!(collected, expected);
assert!(
fresh.exists(),
"fresh entries must not appear in stale list"
);
assert!(foreign.exists(), "unparsable entries must be left alone");
}
#[test]
fn test_collect_stale_trash_entries_missing_dir() {
let missing = std::path::PathBuf::from("/nonexistent/wt/trash/path");
assert!(
collect_stale_trash_entries(&missing, 1_700_000_000, TRASH_STALE_THRESHOLD_SECS)
.is_empty()
);
}
}