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
132 .last()
133 .filter(|chip| chip.status == RunStatus::Running)
134 }
135
136 pub fn latest(&self) -> Option<Timestamp> {
138 self.runs.last().map(|chip| chip.created_at)
139 }
140}
141
142#[derive(Debug, Clone, Default, PartialEq, Eq)]
145pub struct Label {
146 pub project: String,
147 pub branch: String,
148}
149
150pub type Labels = BTreeMap<RunId, Label>;
151
152#[derive(Default)]
155pub struct Discovery {
156 identities: BTreeMap<PathBuf, Identity>,
157 trees: BTreeMap<PathBuf, Vec<Worktree>>,
158}
159
160impl Discovery {
161 pub fn rescan(&mut self) {
163 self.trees.clear();
164 }
165
166 fn identify(&mut self, path: &Path) -> Identity {
167 self.identities
168 .entry(path.to_path_buf())
169 .or_insert_with(|| repository::identify(path))
170 .clone()
171 }
172
173 fn worktrees(&mut self, common: &Path) -> Vec<Worktree> {
174 self.trees
175 .entry(common.to_path_buf())
176 .or_insert_with(|| {
177 repository::worktrees(common)
178 .into_iter()
179 .map(|tree| Worktree {
180 path: crate::platform::canonicalize(&tree.path).unwrap_or(tree.path),
182 branch: tree.branch,
183 })
184 .collect()
185 })
186 .clone()
187 }
188}
189
190fn head(branch: Option<String>, tree: &Path) -> Head {
194 branch
195 .and_then(|name| BranchName::try_from(name).ok())
196 .map_or_else(|| Head::Detached(tree.to_path_buf()), Head::Branch)
197}
198
199fn place_of(identity: &Identity, repo: &Path) -> (Place, PathBuf) {
200 match (&identity.common_dir, &identity.worktree_path) {
201 (Some(common), Some(tree)) => {
202 let head = head(identity.branch.clone(), tree);
203 let relative = repo
204 .strip_prefix(tree)
205 .map(Path::to_path_buf)
206 .unwrap_or_default();
207 (
208 Place::Git {
209 common: common.clone(),
210 head,
211 },
212 relative,
213 )
214 }
215 _ => (Place::Folder(repo.to_path_buf()), PathBuf::new()),
216 }
217}
218
219fn folder_name(path: &Path) -> String {
220 path.file_name().map_or_else(
221 || crate::platform::path_text(path),
222 |name| name.to_string_lossy().into_owned(),
223 )
224}
225
226fn project_name(place: &Place) -> String {
229 match place {
230 Place::Git { common, .. } if common.file_name().is_some_and(|name| name == ".git") => {
231 folder_name(common.parent().unwrap_or(common))
232 }
233 Place::Git { common, .. } => {
234 let name = folder_name(common);
235 name.strip_suffix(".git")
236 .map_or(name.clone(), str::to_owned)
237 }
238 Place::Folder(path) => folder_name(path),
239 }
240}
241
242fn branch_name(place: &Place) -> String {
243 match place {
244 Place::Git {
245 head: Head::Branch(branch),
246 ..
247 } => branch.to_string(),
248 Place::Git {
249 head: Head::Detached(tree),
250 ..
251 } => folder_name(tree),
252 Place::Folder(_) => String::new(),
253 }
254}
255
256fn label(place: &Place) -> Label {
257 Label {
258 project: project_name(place),
259 branch: branch_name(place),
260 }
261}
262
263pub struct Listing {
265 pub branches: Vec<Branch>,
267 pub labels: Labels,
268 pub launch: Option<Place>,
270}
271
272type Grouped = (Vec<Chip>, Option<(Timestamp, PathBuf)>);
274
275pub fn branches(runs: &[CatalogRun], launch: Option<&Path>, discovery: &mut Discovery) -> Listing {
278 let mut grouped: BTreeMap<Place, Grouped> = BTreeMap::new();
279 let mut labels = Labels::new();
280 for run in runs {
281 let identity =
282 if run.repository.common_dir.is_some() && run.repository.worktree_path.is_some() {
283 run.repository.clone()
284 } else {
285 discovery.identify(&run.repo_path)
286 };
287 let (place, relative) = place_of(&identity, &run.repo_path);
288 labels.insert(run.id.clone(), label(&place));
289 let (chips, newest) = grouped.entry(place).or_default();
290 chips.push(Chip {
291 id: run.id.clone(),
292 status: run.status,
293 created_at: run.created_at,
294 completed_at: run.completed_at,
295 });
296 if newest.as_ref().is_none_or(|(at, _)| run.created_at >= *at) {
297 *newest = Some((run.created_at, relative));
298 }
299 }
300 let launched = launch.map(|path| {
301 let (place, _) = place_of(&discovery.identify(path), path);
302 (place, path.to_path_buf())
303 });
304 let commons: BTreeSet<PathBuf> = grouped
305 .keys()
306 .chain(launched.iter().map(|(place, _)| place))
307 .filter_map(|place| match place {
308 Place::Git { common, .. } => Some(common.clone()),
309 Place::Folder(_) => None,
310 })
311 .collect();
312 let mut live: Vec<(Place, PathBuf)> = commons
313 .into_iter()
314 .flat_map(|common| {
315 discovery.worktrees(&common).into_iter().map(move |tree| {
316 let head = head(tree.branch, &tree.path);
317 (
318 Place::Git {
319 common: common.clone(),
320 head,
321 },
322 tree.path,
323 )
324 })
325 })
326 .collect();
327 let folders: BTreeSet<&PathBuf> = grouped
328 .keys()
329 .chain(launched.iter().map(|(place, _)| place))
330 .filter_map(|place| match place {
331 Place::Folder(path) => Some(path),
332 Place::Git { .. } => None,
333 })
334 .collect();
335 let started = |path: &PathBuf| {
339 launched
340 .as_ref()
341 .is_some_and(|(place, _)| matches!(place, Place::Folder(folder) if folder == path))
342 };
343 live.extend(
344 folders
345 .into_iter()
346 .filter(|path| {
347 path.is_dir()
348 && (started(path)
349 || crate::workspace::has_artifact_marker(path).unwrap_or(false))
350 })
351 .map(|path| (Place::Folder(path.clone()), path.clone())),
352 );
353 let mut branches: Vec<Branch> = live
354 .into_iter()
355 .map(|(place, tree)| {
356 let (mut runs, newest) = grouped.remove(&place).unwrap_or_default();
357 runs.sort_by(|a, b| (a.created_at, &a.id).cmp(&(b.created_at, &b.id)));
358 let workspace = match &launched {
359 Some((launch, path)) if *launch == place => path.clone(),
360 _ => newest
361 .map(|(_, relative)| tree.join(relative))
362 .filter(|path| path.is_dir())
363 .unwrap_or(tree),
364 };
365 let Label { project, branch } = label(&place);
366 Branch {
367 place,
368 project,
369 name: branch,
370 workspace,
371 runs,
372 }
373 })
374 .collect();
375 query::order(&mut branches, Sort::Recent);
376 let launch = launched
377 .map(|(place, _)| place)
378 .filter(|place| branches.iter().any(|branch| &branch.place == place));
379 Listing {
380 branches,
381 labels,
382 launch,
383 }
384}