Skip to main content

git_workflow/pool/
detect.rs

1//! Filesystem-derived pool state detection
2//!
3//! Instead of maintaining an inventory.json file, pool state is derived
4//! from the filesystem: directory existence determines pool membership,
5//! and marker files in `.git/worktree-pool/acquired/` determine status.
6
7use std::fs;
8use std::path::{Path, PathBuf};
9
10use crate::error::Result;
11
12/// Status of a worktree in the pool, derived from filesystem state
13#[derive(Debug, Clone, PartialEq, Eq)]
14pub enum WorktreeStatus {
15    Available,
16    Acquired,
17}
18
19impl std::fmt::Display for WorktreeStatus {
20    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
21        match self {
22            WorktreeStatus::Available => write!(f, "available"),
23            WorktreeStatus::Acquired => write!(f, "acquired"),
24        }
25    }
26}
27
28/// A single worktree entry derived from filesystem scan
29#[derive(Debug, Clone)]
30pub struct PoolEntry {
31    pub name: String,
32    pub path: PathBuf,
33    pub branch: String,
34    pub status: WorktreeStatus,
35    pub owner: Option<String>,
36}
37
38/// Recommended next action for pool management
39#[derive(Debug, Clone, PartialEq, Eq)]
40pub enum PoolNextAction {
41    /// No pool exists yet
42    WarmPool,
43    /// Worktrees available, some acquired
44    Ready { available: usize },
45    /// All worktrees in use
46    Exhausted { acquired: usize },
47    /// All worktrees available, no work in progress
48    AllIdle { available: usize },
49}
50
51/// Snapshot of the pool state, derived from the filesystem
52#[derive(Debug)]
53pub struct PoolState {
54    pub entries: Vec<PoolEntry>,
55}
56
57impl PoolState {
58    /// Scan the filesystem to build pool state.
59    ///
60    /// - `worktrees_dir`: path to `.worktrees/`
61    /// - `acquired_dir`: path to `.git/worktree-pool/acquired/`
62    pub fn scan(worktrees_dir: &Path, acquired_dir: &Path) -> Result<Self> {
63        let mut entries = Vec::new();
64
65        if !worktrees_dir.exists() {
66            return Ok(Self { entries });
67        }
68
69        let mut dirs: Vec<_> = fs::read_dir(worktrees_dir)?
70            .filter_map(|e| e.ok())
71            .filter(|e| {
72                e.file_type().map(|t| t.is_dir()).unwrap_or(false)
73                    && e.file_name()
74                        .to_str()
75                        .is_some_and(|n| n.starts_with("pool-"))
76            })
77            .collect();
78
79        // Sort by name for deterministic ordering
80        dirs.sort_by_key(|e| e.file_name());
81
82        for dir_entry in dirs {
83            let name = dir_entry.file_name().to_string_lossy().to_string();
84            let path = dir_entry.path();
85            let branch = name.clone();
86            let marker = acquired_dir.join(&name);
87
88            let (status, owner) = if marker.exists() {
89                let owner = fs::read_to_string(&marker)
90                    .ok()
91                    .map(|s| s.trim().to_string())
92                    .filter(|s| !s.is_empty());
93                (WorktreeStatus::Acquired, owner)
94            } else {
95                (WorktreeStatus::Available, None)
96            };
97
98            entries.push(PoolEntry {
99                name,
100                path,
101                branch,
102                status,
103                owner,
104            });
105        }
106
107        Ok(Self { entries })
108    }
109
110    /// Count worktrees with a given status
111    pub fn count_by_status(&self, status: &WorktreeStatus) -> usize {
112        self.entries.iter().filter(|e| e.status == *status).count()
113    }
114
115    /// Find the first available worktree
116    pub fn find_available(&self) -> Option<&PoolEntry> {
117        self.entries
118            .iter()
119            .find(|e| e.status == WorktreeStatus::Available)
120    }
121
122    /// Find a worktree by name or path
123    pub fn find_by_name_or_path(&self, identifier: &str) -> Option<&PoolEntry> {
124        self.entries
125            .iter()
126            .find(|e| e.name == identifier || e.path.to_string_lossy() == identifier)
127    }
128
129    /// Find the next pool name (pool-NNN) based on existing entries
130    pub fn next_name(&self) -> String {
131        let max = self
132            .entries
133            .iter()
134            .filter_map(|e| e.name.strip_prefix("pool-"))
135            .filter_map(|n| n.parse::<u32>().ok())
136            .max()
137            .unwrap_or(0);
138        format!("pool-{:03}", max + 1)
139    }
140
141    /// Determine the recommended next action
142    pub fn next_action(&self) -> PoolNextAction {
143        if self.entries.is_empty() {
144            return PoolNextAction::WarmPool;
145        }
146
147        let available = self.count_by_status(&WorktreeStatus::Available);
148        let acquired = self.count_by_status(&WorktreeStatus::Acquired);
149
150        if available == 0 {
151            return PoolNextAction::Exhausted { acquired };
152        }
153
154        if acquired == 0 {
155            return PoolNextAction::AllIdle { available };
156        }
157
158        PoolNextAction::Ready { available }
159    }
160}