use std::collections::{BTreeMap, BTreeSet};
use std::path::{Path, PathBuf};
use chrono::{DateTime, Utc};
use serde::Serialize;
use super::record::{ManagedSessionId, SessionRecord};
use super::worktree_ownership::{OWNERLESS_GRACE, SentinelOwner, read_sentinel_owner};
use super::worktree_registry::{Admission, ScannedWorktree, scan_registered_worktrees};
use super::worktree_safety::{git_command, inspect_dirt};
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize)]
pub struct WorktreeKey {
pub common_dir: PathBuf,
pub admin_dir: Option<String>,
}
pub fn worktree_key(candidate: &Path) -> Option<WorktreeKey> {
let canonical = std::fs::canonicalize(candidate).ok()?;
let toplevel = git_rev_parse_path(&canonical, "--show-toplevel")?;
if std::fs::canonicalize(&toplevel).ok()? != canonical {
return None;
}
let common_dir = git_rev_parse_path(&canonical, "--git-common-dir")?;
Some(WorktreeKey {
common_dir: std::fs::canonicalize(&common_dir).ok()?,
admin_dir: admin_dir_name(&canonical),
})
}
fn git_rev_parse_path(dir: &Path, flag: &str) -> Option<PathBuf> {
let out = git_command(dir, &["rev-parse", "--path-format=absolute", flag])
.output()
.ok()?;
if !out.status.success() {
return None;
}
let raw = String::from_utf8_lossy(&out.stdout);
let trimmed = raw.trim();
if trimmed.is_empty() {
None
} else {
Some(PathBuf::from(trimmed))
}
}
fn admin_dir_name(worktree: &Path) -> Option<String> {
let content = std::fs::read_to_string(worktree.join(".git")).ok()?;
let gitdir = content
.lines()
.find_map(|l| l.trim().strip_prefix("gitdir:"))?
.trim();
Path::new(gitdir)
.file_name()
.map(|n| n.to_string_lossy().into_owned())
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum ReconcileState {
Live,
Orphaned,
Unknown,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum WorktreeCategory {
SessionOwned,
HarnessAgent,
TmpScratchpad,
ProjectCheckout,
OperatorCheckout,
Unclassified,
}
#[derive(Debug, Clone, Serialize)]
pub struct ReconciledWorktree {
pub path: PathBuf,
pub key: Option<WorktreeKey>,
pub state: ReconcileState,
pub reason: String,
pub category: WorktreeCategory,
pub admission: Option<Admission>,
pub registry_root: Option<PathBuf>,
pub contested: bool,
pub sessions: Vec<ManagedSessionId>,
pub sentinel_owner: Option<ManagedSessionId>,
pub branch: Option<String>,
pub dirty: Option<String>,
pub contains_other_entry: bool,
}
#[derive(Debug, Clone, Serialize)]
pub struct ProposedAdoption {
pub path: PathBuf,
pub inferred_owner: ManagedSessionId,
pub evidence: String,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize)]
pub struct ReconcileCounts {
pub total: usize,
pub live: usize,
pub orphaned: usize,
pub unknown: usize,
pub admitted: usize,
pub excluded: usize,
pub record_only: usize,
}
#[derive(Debug, Clone, Serialize)]
pub struct ReconcileReport {
pub entries: Vec<ReconciledWorktree>,
pub proposed_adoptions: Vec<ProposedAdoption>,
pub counts: ReconcileCounts,
}
pub fn reconcile_worktrees(
repos_root: &Path,
records: &[SessionRecord],
live_cwds: &[PathBuf],
now: DateTime<Utc>,
) -> ReconcileReport {
let mut scanned: BTreeMap<PathBuf, Vec<ScannedWorktree>> = BTreeMap::new();
for s in scan_registered_worktrees(repos_root) {
scanned.entry(s.path.clone()).or_default().push(s);
}
let mut by_workspace: BTreeMap<PathBuf, Vec<&SessionRecord>> = BTreeMap::new();
for r in records {
if let Some(ws) = r.workspace_path.as_deref() {
let key = std::fs::canonicalize(ws).unwrap_or_else(|_| ws.to_path_buf());
by_workspace.entry(key).or_default().push(r);
}
}
let universe: BTreeSet<PathBuf> = scanned.keys().chain(by_workspace.keys()).cloned().collect();
let mut entries: Vec<ReconciledWorktree> = Vec::new();
for path in &universe {
let claims = scanned.get(path).map_or(&[][..], Vec::as_slice);
let owners = by_workspace.get(path).map_or(&[][..], Vec::as_slice);
if claims.is_empty() {
entries.push(classify(path, None, owners, records, live_cwds, now, false));
continue;
}
let contested = claims.len() > 1;
for claim in claims {
entries.push(classify(
path,
Some(claim),
owners,
records,
live_cwds,
now,
contested,
));
}
}
entries.sort_by(|a, b| {
b.path
.components()
.count()
.cmp(&a.path.components().count())
.then_with(|| a.path.cmp(&b.path))
});
for entry in &mut entries {
entry.contains_other_entry = universe
.iter()
.any(|other| other != &entry.path && other.starts_with(&entry.path));
}
let proposed_adoptions = propose_adoptions(&entries, records);
let counts = count(&entries);
ReconcileReport {
entries,
proposed_adoptions,
counts,
}
}
fn count(entries: &[ReconciledWorktree]) -> ReconcileCounts {
let mut c = ReconcileCounts {
total: entries.len(),
..ReconcileCounts::default()
};
for e in entries {
match e.state {
ReconcileState::Live => c.live += 1,
ReconcileState::Orphaned => c.orphaned += 1,
ReconcileState::Unknown => c.unknown += 1,
}
match e.admission {
Some(Admission::Admitted) => c.admitted += 1,
Some(_) => c.excluded += 1,
None => c.record_only += 1,
}
}
c
}
fn classify(
path: &Path,
scanned: Option<&ScannedWorktree>,
owners: &[&SessionRecord],
records: &[SessionRecord],
live_cwds: &[PathBuf],
now: DateTime<Utc>,
contested: bool,
) -> ReconciledWorktree {
let key = worktree_key(path);
let sentinel = read_sentinel_owner(path);
let sentinel_owner = match sentinel {
SentinelOwner::Known(owner, _) => Some(owner),
SentinelOwner::Unknown => None,
};
let mut entry = ReconciledWorktree {
path: path.to_path_buf(),
key: key.clone(),
state: ReconcileState::Unknown,
reason: String::new(),
category: categorize(
path,
scanned,
!owners.is_empty() || sentinel_owner.is_some(),
),
admission: scanned.map(|s| s.admission),
registry_root: scanned.map(|s| s.registry_root.clone()),
contested,
sessions: owners.iter().map(|r| r.id).collect(),
sentinel_owner,
branch: scanned.and_then(|s| s.branch.clone()),
dirty: None,
contains_other_entry: false,
};
if key.is_none() {
entry.reason = match entry.admission {
Some(a @ (Admission::Bare | Admission::Prunable | Admission::Unresolvable)) => {
format!(
"{} — no working tree to key on, which is git answering correctly rather \
than a registry to repair",
a.reason()
)
}
Some(_) => "no git-derived key: `rev-parse --show-toplevel` disagrees this path is \
its own worktree root, or git could not answer — repair the registry \
(`git worktree repair`) before trusting any verdict here"
.to_string(),
None => "no git-derived key AND no git registry entry: this path is not a worktree \
(it may be an ordinary directory inside a live checkout) — never treat it \
as an orphan"
.to_string(),
};
return entry;
}
if contested {
entry.reason = "contested: more than one git registry lists this path; the git-derived \
key names only ONE of them, so at most one claim is right and nothing \
here can say which — resolve the duplicate registration (this is the \
hand-copied-admin-directory shape) before acting on any row for this path"
.to_string();
return entry;
}
if let Some(r) = owners.first() {
entry.reason = format!(
"live: session {} records this as its workspace_path (recorded state {:?}, which is \
bookkeeping and NOT a liveness signal — the veto holds in every state)",
r.id, r.state
);
entry.state = ReconcileState::Live;
return entry;
}
if let Some(cwd) = live_cwds.iter().find(|c| c.starts_with(path)) {
entry.reason = format!(
"live: an observed working directory sits at or under it ({})",
cwd.display()
);
entry.state = ReconcileState::Live;
return entry;
}
if let SentinelOwner::Known(owner, created_at) = sentinel
&& !provably_ownerless(owner, created_at, records, now)
{
entry.reason = format!(
"live: ownership sentinel names {owner}, which is neither terminal nor aged out"
);
entry.state = ReconcileState::Live;
return entry;
}
if entry.admission != Some(Admission::Admitted) {
entry.reason = scanned.map_or_else(
|| "unknown: a session record points here but no git registry lists it".to_string(),
|s| format!("unknown: {}", s.admission.reason()),
);
return entry;
}
let SentinelOwner::Known(owner, _) = sentinel else {
entry.reason = "unknown: no ownership sentinel (absent, empty, or unparsable) — never \
auto-reclaimable, this is the 82-of-85 case"
.to_string();
return entry;
};
if let Some(dirt) = inspect_dirt(path) {
entry.reason = format!("unknown: holds unsaved work — {}", dirt.reason);
entry.dirty = Some(dirt.reason);
return entry;
}
entry.reason = format!(
"orphaned: sentinel owner {owner} is provably ownerless, git admitted the path, and the \
tree is clean"
);
entry.state = ReconcileState::Orphaned;
entry
}
fn provably_ownerless(
owner: ManagedSessionId,
sentinel_created_at: DateTime<Utc>,
records: &[SessionRecord],
now: DateTime<Utc>,
) -> bool {
match records.iter().find(|r| r.id == owner) {
Some(record) => record.state.is_terminal(),
None => now - sentinel_created_at > OWNERLESS_GRACE,
}
}
fn categorize(path: &Path, scanned: Option<&ScannedWorktree>, has_owner: bool) -> WorktreeCategory {
if has_owner {
return WorktreeCategory::SessionOwned;
}
let admission = scanned.map(|s| s.admission);
if admission == Some(Admission::MainCheckout) {
return WorktreeCategory::ProjectCheckout;
}
let is_agent_dir = path
.components()
.zip(path.components().skip(1))
.any(|(a, b)| a.as_os_str() == ".claude" && b.as_os_str() == "worktrees");
if is_agent_dir {
return WorktreeCategory::HarnessAgent;
}
let inside_project = matches!(
admission,
Some(
Admission::Admitted
| Admission::Bare
| Admission::Prunable
| Admission::Locked
| Admission::Unresolvable
)
);
let text = path.to_string_lossy();
let temp_rooted = text.starts_with("/tmp/")
|| text.starts_with("/private/tmp/")
|| text.starts_with("/var/folders/")
|| text.starts_with("/private/var/folders/");
if !inside_project && temp_rooted {
return WorktreeCategory::TmpScratchpad;
}
match admission {
Some(Admission::OutsideProject | Admission::OutsideReposRoot) => {
WorktreeCategory::OperatorCheckout
}
_ => WorktreeCategory::Unclassified,
}
}
fn propose_adoptions(
entries: &[ReconciledWorktree],
records: &[SessionRecord],
) -> Vec<ProposedAdoption> {
let mut out = Vec::new();
for e in entries {
if e.admission != Some(Admission::Admitted)
|| e.key.is_none()
|| e.sentinel_owner.is_some()
|| e.contains_other_entry
|| e.contested
{
continue;
}
if let Some(id) = e.sessions.first() {
out.push(ProposedAdoption {
path: e.path.clone(),
inferred_owner: *id,
evidence: format!(
"sessions.json: session {id} records this exact path as its workspace_path"
),
});
continue;
}
if let Some(branch) = e.branch.as_deref()
&& let Some(r) = records
.iter()
.find(|r| r.branch.as_deref() == Some(branch) && r.workspace_path.is_none())
{
out.push(ProposedAdoption {
path: e.path.clone(),
inferred_owner: r.id,
evidence: format!(
"branch match: session {} records branch `{branch}` and has no workspace_path \
of its own",
r.id
),
});
}
}
out
}
impl super::manager::SessionManager {
pub(crate) async fn reconcile_worktree_inventory(
&self,
repos_root: &Path,
) -> Result<ReconcileReport, anyhow::Error> {
let records = self.list().await;
let mut live_cwds: Vec<PathBuf> = Vec::new();
for r in &records {
if self.tmux.session_exists(&r.tmux_name)
&& let Some(cwd) = self.tmux.get_pane_cwd(&r.tmux_name)
{
live_cwds.push(std::fs::canonicalize(&cwd).unwrap_or(cwd));
}
}
let repos_root = repos_root.to_path_buf();
tokio::task::spawn_blocking(move || {
reconcile_worktrees(&repos_root, &records, &live_cwds, Utc::now())
})
.await
.map_err(|e| anyhow::anyhow!("reconcile-worktrees: inventory scan panicked: {e}"))
}
}
#[cfg(test)]
#[path = "worktree_reconcile_tests.rs"]
mod worktree_reconcile_tests;