use crate::core::types::ReclaimKind;
pub(super) fn prelude() -> String {
r#"
# --- shared helpers -------------------------------------------------------
# A path is idle if no regular file under it was modified within N minutes.
# This is a floor that protects in-flight builds, not the reclaim policy.
fb_is_idle() {
[ -e "$1" ] || return 1
! find "$1" -type f -mmin "-$2" -print -quit 2>/dev/null | grep -q .
}
fb_bytes() { du -sx --block-size=1 "$1" 2>/dev/null | cut -f1; }
# Absolute paths currently held open by ANY process (cwd or fd). One pass over
# /proc; the caller greps this file rather than re-scanning per candidate.
fb_scan_open_paths() {
: >"$FB_OPEN"
# `/proc/*` not `/proc/[0-9]*`: bashrs misparses the bracket glob as a test
# expression (SC1020/SC1140) and the whole script fails I8 purification.
# Non-pid entries are filtered by requiring a readable cmdline.
for p in /proc/*; do
[ -e "$p/cmdline" ] || continue
for l in "$p/cwd" "$p/fd"/*; do
t=$(readlink "$l" 2>/dev/null) || continue
case "$t" in /*) printf '%s\n' "$t" ;; esac
done
done 2> /dev/null | sort -u > "$FB_OPEN"
}
# True when some live process sits inside $1.
fb_in_use() {
[ -s "$FB_OPEN" ] || return 1
grep -qF -e "$1" "$FB_OPEN"
}
# Emit "<mtime_epoch>\t<path>" so the caller can sort oldest-first.
fb_stamp() { printf '%s\t%s\n' "$(stat -c %Y "$1" 2>/dev/null || echo 0)" "$1"; }
# SEC011 guard: refuse anything that is not a plausible reclaim target.
# Deletion candidates all come from globs and finds; a glob that matches one
# level too high, or a variable that came back empty, must abort rather than
# delete. Depth >= 3 keeps the reaper away from `/`, `/home`, `/home/noah`,
# `/tmp` and every other top-level directory even if a root is misdeclared.
fb_sweepable() {
fb_p="$1"
if [ -z "$fb_p" ]; then fb_log " REFUSE empty path"; return 1; fi
case "$fb_p" in
/*) ;;
*) fb_log " REFUSE non-absolute: $fb_p"; return 1 ;;
esac
case "$fb_p" in
*..*) fb_log " REFUSE traversal: $fb_p"; return 1 ;;
*/) fb_log " REFUSE trailing slash: $fb_p"; return 1 ;;
esac
fb_depth=$(printf '%s' "$fb_p" | tr -cd '/' | wc -c)
if [ "$fb_depth" -lt 3 ]; then
fb_log " REFUSE too shallow (depth $fb_depth): $fb_p"
return 1
fi
[ -e "$fb_p" ] || return 1
return 0
}
"#
.to_string()
}
pub(super) const fn fn_name(kind: ReclaimKind) -> &'static str {
match kind {
ReclaimKind::CargoTarget => "fb_find_cargo_target",
ReclaimKind::ClaudeScratchpad => "fb_find_claude_scratchpad",
ReclaimKind::AbandonedWorktree => "fb_find_abandoned_worktree",
ReclaimKind::Glob => "fb_find_glob",
}
}
fn cargo_target() -> String {
r#"
fb_find_cargo_target() {
for root in "$@"; do
[ -d "$root" ] || continue
find "$root" -mindepth 1 -maxdepth 7 -type f \
\( -name .rustc_info.json -o -name CACHEDIR.TAG \) 2> /dev/null |
while IFS= read -r fb_marker; do
dirname "$fb_marker"
done | sort -u |
while IFS= read -r d; do
if [ -f "$d/.rustc_info.json" ]; then
fb_stamp "$d"
elif [ -f "$d/CACHEDIR.TAG" ] && { [ -d "$d/debug" ] || [ -d "$d/release" ]; }; then
# CACHEDIR.TAG alone also matches ~/.cargo/registry, whose children are
# src/ cache/ index/ — a build-output subdir is what tells them apart.
fb_stamp "$d"
fi
done
done | sort -n | cut -f2-
}
"#
.to_string()
}
fn claude_scratchpad() -> String {
r#"
fb_find_claude_scratchpad() {
for root in "$@"; do
[ -d "$root" ] || continue
# Sessions whose project has a live `claude` process (slug = cwd with / -> -).
fb_live_slugs=""
for p in /proc/*; do
[ -e "$p/cmdline" ] || continue
c=$(tr '\0' '\n' <"$p/cmdline" 2>/dev/null | head -1) || continue
case "${c##*/}" in claude) ;; *) continue ;; esac
w=$(readlink "$p/cwd" 2>/dev/null) || continue
fb_live_slugs="$fb_live_slugs $(printf '%s' "$w" | tr '/' '-')"
done 2> /dev/null
for s in "$root"/*/*/scratchpad; do
[ -d "$s" ] || continue
sess=$(dirname "$s"); proj=$(basename "$(dirname "$sess")")
fb_in_use "$sess" && continue
case " $fb_live_slugs " in *" $proj "*) continue ;; esac
fb_stamp "$s"
done
done | sort -n | cut -f2-
}
"#
.to_string()
}
fn abandoned_worktree() -> String {
r#"
fb_find_abandoned_worktree() {
for root in "$@"; do
[ -d "$root" ] || continue
for d in "$root"/*; do
[ -e "$d/.git" ] || continue
# A linked worktree has a .git FILE ("gitdir: ..."), not a directory;
# never offer the primary checkout as a candidate.
[ -f "$d/.git" ] || continue
fb_in_use "$d" && continue
git -C "$d" rev-parse --git-dir >/dev/null 2>&1 || continue
# Dirty tree (tracked or untracked) => keep. Hoisted out of the `[ -n ... ]`
# test: bashrs SEC002 cannot see the quoting on `$d` through the nested
# quotes of a command substitution inside a test.
dirty=$(git -C "$d" status --porcelain 2>/dev/null)
[ -n "$dirty" ] && continue
# No upstream => unpushed work => keep. Fails closed on git error.
up=$(git -C "$d" rev-parse --abbrev-ref '@{upstream}' 2>/dev/null) || continue
[ -n "$up" ] || continue
range="$up..HEAD"
ahead=$(git -C "$d" rev-list --count "$range" 2>/dev/null) || continue
[ "$ahead" = "0" ] || continue
fb_stamp "$d"
done
done | sort -n | cut -f2-
}
"#
.to_string()
}
fn glob() -> String {
r#"
fb_find_glob() {
for pat in "$@"; do
for d in $pat; do
[ -e "$d" ] || continue
fb_in_use "$d" && continue
fb_stamp "$d"
done
done | sort -n | cut -f2-
}
"#
.to_string()
}
pub(super) fn all_detectors() -> String {
format!(
"{}{}{}{}",
cargo_target(),
claude_scratchpad(),
abandoned_worktree(),
glob()
)
}
pub(super) const fn pre_delete(kind: ReclaimKind) -> &'static str {
match kind {
ReclaimKind::AbandonedWorktree => {
" FB_REPO_DIR=$(git -C \"$cand\" rev-parse --git-common-dir 2>/dev/null || echo '')\n"
}
_ => "",
}
}
pub(super) const fn post_delete(kind: ReclaimKind) -> &'static str {
match kind {
ReclaimKind::AbandonedWorktree => {
" [ -n \"$FB_REPO_DIR\" ] && git --git-dir \"$FB_REPO_DIR\" worktree prune 2>/dev/null\n"
}
_ => "",
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn cargo_detector_accepts_either_real_layout() {
let s = cargo_target();
assert!(
s.contains(r#"if [ -f "$d/.rustc_info.json" ]; then"#),
".rustc_info.json alone must be sufficient"
);
assert!(
s.contains(
r#"[ -f "$d/CACHEDIR.TAG" ] && { [ -d "$d/debug" ] || [ -d "$d/release" ]; }"#
),
"CACHEDIR.TAG must be accepted when a build-output subdir is present"
);
}
#[test]
fn cargo_detector_still_excludes_the_registry() {
let s = cargo_target();
let tag_branch = s
.find(r#"[ -f "$d/CACHEDIR.TAG" ]"#)
.expect("tag branch present");
let rest = &s[tag_branch..];
assert!(
rest.starts_with(r#"[ -f "$d/CACHEDIR.TAG" ] && {"#),
"CACHEDIR.TAG must never qualify a directory on its own"
);
assert!(rest.contains(r#"-d "$d/debug""#) && rest.contains(r#"-d "$d/release""#));
}
#[test]
fn cargo_detector_never_matches_by_name() {
let s = cargo_target();
assert!(
!s.contains("-name target"),
"detection must not key on names"
);
assert!(!s.contains("-name '.target'"));
}
#[test]
fn worktree_detector_fails_closed_without_upstream() {
let s = abandoned_worktree();
assert!(s.contains("@{upstream}"));
assert!(s.contains("rev-list --count"));
assert!(s.contains("status --porcelain"));
assert!(s.contains("|| continue"));
}
#[test]
fn worktree_detector_skips_primary_checkout() {
assert!(abandoned_worktree().contains("[ -f \"$d/.git\" ] || continue"));
}
#[test]
fn scratchpad_detector_has_both_liveness_signals() {
let s = claude_scratchpad();
assert!(s.contains("fb_in_use"), "open-handle signal missing");
assert!(s.contains("fb_live_slugs"), "project-slug signal missing");
}
#[test]
fn every_kind_has_a_detector_definition() {
let all = all_detectors();
for k in [
ReclaimKind::CargoTarget,
ReclaimKind::ClaudeScratchpad,
ReclaimKind::AbandonedWorktree,
ReclaimKind::Glob,
] {
assert!(
all.contains(&format!("{}()", fn_name(k))),
"no detector emitted for {k}"
);
}
}
#[test]
fn only_worktrees_prune_after_delete() {
assert!(post_delete(ReclaimKind::AbandonedWorktree).contains("worktree prune"));
assert_eq!(post_delete(ReclaimKind::CargoTarget), "");
assert_eq!(post_delete(ReclaimKind::Glob), "");
assert_eq!(post_delete(ReclaimKind::ClaudeScratchpad), "");
}
#[test]
fn prune_targets_the_repo_not_the_pool_directory() {
let post = post_delete(ReclaimKind::AbandonedWorktree);
assert!(
!post.contains("dirname"),
"prune must not be run from the pool directory: {post}"
);
assert!(post.contains("--git-dir \"$FB_REPO_DIR\""));
}
#[test]
fn repo_dir_is_captured_before_the_worktree_is_deleted() {
let pre = pre_delete(ReclaimKind::AbandonedWorktree);
assert!(pre.contains("--git-common-dir"));
assert!(pre.contains("FB_REPO_DIR="));
assert_eq!(pre_delete(ReclaimKind::CargoTarget), "");
assert_eq!(pre_delete(ReclaimKind::Glob), "");
assert_eq!(pre_delete(ReclaimKind::ClaudeScratchpad), "");
}
#[test]
fn candidates_are_emitted_oldest_first() {
for s in [
cargo_target(),
claude_scratchpad(),
abandoned_worktree(),
glob(),
] {
assert!(s.contains("sort -n | cut -f2-"), "not oldest-first: {s}");
}
}
#[test]
fn prelude_defines_every_helper_the_detectors_call() {
let p = prelude();
for helper in [
"fb_is_idle",
"fb_bytes",
"fb_in_use",
"fb_stamp",
"fb_scan_open_paths",
] {
assert!(
p.contains(&format!("{helper}()")),
"missing helper {helper}"
);
}
}
}