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, Opened, Snapshot};
17pub use query::{DEFAULT, Query, Sort, complete, show};
18pub use starred::{star, star_artifact, starred, starred_artifacts};
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
104impl Place {
105 pub fn project(&self) -> Project {
107 match self {
108 Self::Git { common, .. } => Project::Git(common.clone()),
109 Self::Folder(path) => Project::Folder(path.clone()),
110 }
111 }
112}
113
114#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
116#[serde(rename_all = "camelCase")]
117pub enum Project {
118 Git(#[serde(with = "crate::platform::path_serde")] PathBuf),
119 Folder(#[serde(with = "crate::platform::path_serde")] PathBuf),
120}
121
122#[derive(Debug, Clone, PartialEq, Eq)]
124pub struct Chip {
125 pub id: RunId,
126 pub status: RunStatus,
127 pub created_at: Timestamp,
128 pub completed_at: Option<Timestamp>,
129 pub comment: Option<crate::types::RunComment>,
131}
132
133#[derive(Debug, Clone, PartialEq, Eq)]
135pub struct Branch {
136 pub place: Place,
137 pub project: String,
139 pub name: String,
141 pub workspace: PathBuf,
144 pub runs: Vec<Chip>,
146}
147
148impl Branch {
149 pub fn running(&self) -> Option<&Chip> {
151 self.runs.last().filter(|chip| chip.status.is_open())
152 }
153
154 pub fn latest(&self) -> Option<Timestamp> {
156 self.runs.last().map(|chip| chip.created_at)
157 }
158}
159
160#[derive(Debug, Clone, Default, PartialEq, Eq)]
163pub struct Label {
164 pub project: String,
165 pub branch: String,
166}
167
168pub type Labels = BTreeMap<RunId, Label>;
169
170#[derive(Default)]
173pub struct Discovery {
174 identities: BTreeMap<PathBuf, Identity>,
175 trees: BTreeMap<PathBuf, Vec<Worktree>>,
176}
177
178impl Discovery {
179 pub fn rescan(&mut self) {
181 self.trees.clear();
182 }
183
184 fn identify(&mut self, path: &Path) -> Identity {
185 self.identities
186 .entry(path.to_path_buf())
187 .or_insert_with(|| repository::identify(path))
188 .clone()
189 }
190
191 fn worktrees(&mut self, common: &Path) -> Vec<Worktree> {
192 self.trees
193 .entry(common.to_path_buf())
194 .or_insert_with(|| {
195 repository::worktrees(common)
196 .into_iter()
197 .map(|tree| Worktree {
198 path: crate::platform::canonicalize(&tree.path).unwrap_or(tree.path),
200 branch: tree.branch,
201 })
202 .collect()
203 })
204 .clone()
205 }
206}
207
208fn head(branch: Option<String>, tree: &Path) -> Head {
212 branch
213 .and_then(|name| BranchName::try_from(name).ok())
214 .map_or_else(|| Head::Detached(tree.to_path_buf()), Head::Branch)
215}
216
217fn place_of(identity: &Identity, repo: &Path) -> (Place, PathBuf) {
218 match (&identity.common_dir, &identity.worktree_path) {
219 (Some(common), Some(tree)) => {
220 let head = head(identity.branch.clone(), tree);
221 let relative = repo
222 .strip_prefix(tree)
223 .map(Path::to_path_buf)
224 .unwrap_or_default();
225 (
226 Place::Git {
227 common: common.clone(),
228 head,
229 },
230 relative,
231 )
232 }
233 _ => (Place::Folder(repo.to_path_buf()), PathBuf::new()),
234 }
235}
236
237fn folder_name(path: &Path) -> String {
238 path.file_name().map_or_else(
239 || crate::platform::path_text(path),
240 |name| name.to_string_lossy().into_owned(),
241 )
242}
243
244fn project_name(place: &Place) -> String {
247 match place {
248 Place::Git { common, .. } if common.file_name().is_some_and(|name| name == ".git") => {
249 folder_name(common.parent().unwrap_or(common))
250 }
251 Place::Git { common, .. } => {
252 let name = folder_name(common);
253 name.strip_suffix(".git")
254 .map_or(name.clone(), str::to_owned)
255 }
256 Place::Folder(path) => folder_name(path),
257 }
258}
259
260fn branch_name(place: &Place) -> String {
261 match place {
262 Place::Git {
263 head: Head::Branch(branch),
264 ..
265 } => branch.to_string(),
266 Place::Git {
267 head: Head::Detached(tree),
268 ..
269 } => folder_name(tree),
270 Place::Folder(_) => String::new(),
271 }
272}
273
274fn label(place: &Place) -> Label {
275 Label {
276 project: project_name(place),
277 branch: branch_name(place),
278 }
279}
280
281pub struct Listing {
283 pub branches: Vec<Branch>,
285 pub labels: Labels,
286 pub launch: Option<Place>,
288}
289
290type Grouped = (Vec<Chip>, Option<(Timestamp, PathBuf)>);
292
293pub fn branches(runs: &[CatalogRun], launch: Option<&Path>, discovery: &mut Discovery) -> Listing {
296 let mut grouped: BTreeMap<Place, Grouped> = BTreeMap::new();
297 let mut labels = Labels::new();
298 for run in runs {
299 let identity =
300 if run.repository.common_dir.is_some() && run.repository.worktree_path.is_some() {
301 run.repository.clone()
302 } else {
303 discovery.identify(&run.repo_path)
304 };
305 let (place, relative) = place_of(&identity, &run.repo_path);
306 labels.insert(run.id.clone(), label(&place));
307 let (chips, newest) = grouped.entry(place).or_default();
308 chips.push(Chip {
309 id: run.id.clone(),
310 status: run.status,
311 created_at: run.created_at,
312 completed_at: run.completed_at,
313 comment: run.comment.clone(),
314 });
315 if newest.as_ref().is_none_or(|(at, _)| run.created_at >= *at) {
316 *newest = Some((run.created_at, relative));
317 }
318 }
319 let launched = launch.map(|path| {
320 let (place, _) = place_of(&discovery.identify(path), path);
321 (place, path.to_path_buf())
322 });
323 let commons: BTreeSet<PathBuf> = grouped
324 .keys()
325 .chain(launched.iter().map(|(place, _)| place))
326 .filter_map(|place| match place {
327 Place::Git { common, .. } => Some(common.clone()),
328 Place::Folder(_) => None,
329 })
330 .collect();
331 let mut live: Vec<(Place, PathBuf)> = commons
332 .into_iter()
333 .flat_map(|common| {
334 discovery.worktrees(&common).into_iter().map(move |tree| {
335 let head = head(tree.branch, &tree.path);
336 (
337 Place::Git {
338 common: common.clone(),
339 head,
340 },
341 tree.path,
342 )
343 })
344 })
345 .collect();
346 let folders: BTreeSet<&PathBuf> = grouped
347 .keys()
348 .chain(launched.iter().map(|(place, _)| place))
349 .filter_map(|place| match place {
350 Place::Folder(path) => Some(path),
351 Place::Git { .. } => None,
352 })
353 .collect();
354 let started = |path: &PathBuf| {
358 launched
359 .as_ref()
360 .is_some_and(|(place, _)| matches!(place, Place::Folder(folder) if folder == path))
361 };
362 live.extend(
363 folders
364 .into_iter()
365 .filter(|path| {
366 path.is_dir()
367 && (started(path)
368 || crate::workspace::has_artifact_marker(path).unwrap_or(false))
369 })
370 .map(|path| (Place::Folder(path.clone()), path.clone())),
371 );
372 let mut branches: Vec<Branch> = live
373 .into_iter()
374 .map(|(place, tree)| {
375 let (mut runs, newest) = grouped.remove(&place).unwrap_or_default();
376 runs.sort_by(|a, b| (a.created_at, &a.id).cmp(&(b.created_at, &b.id)));
377 let workspace = match &launched {
378 Some((launch, path)) if *launch == place => path.clone(),
379 _ => newest
380 .map(|(_, relative)| tree.join(relative))
381 .filter(|path| path.is_dir())
382 .unwrap_or(tree),
383 };
384 let Label { project, branch } = label(&place);
385 Branch {
386 place,
387 project,
388 name: branch,
389 workspace,
390 runs,
391 }
392 })
393 .collect();
394 query::order(&mut branches, Sort::Recent);
395 let launch = launched
396 .map(|(place, _)| place)
397 .filter(|place| branches.iter().any(|branch| &branch.place == place));
398 Listing {
399 branches,
400 labels,
401 launch,
402 }
403}