use std::{
collections::{BTreeMap, HashMap},
fs,
io::Read as _,
path::{Path, PathBuf},
process::Command,
};
use anyhow::Result;
use brgr_protocol::{TaskId, TaskSpec, TerminalOutcome};
use brgr_store::{OpenReason, Settlement, Store};
use serde_json::json;
use crate::{Paths, print_value};
pub(crate) struct Prune {
pub(crate) entries: Vec<Entry>,
pub(crate) orphans: Vec<Orphan>,
}
pub(crate) struct Orphan {
pub(crate) branch: String,
pub(crate) owner: Option<String>,
pub(crate) outcome: Outcome,
}
pub(crate) struct Entry {
pub(crate) worktree: PathBuf,
pub(crate) slug: String,
pub(crate) owner: Option<String>,
pub(crate) ignored: Vec<String>,
pub(crate) outcome: Outcome,
}
pub(crate) enum Outcome {
Removable,
Removed(Option<String>),
RemovedKeepingBranch(String),
Kept(String),
}
impl Outcome {
pub(crate) fn code(&self) -> &'static str {
match self {
Self::Removable => "removable",
Self::Removed(_) => "removed",
Self::RemovedKeepingBranch(_) => "removed_branch_kept",
Self::Kept(_) => "kept",
}
}
pub(crate) fn reason(&self) -> Option<&str> {
match self {
Self::Kept(reason) | Self::RemovedKeepingBranch(reason) => Some(reason),
Self::Removed(reason) => reason.as_deref(),
Self::Removable => None,
}
}
}
pub(crate) fn prune(
worktrees_root: &Path,
store: &Store,
worker_alive: &dyn Fn(TaskId) -> bool,
apply: bool,
include_ignored: bool,
) -> Prune {
let current_dir = std::env::current_dir()
.ok()
.and_then(|path| canonical(&path));
let sweep = Sweep {
store,
current_dir: current_dir.as_deref(),
include_ignored,
worker_alive,
};
let mut entries = Vec::new();
let mut orphans = Vec::new();
let mut known = Repositories::load(store);
let mut unlocated = Vec::new();
let (directories, unreadable) = repository_directories(worktrees_root);
entries.extend(unreadable.into_iter().map(unreadable_entry));
for directory in directories {
let (candidates, unreadable) = child_directories(&directory);
entries.extend(unreadable.into_iter().map(unreadable_entry));
let directory_name = file_name(&directory);
let emptied_by_hand = candidates.is_empty();
let mut removed_any = false;
for worktree in candidates {
let slug = file_name(&worktree);
let assessment = assess(&worktree, &slug, &sweep, &mut known);
let (outcome, removed) = sweep_outcome(&worktree, &slug, &assessment, store, apply);
if removed {
removed_any = true;
}
entries.push(Entry {
worktree,
slug,
owner: assessment.owner,
ignored: assessment.ignored,
outcome,
});
}
if apply && removed_any {
remove_if_emptied(&directory, current_dir.as_deref());
}
if emptied_by_hand {
unlocated.push(directory_name);
}
}
unlocated.retain(|name| {
!known
.inventories
.iter()
.any(|inventory| file_name(&inventory.primary) == *name)
});
for name in unlocated {
let directory = worktrees_root.join(&name);
if directory.is_dir() {
entries.push(Entry {
worktree: directory,
slug: name,
owner: None,
ignored: Vec::new(),
outcome: Outcome::Kept(
"no checkout of this repository survives and brgr did not record its path, \
so its brgr/task-* branches cannot be located; remove them from the \
repository with `git branch -d`"
.to_owned(),
),
});
}
}
for inventory in &known.inventories {
orphans.extend(reconcile_orphans(inventory, store, apply));
}
entries.sort_by(|left, right| left.worktree.cmp(&right.worktree));
orphans.sort_by(|left, right| left.branch.cmp(&right.branch));
Prune { entries, orphans }
}
fn remove_if_emptied(repository: &Path, current_dir: Option<&Path>) {
if file_name(repository).starts_with('.') {
return;
}
if current_dir.is_some_and(|cwd| is_self_or_ancestor(repository, cwd)) {
return;
}
if fs::read_dir(repository).is_ok_and(|mut entries| entries.next().is_none()) {
let _ = fs::remove_dir(repository);
}
}
fn reconcile_orphans(inventory: &Inventory, store: &Store, apply: bool) -> Vec<Orphan> {
let Ok(listing) = git(
&inventory.primary,
&[
"branch",
"--list",
"brgr/task-*",
"--format=%(refname:short)",
],
) else {
return Vec::new();
};
let mut orphans = Vec::new();
for branch in listing
.lines()
.map(str::trim)
.filter(|line| !line.is_empty())
{
let Some((prefix, revision)) = branch.strip_prefix("brgr/task-").and_then(parse_slug)
else {
continue;
};
let mut owner = None;
let judged = orphan_objection(inventory, store, (prefix, revision), branch, &mut owner);
let outcome = match judged {
Err(Live) => continue,
Ok(Some(reason)) => Outcome::Kept(reason),
Ok(None) if apply => reclaim(inventory, store, (prefix, revision), branch),
Ok(None) => Outcome::Removable,
};
orphans.push(Orphan {
branch: branch.to_owned(),
owner,
outcome,
});
}
orphans
}
struct Live;
fn owning_task(
inventory: &Inventory,
store: &Store,
(prefix, revision): (&str, u32),
) -> Result<Option<TaskSpec>, String> {
let matches = store
.task_revisions_with_prefix(prefix, revision)
.map_err(|error| format!("task lookup failed: {error}"))?;
let mut ours = Vec::new();
for task in matches {
let belongs = match store.task_checkout(task.task_id, revision) {
Ok(Some(recorded)) => resolve(Path::new(&recorded)) == inventory.primary,
Ok(None) => inventory.is_registered(&resolve(Path::new(&task.workspace))),
Err(error) => return Err(format!("checkout lookup failed: {error}")),
};
if belongs {
ours.push(task);
}
}
match ours.len() {
0 | 1 => Ok(ours.pop()),
_ => Err("task id prefix is ambiguous; refusing to guess".to_owned()),
}
}
fn unsettled(
store: &Store,
task: TaskId,
revision: u32,
worker_alive: Option<&dyn Fn(TaskId) -> bool>,
) -> Option<String> {
let settlement = match store.revision_settlement(task, revision) {
Ok(settlement) => settlement,
Err(error) => return Some(format!("settlement lookup failed: {error}")),
};
match settlement {
Settlement::Acknowledged(_) if worker_alive.is_some_and(|alive| alive(task)) => {
Some("a worker of this task is still running".to_owned())
}
Settlement::Decided | Settlement::Acknowledged(_) => None,
Settlement::Open(OpenReason::NoResult) => {
Some("no result recorded for this revision yet".to_owned())
}
Settlement::Open(OpenReason::Undecided) => {
Some("no owner decision recorded for this revision".to_owned())
}
Settlement::Open(OpenReason::Unacknowledged(outcome)) => Some(format!(
"its {} result is not acknowledged; run `brgr result {task} --ack`",
outcome_name(outcome)
)),
Settlement::Open(OpenReason::AttemptActive) => {
Some("an attempt of this revision is still running".to_owned())
}
Settlement::Open(OpenReason::RetryGranted) => {
Some("a retry was granted for this revision and may still start".to_owned())
}
}
}
fn outcome_name(outcome: TerminalOutcome) -> &'static str {
match outcome {
TerminalOutcome::Candidate => "candidate",
TerminalOutcome::Failed => "failed",
TerminalOutcome::Cancelled => "cancelled",
TerminalOutcome::Lost => "lost",
}
}
fn orphan_objection(
inventory: &Inventory,
store: &Store,
(prefix, revision): (&str, u32),
branch: &str,
owner: &mut Option<String>,
) -> Result<Option<String>, Live> {
let task = match owning_task(inventory, store, (prefix, revision)) {
Ok(Some(task)) => task,
Ok(None) => {
return Ok(Some(
"no task revision of this repository matches this branch".to_owned(),
));
}
Err(reason) => return Ok(Some(reason)),
};
let worktree = resolve(Path::new(&task.workspace));
if worktree.is_dir() {
return Err(Live);
}
*owner = Some(task.owner_id.as_str().to_owned());
if let Some(reason) = unsettled(store, task.task_id, revision, None) {
return Ok(Some(reason));
}
if inventory.is_locked(&worktree) {
return Ok(Some(
"its stale worktree registration is locked; unlock it first with `git worktree unlock`"
.to_owned(),
));
}
match preservation(&inventory.primary, branch, store, &task, true) {
Ok(Some(_)) => Ok(None),
Ok(None) => Ok(Some(UNPRESERVED.to_owned())),
Err(reason) => Ok(Some(format!("branch could not be checked: {reason}"))),
}
}
fn reclaim(
inventory: &Inventory,
store: &Store,
(prefix, revision): (&str, u32),
branch: &str,
) -> Outcome {
let Ok(Some(task)) = owning_task(inventory, store, (prefix, revision)) else {
return Outcome::Kept("task could not be re-read before removal".to_owned());
};
let worktree = resolve(Path::new(&task.workspace));
if inventory.is_registered(&worktree)
&& let Err(error) = git(
&inventory.primary,
&["worktree", "remove", &lossy(&worktree)],
)
{
return Outcome::Kept(format!(
"stale registration {} could not be removed: {error}",
worktree.display()
));
}
match delete_task_branch(&inventory.primary, branch, store, &task, true) {
Ok(reason) => Outcome::Removed(reason),
Err(reason) => Outcome::Kept(reason),
}
}
struct Assessment {
blocked: Option<String>,
owner: Option<String>,
ignored: Vec<String>,
force: bool,
primary: Option<PathBuf>,
task: Option<TaskSpec>,
}
struct Sweep<'a> {
store: &'a Store,
current_dir: Option<&'a Path>,
include_ignored: bool,
worker_alive: &'a dyn Fn(TaskId) -> bool,
}
fn assess(worktree: &Path, slug: &str, sweep: &Sweep<'_>, known: &mut Repositories) -> Assessment {
let mut found = Assessment {
blocked: None,
owner: None,
ignored: Vec::new(),
force: false,
primary: None,
task: None,
};
found.blocked = objection(worktree, slug, sweep, known, &mut found);
found
}
fn objection(
worktree: &Path,
slug: &str,
sweep: &Sweep<'_>,
known: &mut Repositories,
found: &mut Assessment,
) -> Option<String> {
let Sweep {
store,
current_dir,
include_ignored,
worker_alive,
} = *sweep;
let Some((prefix, revision)) = parse_slug(slug) else {
return Some("directory name is not a brgr task slug; refusing to touch it".to_owned());
};
match fs::symlink_metadata(worktree) {
Ok(metadata) if metadata.file_type().is_symlink() => {
return Some("path is a symlink; refusing to follow it".to_owned());
}
Ok(metadata) if !metadata.is_dir() => {
return Some("path is not a directory".to_owned());
}
Ok(_) => {}
Err(error) => return Some(format!("path is unreadable: {error}")),
}
let Some(resolved) = canonical(worktree) else {
return Some("path could not be resolved".to_owned());
};
if let Some(cwd) = current_dir
&& is_self_or_ancestor(&resolved, cwd)
{
return Some(
"this is the current working directory; refusing to remove it from under the \
running process"
.to_owned(),
);
}
let matches = match store.task_revisions_with_prefix(prefix, revision) {
Ok(matches) => matches,
Err(error) => return Some(format!("task lookup failed: {error}")),
};
let Some(task) = matches
.into_iter()
.find(|task| resolve(Path::new(&task.workspace)) == resolved)
else {
return Some("no task revision was admitted in this directory".to_owned());
};
found.owner = Some(task.owner_id.as_str().to_owned());
found.task = Some(task.clone());
let recorded = match store.task_checkout(task.task_id, revision) {
Ok(recorded) => recorded,
Err(error) => return Some(format!("checkout lookup failed: {error}")),
};
let Some(index) = known.locate(recorded.as_deref(), worktree, &resolved) else {
return Some("worktree is not a usable git checkout".to_owned());
};
let primary = known.inventories[index].primary.clone();
if let Some(recorded) = recorded
&& resolve(Path::new(&recorded)) != primary
{
return Some(format!(
"brgr recorded this worktree under {recorded}, but git places it in {}",
primary.display()
));
}
if let Some(reason) = unsettled(store, task.task_id, revision, Some(worker_alive)) {
return Some(reason);
}
let inventory = &known.inventories[index];
if !inventory.is_registered(&resolved) {
return Some("path is not a registered worktree of its repository".to_owned());
}
if inventory.is_locked(&resolved) {
return Some("worktree is locked; unlock it first with `git worktree unlock`".to_owned());
}
if let Some(reason) = changes_objection(worktree, store, &task, include_ignored, found) {
return Some(reason);
}
found.primary = Some(inventory.primary.clone());
None
}
fn changes_objection(
worktree: &Path,
store: &Store,
task: &TaskSpec,
include_ignored: bool,
found: &mut Assessment,
) -> Option<String> {
let status = match git(
worktree,
&[
"status",
"--porcelain",
"--ignored=matching",
"--untracked-files=all",
],
) {
Ok(status) => status,
Err(reason) => return Some(format!("worktree status is unreadable: {reason}")),
};
let changed: Vec<&str> = status
.lines()
.filter(|line| !line.is_empty() && !line.starts_with("!! "))
.collect();
if !changed.is_empty() {
if let Some(line) = changed.iter().find(|line| unsealable(worktree, line)) {
return Some(format!(
"worktree holds {}, which no sealed patch can hold",
line.get(3..).unwrap_or(line)
));
}
if changed.iter().all(|line| harness_cache(line)) || patch_is_sealed(store, task) {
found.force = true;
} else {
return Some("worktree holds modified or untracked files".to_owned());
}
}
found.ignored = status
.lines()
.filter_map(|line| line.strip_prefix("!! "))
.map(str::to_owned)
.collect();
let kept_ignored: Vec<&String> = found
.ignored
.iter()
.filter(|path| !regenerable(path))
.collect();
if !kept_ignored.is_empty() && !include_ignored {
return Some(format!(
"worktree holds {} ignored path(s) git's clean check cannot see, such as {}; \
pass --include-ignored to remove them",
kept_ignored.len(),
kept_ignored.first().map_or("", |path| path.as_str())
));
}
None
}
fn unsealable(worktree: &Path, status_line: &str) -> bool {
let Some(path) = status_line.get(3..) else {
return false;
};
let path = path.trim_end_matches('/');
path == ".brgr" || path.starts_with(".brgr/") || worktree.join(path).join(".git").exists()
}
const DISPOSABLE: [&str; 13] = [
"node_modules",
"target",
".next",
".turbo",
"__pycache__",
".pytest_cache",
".mypy_cache",
".venv",
".gradle",
".gjc",
".commandcode",
".agent-progress",
".build",
];
const TOOL_STATE: [&str; 4] = [".gjc", ".commandcode", ".agent-progress", ".build"];
fn harness_cache(status_line: &str) -> bool {
let Some(path) = status_line.strip_prefix("?? ") else {
return false;
};
let directories = path
.strip_suffix('/')
.unwrap_or_else(|| path.rsplit_once('/').map_or("", |(parent, _)| parent));
directories
.split('/')
.any(|component| TOOL_STATE.contains(&component))
}
fn regenerable(path: &str) -> bool {
path.strip_suffix('/')
.map(|directory| directory.rsplit('/').next().unwrap_or(directory))
.is_some_and(|name| DISPOSABLE.contains(&name))
}
fn patch_is_sealed(store: &Store, task: &TaskSpec) -> bool {
if !task.evidence.capture_diff {
return false;
}
let Ok(Some(result)) = store.revision_result(task.task_id, task.revision) else {
return false;
};
let Some(sealed) = result
.artifacts
.iter()
.find(|artifact| artifact.media_type == "text/x-diff")
else {
return false;
};
let limit = artifact_limit(task);
let Ok(sealed) = store.read_artifact(sealed, limit) else {
return false;
};
if !brgr_core::task_patch(task, limit, std::time::Duration::from_secs(30))
.is_ok_and(|now| now == sealed)
{
return false;
}
let workspace = Path::new(&task.workspace);
task.evidence.files.iter().all(|relative| {
let path = workspace.join(relative);
if fs::symlink_metadata(&path).is_err() {
return true;
}
read_bounded(&path, limit).is_some_and(|bytes| {
let digest = crate::pane_adapter::native_digest(&bytes);
result
.artifacts
.iter()
.any(|artifact| artifact.digest == digest)
})
})
}
fn artifact_limit(task: &TaskSpec) -> u64 {
task.artifact_contract.max_bytes.min(8 * 1024 * 1024)
}
fn read_bounded(path: &Path, limit: u64) -> Option<Vec<u8>> {
let mut bytes = Vec::new();
fs::File::open(path)
.ok()?
.take(limit.saturating_add(1))
.read_to_end(&mut bytes)
.ok()?;
(u64::try_from(bytes.len()).ok()? <= limit).then_some(bytes)
}
fn repository_directories(worktrees_root: &Path) -> (Vec<PathBuf>, Vec<PathBuf>) {
if !worktrees_root.is_dir() {
return (Vec::new(), Vec::new());
}
let (children, mut unreadable) = child_directories(worktrees_root);
let mut repositories = Vec::new();
for child in children {
if fs::read_dir(&child).is_ok() {
repositories.push(child);
} else {
unreadable.push(child);
}
}
(repositories, unreadable)
}
fn child_directories(directory: &Path) -> (Vec<PathBuf>, Vec<PathBuf>) {
let Ok(read) = fs::read_dir(directory) else {
return (Vec::new(), vec![directory.to_path_buf()]);
};
let mut found = Vec::new();
let mut unreadable = Vec::new();
for entry in read {
match entry {
Ok(entry) if !file_name(&entry.path()).starts_with('.') => found.push(entry.path()),
Ok(_) => {}
Err(_) => unreadable.push(directory.to_path_buf()),
}
}
found.sort();
unreadable.dedup();
(found, unreadable)
}
fn unreadable_entry(path: PathBuf) -> Entry {
let slug = file_name(&path);
Entry {
worktree: path,
slug,
owner: None,
ignored: Vec::new(),
outcome: Outcome::Kept("directory could not be read".to_owned()),
}
}
fn parse_slug(slug: &str) -> Option<(&str, u32)> {
const PREFIX_LENGTH: usize = 8;
let (prefix, revision) = match slug.split_once("-r") {
Some((prefix, revision)) => {
let parsed = revision.parse::<u32>().ok()?;
if parsed < 2 || revision != parsed.to_string() {
return None;
}
(prefix, parsed)
}
None => (slug, 1),
};
if prefix.len() != PREFIX_LENGTH
|| !prefix
.bytes()
.all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
{
return None;
}
Some((prefix, revision))
}
fn sweep_outcome(
worktree: &Path,
slug: &str,
assessment: &Assessment,
store: &Store,
apply: bool,
) -> (Outcome, bool) {
match (
assessment.blocked.as_ref(),
assessment.primary.as_ref(),
assessment.task.as_ref(),
) {
(Some(reason), _, _) => (Outcome::Kept(reason.clone()), false),
(None, Some(primary), Some(task)) if apply => {
match remove(
worktree,
slug,
primary,
assessment.force,
(store, task, true),
) {
Ok(RemovedWhat::Both { reason }) => (Outcome::Removed(reason), true),
Ok(RemovedWhat::CheckoutOnly { reason }) => {
(Outcome::RemovedKeepingBranch(reason), true)
}
Err(reason) => (Outcome::Kept(reason), false),
}
}
(None, Some(_), Some(_)) => (Outcome::Removable, false),
(None, _, _) => (
Outcome::Kept("no repository was resolved for it".to_owned()),
false,
),
}
}
enum RemovedWhat {
Both { reason: Option<String> },
CheckoutOnly { reason: String },
}
fn remove(
worktree: &Path,
slug: &str,
primary: &Path,
force: bool,
(store, task, allow_sealed): (&Store, &TaskSpec, bool),
) -> Result<RemovedWhat, String> {
let path = lossy(worktree);
let mut arguments = vec!["worktree", "remove"];
if force {
arguments.push("--force");
}
arguments.push(&path);
git(primary, &arguments)
.map_err(|error| format!("git declined to remove the worktree: {error}"))?;
let branch = format!("brgr/task-{slug}");
match delete_task_branch(primary, &branch, store, task, allow_sealed) {
Ok(reason) => Ok(RemovedWhat::Both { reason }),
Err(reason) => Ok(RemovedWhat::CheckoutOnly {
reason: format!("checkout removed; branch {branch} kept: {reason}"),
}),
}
}
struct Repositories {
inventories: Vec<Inventory>,
}
impl Repositories {
fn load(store: &Store) -> Self {
let mut known = Self {
inventories: Vec::new(),
};
for primary in store.task_checkouts().unwrap_or_default() {
if let Some(inventory) = Inventory::load(Path::new(&primary)) {
known.adopt(inventory);
}
}
known
}
fn adopt(&mut self, inventory: Inventory) -> usize {
if let Some(index) = self
.inventories
.iter()
.position(|known| known.primary == inventory.primary)
{
return index;
}
self.inventories.push(inventory);
self.inventories.len() - 1
}
fn locate(
&mut self,
recorded: Option<&str>,
worktree: &Path,
resolved: &Path,
) -> Option<usize> {
if let Some(recorded) = recorded {
let primary = resolve(Path::new(recorded));
if let Some(index) = self
.inventories
.iter()
.position(|known| known.primary == primary)
{
return Some(index);
}
}
if let Some(index) = self
.inventories
.iter()
.position(|known| known.is_registered(resolved))
{
return Some(index);
}
Inventory::load(worktree).map(|inventory| self.adopt(inventory))
}
}
struct Inventory {
primary: PathBuf,
registered: Vec<PathBuf>,
locked: Vec<PathBuf>,
}
impl Inventory {
fn load(checkout: &Path) -> Option<Self> {
let listing = git(checkout, &["worktree", "list", "--porcelain"]).ok()?;
let primary = listing
.lines()
.find_map(|line| line.strip_prefix("worktree "))
.map(|path| resolve(Path::new(path)))?;
Some(Self::parse(primary, &listing))
}
fn parse(primary: PathBuf, listing: &str) -> Self {
let mut registered = Vec::new();
let mut locked = Vec::new();
let mut current: Option<PathBuf> = None;
for line in listing.lines() {
if let Some(path) = line.strip_prefix("worktree ") {
let path = resolve(Path::new(path));
registered.push(path.clone());
current = Some(path);
} else if (line == "locked" || line.starts_with("locked "))
&& let Some(path) = current.clone()
{
locked.push(path);
}
}
Self {
primary,
registered,
locked,
}
}
fn is_registered(&self, resolved: &Path) -> bool {
self.registered.iter().any(|path| path == resolved)
}
fn is_locked(&self, resolved: &Path) -> bool {
self.locked.iter().any(|path| path == resolved)
}
}
const UNPRESERVED: &str = "branch has commits not merged into the repository's HEAD and \
found on no other branch or remote; deleting it would lose them";
enum Holder {
Elsewhere,
Sealed(String),
}
fn preservation(
primary: &Path,
branch: &str,
store: &Store,
task: &TaskSpec,
allow_sealed: bool,
) -> Result<Option<(String, Holder)>, String> {
if let Some((tip, _)) = preserved_elsewhere(primary, branch)? {
return Ok(Some((tip, Holder::Elsewhere)));
}
if !allow_sealed {
return Ok(None);
}
let tip = branch_tip(primary, branch)?;
Ok(sealed_result_holds_branch(primary, &tip, store, task)
.map(|result_id| (tip, Holder::Sealed(result_id))))
}
fn branch_tip(primary: &Path, branch: &str) -> Result<String, String> {
git(
primary,
&[
"rev-parse",
"--verify",
&format!("refs/heads/{branch}^{{commit}}"),
],
)
.map(|tip| tip.trim().to_owned())
}
fn preserved_elsewhere(primary: &Path, branch: &str) -> Result<Option<(String, String)>, String> {
let tip = branch_tip(primary, branch)?;
if git(primary, &["merge-base", "--is-ancestor", &tip, "HEAD"]).is_ok() {
return Ok(Some((tip, "HEAD".to_owned())));
}
let holders = git(
primary,
&[
"for-each-ref",
"--contains",
&tip,
"--format=%(refname)",
"refs/heads",
"refs/remotes",
],
)?;
let holder = holders
.lines()
.map(str::trim)
.find(|name| {
!name.is_empty()
&& !name.starts_with("refs/heads/brgr/task-")
&& !name.ends_with("/HEAD")
})
.map(str::to_owned);
Ok(holder.map(|holder| (tip, holder)))
}
fn sealed_result_holds_branch(
primary: &Path,
tip: &str,
store: &Store,
task: &TaskSpec,
) -> Option<String> {
if !task.evidence.capture_diff {
return None;
}
let result = store.revision_result(task.task_id, task.revision).ok()??;
let sealed = result
.artifacts
.iter()
.find(|artifact| artifact.media_type == "text/x-diff")?;
let limit = artifact_limit(task);
let sealed = store.read_artifact(sealed, limit).ok()?;
let merge_base = git(primary, &["merge-base", "HEAD", tip]).ok()?;
let merge_base = merge_base.trim();
if merge_base.is_empty() {
return None;
}
let branch_diff = git_bounded(primary, &["diff", "--binary", merge_base, tip], limit).ok()?;
let branch_counts = changed_line_counts(&branch_diff)?;
let sealed_counts = changed_line_counts(&sealed)?;
counts_are_subset(&branch_counts, &sealed_counts).then(|| result.result_id.to_string())
}
fn counts_are_subset(
branch: &HashMap<(String, String), usize>,
sealed: &HashMap<(String, String), usize>,
) -> bool {
branch
.iter()
.all(|(key, count)| sealed.get(key).copied().unwrap_or(0) >= *count)
}
fn changed_line_counts(diff: &[u8]) -> Option<HashMap<(String, String), usize>> {
let text = std::str::from_utf8(diff).ok()?;
let mut counts = HashMap::new();
let mut paths: Option<(String, String)> = None;
let mut in_hunk = false;
for line in text.split('\n') {
if binary_patch_line(line) {
return None;
}
if let Some(rest) = line.strip_prefix("diff --git ") {
in_hunk = false;
paths = git_diff_paths(rest);
paths.as_ref()?;
continue;
}
if line.starts_with("@@ ") {
in_hunk = true;
continue;
}
let Some(sign) = line.chars().next() else {
continue;
};
if in_hunk {
if sign != '+' && sign != '-' {
continue;
}
let (old, new) = paths.as_ref()?;
let path = match sign {
'+' if new != "/dev/null" => new,
'-' if old != "/dev/null" => old,
'+' => old,
_ => new,
};
*counts.entry((path.clone(), line.to_owned())).or_default() += 1;
} else if carries_a_change(line) {
let (_, new) = paths.as_ref()?;
*counts.entry((new.clone(), line.to_owned())).or_default() += 1;
}
}
Some(counts)
}
fn carries_a_change(line: &str) -> bool {
[
"new file mode ",
"deleted file mode ",
"old mode ",
"new mode ",
"rename from ",
"rename to ",
"copy from ",
"copy to ",
]
.iter()
.any(|prefix| line.starts_with(prefix))
}
fn binary_patch_line(line: &str) -> bool {
if line == "GIT binary patch" || line.starts_with("Binary files ") {
return true;
}
line.strip_prefix("literal ")
.or_else(|| line.strip_prefix("delta "))
.is_some_and(|rest| !rest.is_empty() && rest.bytes().all(|byte| byte.is_ascii_digit()))
}
fn git_diff_paths(rest: &str) -> Option<(String, String)> {
let rest = rest.strip_prefix("a/")?;
let (old, new) = rest.split_once(" b/")?;
Some((old.to_owned(), new.to_owned()))
}
fn delete_task_branch(
primary: &Path,
branch: &str,
store: &Store,
task: &TaskSpec,
allow_sealed: bool,
) -> Result<Option<String>, String> {
let Some((tip, holder)) = preservation(primary, branch, store, task, allow_sealed)? else {
return Err(UNPRESERVED.to_owned());
};
restore_if_moved(primary, branch, &tip)?;
Ok(match holder {
Holder::Sealed(result_id) => Some(format!("changes are held by sealed result {result_id}")),
Holder::Elsewhere => None,
})
}
fn restore_if_moved(primary: &Path, branch: &str, tip: &str) -> Result<(), String> {
let deleted = git(primary, &["branch", "-D", branch])
.map_err(|error| format!("git refused to delete it: {error}"))?;
let was = deleted
.rsplit("(was ")
.next()
.and_then(|rest| rest.split(')').next())
.unwrap_or_default()
.trim();
if was.is_empty() || !tip.starts_with(was) {
let restored = if was.is_empty() { tip } else { was };
git(primary, &["branch", branch, restored]).map_err(|error| {
format!("branch moved during removal and could not be restored: {error}")
})?;
return Err("branch moved while it was being removed; restored and kept".to_owned());
}
Ok(())
}
fn git(directory: &Path, args: &[&str]) -> Result<String, String> {
let bytes = git_bounded(directory, args, u64::MAX)?;
Ok(String::from_utf8_lossy(&bytes).into_owned())
}
fn git_bounded(directory: &Path, args: &[&str], limit: u64) -> Result<Vec<u8>, String> {
let output = Command::new("git")
.arg("-C")
.arg(directory)
.args(args)
.output()
.map_err(|error| error.to_string())?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(stderr.trim().replace('\n', "; "));
}
if u64::try_from(output.stdout.len()).unwrap_or(u64::MAX) > limit {
return Err(format!("output exceeds {limit} bytes"));
}
Ok(output.stdout)
}
fn is_self_or_ancestor(candidate: &Path, inside: &Path) -> bool {
inside == candidate || inside.starts_with(candidate)
}
fn canonical(path: &Path) -> Option<PathBuf> {
path.canonicalize().ok()
}
fn resolve(path: &Path) -> PathBuf {
if let Some(found) = canonical(path) {
return found;
}
match (path.parent().and_then(canonical), path.file_name()) {
(Some(parent), Some(name)) => parent.join(name),
_ => path.to_path_buf(),
}
}
fn file_name(path: &Path) -> String {
path.file_name()
.and_then(|name| name.to_str())
.unwrap_or_default()
.to_owned()
}
fn lossy(path: &Path) -> String {
path.to_string_lossy().into_owned()
}
pub(crate) fn reclaim_task(paths: &Paths, task: TaskId, revision: u32) -> Option<String> {
let store = Store::open(&paths.store).ok()?;
let launch: crate::LaunchEnvelope =
serde_json::from_slice(&fs::read(paths.launch(task, revision)).ok()?).ok()?;
if launch.keep_worktree || (launch.keep_pane && launch.pane_mode) {
return None;
}
let worktree = resolve(Path::new(&launch.spec.workspace));
let root = canonical(&paths.worktrees)?;
let parent = worktree.parent()?;
if parent.parent() != Some(root.as_path()) || !worktree.is_dir() {
return None;
}
if paths.pane_receipt(task, revision).exists() && !pane_closed(paths, task, revision) {
return None;
}
if unsettled(&store, task, revision, None).is_some() {
return None;
}
let exited_by = std::time::Instant::now() + WORKER_EXIT_WAIT;
while crate::supervision::worker_may_be_running(paths, task) {
if std::time::Instant::now() >= exited_by {
return None;
}
std::thread::sleep(std::time::Duration::from_millis(200));
}
let lock = fs::OpenOptions::new()
.read(true)
.write(true)
.create(true)
.truncate(false)
.open(paths.runs.join(format!("{task}-r{revision}.reclaim.lock")))
.ok()?;
if lock.try_lock().is_err() {
return None;
}
if !worktree.is_dir() {
return None;
}
let assessed = paths.runs.join(format!("{task}-r{revision}.reclaimed"));
if assessed.exists() {
return None;
}
if let Some(cwd) = std::env::current_dir()
.ok()
.and_then(|path| canonical(&path))
&& is_self_or_ancestor(&worktree, &cwd)
{
std::env::set_current_dir(&paths.home).ok()?;
}
let current_dir = std::env::current_dir()
.ok()
.and_then(|path| canonical(&path));
let sweep = Sweep {
store: &store,
current_dir: current_dir.as_deref(),
include_ignored: ignored_files_allowed(&store, paths, task, revision),
worker_alive: &|task| crate::supervision::worker_may_be_running(paths, task),
};
let slug = file_name(&worktree);
let mut known = Repositories {
inventories: Vec::new(),
};
let assessment = assess(&worktree, &slug, &sweep, &mut known);
let shown = worktree.display();
let kept = |reason: String| {
worktree.exists().then(|| {
let reason = reason.replace(
"pass --include-ignored to remove them",
"`brgr prune --apply --include-ignored` removes them",
);
format!("kept worktree {shown}: {reason}; `brgr prune` lists it")
})
};
let _ = fs::write(&assessed, b"");
match (assessment.blocked, assessment.primary, assessment.task) {
(Some(reason), _, _) => kept(reason),
(None, Some(primary), Some(task)) => {
match remove(
&worktree,
&slug,
&primary,
assessment.force,
(&store, &task, false),
) {
Ok(RemovedWhat::Both { reason }) => {
remove_if_emptied(parent, current_dir.as_deref());
Some(match reason {
None => format!("removed worktree {shown}"),
Some(reason) => format!("removed worktree {shown}; {reason}"),
})
}
Ok(RemovedWhat::CheckoutOnly { reason }) => {
remove_if_emptied(parent, current_dir.as_deref());
Some(format!("removed worktree {shown}; {reason}"))
}
Err(reason) => kept(reason),
}
}
(None, _, _) => None,
}
}
const WORKER_EXIT_WAIT: std::time::Duration = std::time::Duration::from_secs(5);
fn include_ignored_marker(paths: &Paths, task: TaskId, revision: u32) -> std::path::PathBuf {
paths
.runs
.join(format!("{task}-r{revision}.include-ignored"))
}
pub(crate) fn allow_ignored(paths: &Paths, task: TaskId, revision: u32, decision: &str) {
let _ = fs::write(
include_ignored_marker(paths, task, revision),
decision.as_bytes(),
);
}
fn ignored_files_allowed(store: &Store, paths: &Paths, task: TaskId, revision: u32) -> bool {
let Ok(marked) = fs::read_to_string(include_ignored_marker(paths, task, revision)) else {
return false;
};
let Ok(result) = store.latest_result(task) else {
return false;
};
if result.revision != revision {
return false;
}
store
.decision_for_result(result.result_id)
.ok()
.flatten()
.is_some_and(|decision| decision.decision_id.to_string() == marked.trim())
}
pub(crate) fn forget_ignored(paths: &Paths, task: TaskId, revision: u32) {
let _ = fs::remove_file(include_ignored_marker(paths, task, revision));
}
pub(crate) fn reclaim_and_report(paths: &Paths, task: TaskId, revision: u32) {
if let Some(note) = reclaim_task(paths, task, revision) {
eprintln!("brgr · {note}");
}
}
fn pane_closed(paths: &Paths, task: TaskId, revision: u32) -> bool {
fs::read(paths.pane_receipt(task, revision))
.ok()
.and_then(|bytes| serde_json::from_slice::<serde_json::Value>(&bytes).ok())
.is_some_and(|receipt| receipt["cleanup"] == "closed")
}
pub(crate) fn command(
paths: &Paths,
apply: bool,
include_ignored: bool,
json_output: bool,
) -> Result<()> {
let store = Store::open(&paths.store)?;
let swept = prune(
&paths.worktrees,
&store,
&|task| crate::supervision::worker_may_be_running(paths, task),
apply,
include_ignored,
);
let worktrees: Vec<serde_json::Value> = swept
.entries
.iter()
.map(|entry| {
let mut row = json!({
"worktree": entry.worktree,
"task_slug": entry.slug,
"status": entry.outcome.code(),
});
if let Some(reason) = entry.outcome.reason() {
row["reason"] = json!(reason);
}
if let Some(owner) = &entry.owner {
row["owner_id"] = json!(owner);
}
if !entry.ignored.is_empty() {
row["ignored_paths"] = json!(entry.ignored);
}
row
})
.collect();
let orphans: Vec<serde_json::Value> = swept
.orphans
.iter()
.map(|orphan| {
let mut row = json!({"branch": orphan.branch, "status": orphan.outcome.code()});
if let Some(reason) = orphan.outcome.reason() {
row["reason"] = json!(reason);
}
if let Some(owner) = &orphan.owner {
row["owner_id"] = json!(owner);
}
row
})
.collect();
let mut tally = BTreeMap::<&str, usize>::new();
for entry in &swept.entries {
*tally.entry(entry.outcome.code()).or_default() += 1;
}
let mut orphan_tally = BTreeMap::<&str, usize>::new();
for orphan in &swept.orphans {
*orphan_tally.entry(orphan.outcome.code()).or_default() += 1;
}
let count = |code: &str| tally.get(code).copied().unwrap_or_default();
let orphan_count = |code: &str| orphan_tally.get(code).copied().unwrap_or_default();
let removed = count("removed") + count("removed_branch_kept");
print_value(
&json!({
"applied": apply,
"removed": removed,
"removed_keeping_branch": count("removed_branch_kept"),
"removable": count("removable"),
"kept": count("kept"),
"worktrees": worktrees,
"orphan_branches_removed": orphan_count("removed"),
"orphan_branches_removable": orphan_count("removable"),
"orphan_branches_kept": orphan_count("kept"),
"orphan_branches": orphans,
"note": "sealed results, decisions, artifacts, and task rows are never removed",
}),
json_output,
);
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_file_over_the_limit_is_never_read_whole() {
let temp = tempfile::tempdir().unwrap();
let path = temp.path().join("evidence.bin");
fs::write(&path, b"0123456789").unwrap();
assert_eq!(read_bounded(&path, 10).unwrap(), b"0123456789");
assert!(read_bounded(&path, 9).is_none());
assert!(read_bounded(&temp.path().join("absent"), 10).is_none());
}
#[test]
fn only_an_untracked_harness_directory_is_a_cache() {
assert!(harness_cache("?? .gjc/"));
assert!(harness_cache("?? .commandcode/"));
assert!(harness_cache("?? .agent-progress/"));
assert!(harness_cache("?? .build/"));
assert!(harness_cache("?? .gjc/session.json"));
assert!(!harness_cache("?? .claude/"));
assert!(!harness_cache("?? .cursor/"));
assert!(!harness_cache(" M .gjc/config.json"));
assert!(!harness_cache("?? src/main.rs"));
assert!(!harness_cache("?? .gjcx/"));
assert!(!harness_cache("?? .build"));
assert!(!harness_cache("?? .build-notes.txt"));
assert!(!harness_cache("?? .buildx/"));
assert!(!harness_cache("?? dist/"));
}
#[test]
fn only_dependency_and_build_directories_are_regenerable() {
assert!(regenerable("node_modules/"));
assert!(regenerable("packages/web/.next/"));
assert!(regenerable("target/"));
assert!(regenerable(".gjc/"));
assert!(regenerable(".commandcode/"));
assert!(regenerable(".agent-progress/"));
assert!(regenerable(".build/"));
assert!(regenerable("ios/.build/"));
assert!(!regenerable("dist/"));
assert!(!regenerable("build/"));
assert!(!regenerable("coverage/"));
assert!(!regenerable(".env"));
assert!(!regenerable("secrets/credentials.json"));
assert!(!regenerable("node_modules_backup.tar"));
assert!(!regenerable("build/prod.env"));
assert!(!regenerable("dist/.env"));
assert!(!regenerable("node_modules/x/.env"));
assert!(!regenerable(".build-notes.txt"));
assert!(!regenerable(".buildx/"));
assert!(!regenerable(".build"));
}
#[test]
fn untracked_and_ignored_tool_state_directories_get_the_same_verdict() {
for path in [
".agent-progress/",
".build/",
".commandcode/",
".gjc/",
"ios/.build/",
] {
assert!(regenerable(path), "{path}");
assert!(harness_cache(&format!("?? {path}")), "{path}");
}
for path in [".build", ".build-notes.txt", ".buildx/", "dist/", "build/"] {
assert!(!regenerable(path), "{path}");
assert!(!harness_cache(&format!("?? {path}")), "{path}");
}
}
#[test]
fn an_untracked_file_under_a_generic_looking_directory_is_not_a_cache() {
for status in [
"?? work/target/plan.txt",
"?? node_modules/x.js",
"?? target/",
"?? packages/web/.next/page.js",
"?? .venv/notes.txt",
] {
assert!(!harness_cache(status), "{status}");
}
for status in [
"?? vendor/.build/x",
"?? .agent-progress/state.json",
"?? a/b/.gjc/c",
] {
assert!(harness_cache(status), "{status}");
}
assert!(
!harness_cache("?? vendor/.build"),
"a file named like the directory"
);
}
#[test]
fn every_tool_state_name_is_also_disposable_when_ignored() {
for name in TOOL_STATE {
assert!(DISPOSABLE.contains(&name), "{name}");
}
}
#[test]
fn a_changed_line_that_looks_like_a_diff_header_is_still_counted() {
let diff = "diff --git a/q.sql b/q.sql\nindex 111..222 100644\n--- a/q.sql\n+++ b/q.sql\n@@ -1,2 +1,2 @@\n--- old comment\n+++ new\n select 1;\n";
let counts = changed_line_counts(diff.as_bytes()).unwrap();
let key = |line: &str| ("q.sql".to_owned(), line.to_owned());
assert_eq!(counts.get(&key("--- old comment")), Some(&1));
assert_eq!(counts.get(&key("+++ new")), Some(&1));
assert_eq!(counts.get(&key("--- a/q.sql")), None);
assert_eq!(counts.get(&key("+++ b/q.sql")), None);
}
#[test]
fn a_change_without_changed_lines_is_still_a_change() {
let none = HashMap::new();
for diff in [
"diff --git a/run.sh b/run.sh\nold mode 100644\nnew mode 100755\n",
"diff --git a/a.txt b/b.txt\nsimilarity index 100%\nrename from a.txt\nrename to b.txt\n",
"diff --git a/e b/e\nnew file mode 100644\nindex 0000000..e69de29\n",
"diff --git a/gone b/gone\ndeleted file mode 100644\nindex e69de29..0000000\n",
] {
let counts = changed_line_counts(diff.as_bytes()).unwrap();
assert!(!counts_are_subset(&counts, &none), "{diff}");
assert!(counts_are_subset(&counts, &counts), "{diff}");
}
}
use crate::adversary::Adversary;
#[test]
fn a_parsed_slug_is_always_one_brgr_could_have_written() {
const PREFIX_ALPHABET: &str = "0123456789abcdefabcdefabcdefABCDEF";
const SUFFIXES: &[&str] = &[
"1",
"2",
"10",
"01",
"007",
"+5",
"-3",
"0",
"",
"x",
" 2",
"2 ",
"4294967295",
"4294967296",
"99999999999999999999",
"1_0",
"2.0",
"Ù£",
];
let mut adversary = Adversary::new(0x5eed_0f0f_c0de_0002);
let mut accepted = 0_u32;
for case in 0..4_096 {
let length = if adversary.below(4) == 0 {
adversary.below(11)
} else {
8
};
let mut slug: String = (0..length)
.map(|_| {
let letters: Vec<char> = PREFIX_ALPHABET.chars().collect();
letters[adversary.below(letters.len())]
})
.collect();
match adversary.below(4) {
0 => {}
1 | 2 => {
let suffix = SUFFIXES[adversary.below(SUFFIXES.len())];
slug.push_str("-r");
slug.push_str(suffix);
}
_ => slug = adversary.text("0123456789abcdefABCDEFr-+ ._", 16),
}
let Some((prefix, revision)) = parse_slug(&slug) else {
continue;
};
accepted += 1;
let rendered = if revision == 1 {
prefix.to_owned()
} else {
format!("{prefix}-r{revision}")
};
assert_eq!(
rendered, slug,
"case {case}: {slug:?} parsed as ({prefix:?}, {revision}), which renders as \
{rendered:?} — brgr never created a directory by that name"
);
}
assert!(
accepted > 100,
"only {accepted} of 4096 generated slugs parsed; the generator is no \
longer reaching the path this asserts about"
);
}
#[test]
fn a_slug_suffix_has_to_be_the_one_task_slug_would_write() {
assert_eq!(parse_slug("3d3c9081-r+5"), None);
assert_eq!(parse_slug("3d3c9081-r007"), None);
assert_eq!(parse_slug("3d3c9081-r01"), None);
assert_eq!(parse_slug("3d3c9081-r1"), None);
assert_eq!(parse_slug("3d3c9081"), Some(("3d3c9081", 1)));
assert_eq!(parse_slug("3d3c9081-r2"), Some(("3d3c9081", 2)));
assert_eq!(
parse_slug("3d3c9081-r4294967295"),
Some(("3d3c9081", u32::MAX))
);
}
#[test]
fn slugs_parse_their_revision_and_reject_every_other_directory_name() {
assert_eq!(parse_slug("3d3c9081"), Some(("3d3c9081", 1)));
assert_eq!(parse_slug("3d3c9081-r4"), Some(("3d3c9081", 4)));
assert_eq!(parse_slug("3d3c9081-r0"), None);
assert_eq!(parse_slug("3d3c9081-rx"), None);
assert_eq!(parse_slug("not-a-slug"), None);
assert_eq!(parse_slug(""), None);
assert_eq!(parse_slug(".DS_Store"), None);
assert_eq!(parse_slug("f"), None);
assert_eq!(parse_slug("cafe"), None);
assert_eq!(parse_slug("dec0de"), None);
assert_eq!(parse_slug("deadbeefcafe"), None);
assert_eq!(parse_slug("deadbeefcafe-r2"), None);
}
#[test]
fn slugs_reject_uppercase_hex_that_task_slug_could_not_emit() {
assert_eq!(parse_slug("abcd1234"), Some(("abcd1234", 1)));
assert_eq!(parse_slug("ABCD1234"), None);
assert_eq!(parse_slug("abcD1234"), None);
assert_eq!(parse_slug("ABCD1234-r2"), None);
}
#[test]
fn the_cwd_guard_covers_the_directory_holding_the_worktrees() {
let repository = Path::new("/home/brgr/worktrees/repo");
let worktree = repository.join("abcd1234");
assert!(is_self_or_ancestor(&worktree, &worktree));
assert!(is_self_or_ancestor(&worktree, &worktree.join("src")));
assert!(is_self_or_ancestor(repository, repository));
assert!(is_self_or_ancestor(repository, &worktree));
assert!(!is_self_or_ancestor(&worktree, repository));
assert!(!is_self_or_ancestor(
&worktree,
Path::new("/home/brgr/worktrees/other/abcd1234")
));
}
#[test]
fn outcomes_report_their_reason_and_removed_states_do_not() {
assert_eq!(Outcome::Removable.code(), "removable");
assert_eq!(Outcome::Removed(None).code(), "removed");
assert_eq!(Outcome::Kept("why".to_owned()).code(), "kept");
assert_eq!(Outcome::Kept("why".to_owned()).reason(), Some("why"));
let kept_branch = Outcome::RemovedKeepingBranch("branch kept".to_owned());
assert_eq!(kept_branch.code(), "removed_branch_kept");
assert_eq!(kept_branch.reason(), Some("branch kept"));
assert_eq!(Outcome::Removed(None).reason(), None);
assert_eq!(
Outcome::Removed(Some("sealed".to_owned())).reason(),
Some("sealed")
);
assert_eq!(Outcome::Removable.reason(), None);
}
#[test]
fn changed_line_counts_ignore_headers_and_count_multiplicity() {
let diff = b"\
diff --git a/foo.txt b/foo.txt
index 1111111..2222222 100644
--- a/foo.txt
+++ b/foo.txt
@@ -1,3 +1,4 @@
context
-old
+new
+new
";
let counts = changed_line_counts(diff).unwrap();
assert_eq!(counts.get(&("foo.txt".into(), "-old".into())), Some(&1));
assert_eq!(counts.get(&("foo.txt".into(), "+new".into())), Some(&2));
assert!(!counts.keys().any(|(_, line)| line.starts_with("@@")));
assert!(!counts.keys().any(|(_, line)| line.starts_with("diff ")));
}
#[test]
fn a_binary_patch_cannot_prove_containment() {
let diff = b"\
diff --git a/image.bin b/image.bin
new file mode 100644
index 0000000..abc1234
GIT binary patch
literal 4
zcmV+0NbbH9
";
assert_eq!(changed_line_counts(diff), None);
}
#[test]
fn a_branch_multiset_must_not_exceed_the_sealed_multiset() {
let mut sealed = HashMap::new();
sealed.insert(("f".into(), "+foo".into()), 2);
let mut fewer = HashMap::new();
fewer.insert(("f".into(), "+foo".into()), 1);
assert!(counts_are_subset(&fewer, &sealed));
let mut extra = HashMap::new();
extra.insert(("f".into(), "+foo".into()), 3);
assert!(!counts_are_subset(&extra, &sealed));
}
}