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 the per-worktree git dir 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 the acquired markers directory
62    /// - `prefix`: directory name prefix to filter (e.g., "web-2-pool-")
63    pub fn scan(worktrees_dir: &Path, acquired_dir: &Path, prefix: &str) -> Result<Self> {
64        let mut entries = Vec::new();
65
66        if !worktrees_dir.exists() {
67            return Ok(Self { entries });
68        }
69
70        let mut dirs: Vec<_> = fs::read_dir(worktrees_dir)?
71            .filter_map(|e| e.ok())
72            .filter(|e| {
73                e.file_type().map(|t| t.is_dir()).unwrap_or(false)
74                    && e.file_name()
75                        .to_str()
76                        .is_some_and(|n| n.starts_with(prefix))
77            })
78            .collect();
79
80        // Sort by name for deterministic ordering
81        dirs.sort_by_key(|e| e.file_name());
82
83        for dir_entry in dirs {
84            let name = dir_entry.file_name().to_string_lossy().to_string();
85            let path = dir_entry.path();
86            let branch = name.clone();
87            let marker = acquired_dir.join(&name);
88
89            let (status, owner) = if marker.exists() {
90                let owner = fs::read_to_string(&marker)
91                    .ok()
92                    .map(|s| s.trim().to_string())
93                    .filter(|s| !s.is_empty());
94                (WorktreeStatus::Acquired, owner)
95            } else {
96                (WorktreeStatus::Available, None)
97            };
98
99            entries.push(PoolEntry {
100                name,
101                path,
102                branch,
103                status,
104                owner,
105            });
106        }
107
108        Ok(Self { entries })
109    }
110
111    /// Count worktrees with a given status
112    pub fn count_by_status(&self, status: &WorktreeStatus) -> usize {
113        self.entries.iter().filter(|e| e.status == *status).count()
114    }
115
116    /// Find the first available worktree
117    pub fn find_available(&self) -> Option<&PoolEntry> {
118        self.entries
119            .iter()
120            .find(|e| e.status == WorktreeStatus::Available)
121    }
122
123    /// Find a worktree by name or path
124    pub fn find_by_name_or_path(&self, identifier: &str) -> Option<&PoolEntry> {
125        self.entries
126            .iter()
127            .find(|e| e.name == identifier || e.path.to_string_lossy() == identifier)
128    }
129
130    /// Find the next pool name based on existing entries.
131    /// Returns `{prefix}NNN` (e.g., "web-2-pool-003").
132    pub fn next_name(&self, prefix: &str) -> String {
133        let max = self
134            .entries
135            .iter()
136            .filter_map(|e| e.name.strip_prefix(prefix))
137            .filter_map(|n| n.parse::<u32>().ok())
138            .max()
139            .unwrap_or(0);
140        format!("{prefix}{:03}", max + 1)
141    }
142
143    /// Determine the recommended next action
144    pub fn next_action(&self) -> PoolNextAction {
145        if self.entries.is_empty() {
146            return PoolNextAction::WarmPool;
147        }
148
149        let available = self.count_by_status(&WorktreeStatus::Available);
150        let acquired = self.count_by_status(&WorktreeStatus::Acquired);
151
152        if available == 0 {
153            return PoolNextAction::Exhausted { acquired };
154        }
155
156        if acquired == 0 {
157            return PoolNextAction::AllIdle { available };
158        }
159
160        PoolNextAction::Ready { available }
161    }
162}