1mod current;
7mod loader;
8mod query;
9mod starred;
10mod waiting;
11
12#[cfg(test)]
13mod tests;
14
15pub use current::{Current, CurrentArtifact, CurrentEdge, CurrentEval, Plan, scan};
16pub use loader::{Input, Loader, Snapshot};
17pub use query::{DEFAULT, Query, Sort, complete, show};
18pub use starred::{star, starred};
19pub use waiting::{Waiting, waiting};
20
21use crate::{
22 repository::{self, Identity, Worktree},
23 store::CatalogRun,
24 types::{RunId, RunStatus, Timestamp},
25};
26use serde::{Deserialize, Serialize};
27use std::{
28 collections::{BTreeMap, BTreeSet},
29 path::{Path, PathBuf},
30};
31
32#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
36#[serde(try_from = "String", into = "String")]
37pub struct BranchName(String);
38
39impl BranchName {
40 pub fn as_str(&self) -> &str {
41 &self.0
42 }
43}
44
45impl TryFrom<String> for BranchName {
46 type Error = String;
47
48 fn try_from(name: String) -> Result<Self, String> {
49 let refused = name.starts_with('-')
52 || name.ends_with('.')
53 || name == "@"
54 || name.contains("..")
55 || name.contains("@{")
56 || name
57 .split('/')
58 .any(|part| part.is_empty() || part.starts_with('.') || part.ends_with(".lock"))
59 || name.chars().any(|c| {
60 c.is_control() || matches!(c, ' ' | '~' | '^' | ':' | '?' | '*' | '[' | '\\')
61 });
62 if refused {
63 Err(format!("not a branch name: {name:?}"))
64 } else {
65 Ok(Self(name))
66 }
67 }
68}
69
70impl From<BranchName> for String {
71 fn from(name: BranchName) -> Self {
72 name.0
73 }
74}
75
76impl std::fmt::Display for BranchName {
77 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
78 f.write_str(&self.0)
79 }
80}
81
82#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
84#[serde(rename_all = "camelCase")]
85pub enum Head {
86 Branch(BranchName),
87 Detached(#[serde(with = "crate::platform::path_serde")] PathBuf),
89}
90
91#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
94#[serde(rename_all = "camelCase")]
95pub enum Place {
96 Git {
97 #[serde(with = "crate::platform::path_serde")]
98 common: PathBuf,
99 head: Head,
100 },
101 Folder(#[serde(with = "crate::platform::path_serde")] PathBuf),
102}
103
104#[derive(Debug, Clone, PartialEq, Eq)]
106pub struct Chip {
107 pub id: RunId,
108 pub status: RunStatus,
109 pub created_at: Timestamp,
110 pub completed_at: Option<Timestamp>,
111}
112
113#[derive(Debug, Clone, PartialEq, Eq)]
115pub struct Branch {
116 pub place: Place,
117 pub project: String,
119 pub name: String,
121 pub workspace: PathBuf,
124 pub runs: Vec<Chip>,
126}
127
128impl Branch {
129 pub fn running(&self) -> Option<&Chip> {
131 self.runs.last().filter(|chip| chip.status.is_open())
132 }
133
134 pub fn latest(&self) -> Option<Timestamp> {
136 self.runs.last().map(|chip| chip.created_at)
137 }
138}
139
140#[derive(Debug, Clone, Default, PartialEq, Eq)]
143pub struct Label {
144 pub project: String,
145 pub branch: String,
146}
147
148pub type Labels = BTreeMap<RunId, Label>;
149
150#[derive(Default)]
153pub struct Discovery {
154 identities: BTreeMap<PathBuf, Identity>,
155 trees: BTreeMap<PathBuf, Vec<Worktree>>,
156}
157
158impl Discovery {
159 pub fn rescan(&mut self) {
161 self.trees.clear();
162 }
163
164 fn identify(&mut self, path: &Path) -> Identity {
165 self.identities
166 .entry(path.to_path_buf())
167 .or_insert_with(|| repository::identify(path))
168 .clone()
169 }
170
171 fn worktrees(&mut self, common: &Path) -> Vec<Worktree> {
172 self.trees
173 .entry(common.to_path_buf())
174 .or_insert_with(|| {
175 repository::worktrees(common)
176 .into_iter()
177 .map(|tree| Worktree {
178 path: crate::platform::canonicalize(&tree.path).unwrap_or(tree.path),
180 branch: tree.branch,
181 })
182 .collect()
183 })
184 .clone()
185 }
186}
187
188fn head(branch: Option<String>, tree: &Path) -> Head {
192 branch
193 .and_then(|name| BranchName::try_from(name).ok())
194 .map_or_else(|| Head::Detached(tree.to_path_buf()), Head::Branch)
195}
196
197fn place_of(identity: &Identity, repo: &Path) -> (Place, PathBuf) {
198 match (&identity.common_dir, &identity.worktree_path) {
199 (Some(common), Some(tree)) => {
200 let head = head(identity.branch.clone(), tree);
201 let relative = repo
202 .strip_prefix(tree)
203 .map(Path::to_path_buf)
204 .unwrap_or_default();
205 (
206 Place::Git {
207 common: common.clone(),
208 head,
209 },
210 relative,
211 )
212 }
213 _ => (Place::Folder(repo.to_path_buf()), PathBuf::new()),
214 }
215}
216
217fn folder_name(path: &Path) -> String {
218 path.file_name().map_or_else(
219 || crate::platform::path_text(path),
220 |name| name.to_string_lossy().into_owned(),
221 )
222}
223
224fn project_name(place: &Place) -> String {
227 match place {
228 Place::Git { common, .. } if common.file_name().is_some_and(|name| name == ".git") => {
229 folder_name(common.parent().unwrap_or(common))
230 }
231 Place::Git { common, .. } => {
232 let name = folder_name(common);
233 name.strip_suffix(".git")
234 .map_or(name.clone(), str::to_owned)
235 }
236 Place::Folder(path) => folder_name(path),
237 }
238}
239
240fn branch_name(place: &Place) -> String {
241 match place {
242 Place::Git {
243 head: Head::Branch(branch),
244 ..
245 } => branch.to_string(),
246 Place::Git {
247 head: Head::Detached(tree),
248 ..
249 } => folder_name(tree),
250 Place::Folder(_) => String::new(),
251 }
252}
253
254fn label(place: &Place) -> Label {
255 Label {
256 project: project_name(place),
257 branch: branch_name(place),
258 }
259}
260
261pub struct Listing {
263 pub branches: Vec<Branch>,
265 pub labels: Labels,
266 pub launch: Option<Place>,
268}
269
270type Grouped = (Vec<Chip>, Option<(Timestamp, PathBuf)>);
272
273pub fn branches(runs: &[CatalogRun], launch: Option<&Path>, discovery: &mut Discovery) -> Listing {
276 let mut grouped: BTreeMap<Place, Grouped> = BTreeMap::new();
277 let mut labels = Labels::new();
278 for run in runs {
279 let identity =
280 if run.repository.common_dir.is_some() && run.repository.worktree_path.is_some() {
281 run.repository.clone()
282 } else {
283 discovery.identify(&run.repo_path)
284 };
285 let (place, relative) = place_of(&identity, &run.repo_path);
286 labels.insert(run.id.clone(), label(&place));
287 let (chips, newest) = grouped.entry(place).or_default();
288 chips.push(Chip {
289 id: run.id.clone(),
290 status: run.status,
291 created_at: run.created_at,
292 completed_at: run.completed_at,
293 });
294 if newest.as_ref().is_none_or(|(at, _)| run.created_at >= *at) {
295 *newest = Some((run.created_at, relative));
296 }
297 }
298 let launched = launch.map(|path| {
299 let (place, _) = place_of(&discovery.identify(path), path);
300 (place, path.to_path_buf())
301 });
302 let commons: BTreeSet<PathBuf> = grouped
303 .keys()
304 .chain(launched.iter().map(|(place, _)| place))
305 .filter_map(|place| match place {
306 Place::Git { common, .. } => Some(common.clone()),
307 Place::Folder(_) => None,
308 })
309 .collect();
310 let mut live: Vec<(Place, PathBuf)> = commons
311 .into_iter()
312 .flat_map(|common| {
313 discovery.worktrees(&common).into_iter().map(move |tree| {
314 let head = head(tree.branch, &tree.path);
315 (
316 Place::Git {
317 common: common.clone(),
318 head,
319 },
320 tree.path,
321 )
322 })
323 })
324 .collect();
325 let folders: BTreeSet<&PathBuf> = grouped
326 .keys()
327 .chain(launched.iter().map(|(place, _)| place))
328 .filter_map(|place| match place {
329 Place::Folder(path) => Some(path),
330 Place::Git { .. } => None,
331 })
332 .collect();
333 let started = |path: &PathBuf| {
337 launched
338 .as_ref()
339 .is_some_and(|(place, _)| matches!(place, Place::Folder(folder) if folder == path))
340 };
341 live.extend(
342 folders
343 .into_iter()
344 .filter(|path| {
345 path.is_dir()
346 && (started(path)
347 || crate::workspace::has_artifact_marker(path).unwrap_or(false))
348 })
349 .map(|path| (Place::Folder(path.clone()), path.clone())),
350 );
351 let mut branches: Vec<Branch> = live
352 .into_iter()
353 .map(|(place, tree)| {
354 let (mut runs, newest) = grouped.remove(&place).unwrap_or_default();
355 runs.sort_by(|a, b| (a.created_at, &a.id).cmp(&(b.created_at, &b.id)));
356 let workspace = match &launched {
357 Some((launch, path)) if *launch == place => path.clone(),
358 _ => newest
359 .map(|(_, relative)| tree.join(relative))
360 .filter(|path| path.is_dir())
361 .unwrap_or(tree),
362 };
363 let Label { project, branch } = label(&place);
364 Branch {
365 place,
366 project,
367 name: branch,
368 workspace,
369 runs,
370 }
371 })
372 .collect();
373 query::order(&mut branches, Sort::Recent);
374 let launch = launched
375 .map(|(place, _)| place)
376 .filter(|place| branches.iter().any(|branch| &branch.place == place));
377 Listing {
378 branches,
379 labels,
380 launch,
381 }
382}