use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use serde::Serialize;
use crate::session_manager::worktree_keep_list::KeepList;
use crate::session_manager::worktree_reclaim::{BranchPrState, ReclaimGate, ReclaimVerdict};
use crate::session_manager::worktree_reclaim_claim::ClaimState;
use crate::session_manager::worktree_registry::Admission;
use crate::session_manager::worktree_safety::DirtyWorktree;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub(crate) enum WorktreeTier {
Stale,
Review,
Keep,
Missing,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "kebab-case")]
pub(crate) enum ReasonCode {
KeepList,
Dirty,
Unpushed,
LiveSession,
AgentOwned,
UnknownBranchState,
NotRemovable,
NotAdmitted,
Missing,
MergedPr,
ContainedInMainline,
}
#[derive(Debug, Clone, Serialize)]
pub(crate) struct Reason {
pub code: ReasonCode,
pub detail: String,
}
impl Reason {
fn new(code: ReasonCode, detail: impl Into<String>) -> Self {
Self {
code,
detail: detail.into(),
}
}
}
#[derive(Debug, Clone, Serialize)]
pub(crate) struct Classification {
pub tier: WorktreeTier,
pub reasons: Vec<Reason>,
}
pub(crate) struct WorktreeFacts<'a> {
pub path: &'a Path,
pub branch: Option<&'a str>,
pub admission: Admission,
pub claim: &'a ClaimState,
pub pr: &'a BranchPrState,
pub verdict: &'a ReclaimVerdict,
}
pub(crate) fn classify_tier(
facts: &WorktreeFacts<'_>,
keep_list: &KeepList,
probe_dirt: &dyn Fn(&Path) -> Option<DirtyWorktree>,
) -> Classification {
if let Some(kept) = keep_list.keeps(facts.path) {
return keep(vec![Reason::new(ReasonCode::KeepList, kept.detail())]);
}
if facts.admission == Admission::Prunable || !facts.path.exists() {
return Classification {
tier: WorktreeTier::Missing,
reasons: vec![Reason::new(
ReasonCode::Missing,
"git lists this worktree but the directory is gone — `git worktree prune` \
removes the registration",
)],
};
}
let mut reasons = Vec::new();
if let Some(dirt) = probe_dirt(facts.path) {
if dirt.dirty_files > 0 {
reasons.push(Reason::new(
ReasonCode::Dirty,
format!("{} uncommitted working-tree entries", dirt.dirty_files),
));
}
if dirt.unpushed_commits > 0 {
reasons.push(Reason::new(
ReasonCode::Unpushed,
format!("{} commits are on no remote", dirt.unpushed_commits),
));
}
if dirt.dirty_files == 0 && dirt.unpushed_commits == 0 {
reasons.push(Reason::new(ReasonCode::Dirty, dirt.reason.clone()));
}
}
if let Some(session) = claiming_session(facts.claim) {
reasons.push(Reason::new(
ReasonCode::LiveSession,
format!("session {session} claims this workspace"),
));
}
if let ReclaimVerdict::BlockedByAgent { reason, .. } = facts.verdict {
reasons.push(Reason::new(ReasonCode::AgentOwned, reason.clone()));
}
if facts.admission != Admission::Admitted {
reasons.push(Reason::new(
ReasonCode::NotAdmitted,
facts.admission.reason(),
));
}
if let ReclaimVerdict::Blocked {
gate: ReclaimGate::Removability,
reason,
} = facts.verdict
{
reasons.push(Reason::new(ReasonCode::NotRemovable, reason.clone()));
}
if !reasons.is_empty() {
return keep(reasons);
}
match (facts.pr, facts.branch) {
(BranchPrState::Merged { pr }, _) => Classification {
tier: WorktreeTier::Stale,
reasons: vec![Reason::new(
ReasonCode::MergedPr,
format!("PR #{pr} merged; tree clean and fully pushed"),
)],
},
(_, None) => Classification {
tier: WorktreeTier::Stale,
reasons: vec![Reason::new(
ReasonCode::ContainedInMainline,
"no branch, tree clean, and every commit on HEAD is already on a remote",
)],
},
(pr, Some(branch)) => Classification {
tier: WorktreeTier::Review,
reasons: vec![Reason::new(
ReasonCode::UnknownBranchState,
unlanded_detail(pr, branch),
)],
},
}
}
fn keep(reasons: Vec<Reason>) -> Classification {
Classification {
tier: WorktreeTier::Keep,
reasons,
}
}
fn claiming_session(claim: &ClaimState) -> Option<&str> {
match claim {
ClaimState::Unclaimed
| ClaimState::CallerNested { .. }
| ClaimState::ForeignNested { .. }
| ClaimState::DeadClaimsDiscarded { .. } => None,
ClaimState::CallerWorkspace { session } | ClaimState::Foreign { session, .. } => {
Some(session)
}
}
}
fn unlanded_detail(pr: &BranchPrState, branch: &str) -> String {
match pr {
BranchPrState::Open { pr } => format!("PR #{pr} for `{branch}` is still open"),
BranchPrState::ClosedUnmerged { pr } => {
format!("PR #{pr} for `{branch}` was closed without merging")
}
BranchPrState::NoPr => format!("no pull request found for `{branch}`"),
BranchPrState::Unknown => {
format!("the pull-request state of `{branch}` could not be determined")
}
BranchPrState::LookupFailed { reason } => {
format!("the pull-request lookup for `{branch}` failed: {reason}")
}
BranchPrState::Merged { pr } => format!("PR #{pr} for `{branch}` merged"),
}
}
#[derive(Debug, Clone, Serialize)]
pub(crate) struct PrRef {
pub number: u64,
pub state: &'static str,
}
impl PrRef {
pub(crate) fn from_state(pr: &BranchPrState) -> Option<Self> {
match pr {
BranchPrState::Merged { pr } => Some(Self {
number: *pr,
state: "merged",
}),
BranchPrState::Open { pr } => Some(Self {
number: *pr,
state: "open",
}),
BranchPrState::ClosedUnmerged { pr } => Some(Self {
number: *pr,
state: "closed_unmerged",
}),
BranchPrState::NoPr | BranchPrState::Unknown | BranchPrState::LookupFailed { .. } => {
None
}
}
}
}
#[derive(Debug, Clone, Serialize)]
pub(crate) struct SizeNote {
pub from_cache: bool,
pub truncated: bool,
pub measured_at: String,
pub unreadable: usize,
}
#[derive(Debug, Clone, Serialize)]
pub(crate) struct DiskWorktree {
pub id: String,
pub path: PathBuf,
pub branch: Option<String>,
pub tier: WorktreeTier,
pub reasons: Vec<Reason>,
pub gate: Option<ReclaimGate>,
pub reason: Option<String>,
pub reclaimable: bool,
pub bytes: Option<u64>,
pub size: Option<SizeNote>,
pub pr: Option<PrRef>,
pub session: Option<String>,
pub owning_session: Option<String>,
pub build_dir_bytes: Option<u64>,
}
#[derive(Debug, Clone, Serialize)]
pub(crate) struct DiskProject {
pub name: String,
pub path: PathBuf,
pub bytes: Option<u64>,
pub size: Option<SizeNote>,
pub worktrees: Vec<DiskWorktree>,
}
#[derive(Debug, Clone, Copy, Default, Serialize)]
pub(crate) struct TierCounts {
pub stale: usize,
pub review: usize,
pub keep: usize,
pub missing: usize,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum GroupBy {
None,
Session,
}
#[derive(Debug, Clone, Serialize)]
pub(crate) struct DiskSessionGroup {
pub session_id: Option<String>,
pub bytes: u64,
pub build_dir_bytes: u64,
pub worktree_count: usize,
pub tiers: TierCounts,
pub worktree_paths: Vec<PathBuf>,
}
pub(crate) fn group_by_session(projects: &[DiskProject]) -> Vec<DiskSessionGroup> {
let mut groups: BTreeMap<Option<String>, DiskSessionGroup> = BTreeMap::new();
for wt in projects.iter().flat_map(|p| p.worktrees.iter()) {
let group = groups
.entry(wt.owning_session.clone())
.or_insert_with(|| DiskSessionGroup {
session_id: wt.owning_session.clone(),
bytes: 0,
build_dir_bytes: 0,
worktree_count: 0,
tiers: TierCounts::default(),
worktree_paths: Vec::new(),
});
group.bytes = group.bytes.saturating_add(wt.bytes.unwrap_or(0));
group.build_dir_bytes = group
.build_dir_bytes
.saturating_add(wt.build_dir_bytes.unwrap_or(0));
group.worktree_count += 1;
match wt.tier {
WorktreeTier::Stale => group.tiers.stale += 1,
WorktreeTier::Review => group.tiers.review += 1,
WorktreeTier::Keep => group.tiers.keep += 1,
WorktreeTier::Missing => group.tiers.missing += 1,
}
group.worktree_paths.push(wt.path.clone());
}
let mut out: Vec<DiskSessionGroup> = groups.into_values().collect();
out.sort_by(|a, b| {
b.bytes
.cmp(&a.bytes)
.then_with(|| a.session_id.is_none().cmp(&b.session_id.is_none()))
.then_with(|| a.session_id.cmp(&b.session_id))
});
out
}
#[derive(Debug, Clone, Serialize)]
pub(crate) struct DiskRoot {
pub path: PathBuf,
pub bytes: Option<u64>,
pub size: Option<SizeNote>,
pub projects: Vec<DiskProject>,
pub counts: TierCounts,
pub stale_bytes: u64,
pub stale_measured: usize,
}
#[derive(Debug, Clone, Serialize)]
pub(crate) struct KeepListReport {
pub patterns: Vec<String>,
pub invalid: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub error: Option<String>,
}
#[derive(Debug, Clone, Serialize)]
pub(crate) struct DiskSurvey {
pub generated_at: String,
pub partial: bool,
pub keep_list: KeepListReport,
pub root: DiskRoot,
#[serde(skip_serializing_if = "Option::is_none")]
pub by_session: Option<Vec<DiskSessionGroup>>,
}
#[cfg(test)]
#[path = "survey_tests.rs"]
mod survey_tests;