Skip to main content

artifactize_core/monitor/
live.rs

1//! The live branches of every project with Runs on this machine, as the desktop's pane lists
2//! them: each Git worktree that exists now with the branch it has checked out (a detached one by
3//! its folder), and each folder outside Git that exists now and holds declarations. A branch's
4//! Runs are its project's Runs on that branch, from whichever folder they ran in; who ran them
5//! makes no branch of its own. The terminal monitor keeps its own catalog of scopes.
6mod 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/// A branch's name as Git accepts one: not empty, and none of the characters or sequences
33/// `git check-ref-format --branch` refuses. Made at the Git and serde boundaries, so no other
34/// name becomes a branch's key.
35#[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        // Each `/`-separated part is a name of its own: not empty, not starting with `.`, not
50        // ending with `.lock`.
51        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/// How a Git worktree has its project checked out.
83#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
84#[serde(rename_all = "camelCase")]
85pub enum Head {
86    Branch(BranchName),
87    /// A detached worktree, known by its folder.
88    Detached(#[serde(with = "crate::platform::path_serde")] PathBuf),
89}
90
91/// Where a branch lives: a Git repository's checked-out branch, by the repository's common
92/// directory, or a folder outside Git.
93#[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    /// The project the branch belongs to: every branch of a Git repository shares one.
106    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/// A project: a Git repository, by its common directory, or a folder outside Git.
115#[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/// One Run of a branch as its chip shows it.
123#[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    /// What the requester said it is working on (`verify --comment`).
130    pub comment: Option<crate::types::RunComment>,
131}
132
133/// A live branch and its Runs.
134#[derive(Debug, Clone, PartialEq, Eq)]
135pub struct Branch {
136    pub place: Place,
137    /// The project's name: its repository's or folder's.
138    pub project: String,
139    /// The branch, a detached worktree's folder name, or empty for a folder outside Git.
140    pub name: String,
141    /// Where its Current is scanned and its Artifacts' tools run: the worktree (or folder) as it
142    /// is now, at the workspace its newest Run used (or the app was started for) within it.
143    pub workspace: PathBuf,
144    /// Its Runs, oldest first.
145    pub runs: Vec<Chip>,
146}
147
148impl Branch {
149    /// The Run in progress a branch's timeline starts with: the newest Run, while it runs.
150    pub fn running(&self) -> Option<&Chip> {
151        self.runs.last().filter(|chip| chip.status.is_open())
152    }
153
154    /// When its newest Run started.
155    pub fn latest(&self) -> Option<Timestamp> {
156        self.runs.last().map(|chip| chip.created_at)
157    }
158}
159
160/// Which project and branch a saved Run ran on, by name, for every Run whether or not its
161/// branch still lives: what a waiting review of it is listed with.
162#[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/// Cached Git facts. Identities of old Run records without a saved identity are asked of Git
171/// once; which worktrees exist is asked again on every [`Discovery::rescan`].
172#[derive(Default)]
173pub struct Discovery {
174    identities: BTreeMap<PathBuf, Identity>,
175    trees: BTreeMap<PathBuf, Vec<Worktree>>,
176}
177
178impl Discovery {
179    /// Ask Git again which worktrees exist, on the next listing.
180    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                        // Saved identities are canonical; a detached head is matched by path.
199                        path: crate::platform::canonicalize(&tree.path).unwrap_or(tree.path),
200                        branch: tree.branch,
201                    })
202                    .collect()
203            })
204            .clone()
205    }
206}
207
208/// Where `identity` (of the workspace `repo`) lives, and `repo` relative to its worktree.
209/// The head of the worktree at `tree` with `branch` checked out, or detached without one. A
210/// name Git reports that is no branch name is known by the worktree's folder instead.
211fn 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
244/// The project's name: a repository's main folder (the parent of its `.git`), a bare
245/// repository's own folder without `.git`, or the folder outside Git.
246fn 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
281/// What the listing of the branches found.
282pub struct Listing {
283    /// The live branches, the most recent Run first.
284    pub branches: Vec<Branch>,
285    pub labels: Labels,
286    /// The branch of the folder the app was started for, when it lives.
287    pub launch: Option<Place>,
288}
289
290/// A branch's Runs so far, and when its newest started with its workspace in the worktree.
291type Grouped = (Vec<Chip>, Option<(Timestamp, PathBuf)>);
292
293/// The live branches of the projects of `runs`, with their Runs, and the branch of `launch`
294/// (the folder the app was started for) even before its project has a Run.
295pub 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    // A Run's folder is live while it still holds declarations; the folder the app was started
355    // for is live while it exists, as the CLI takes any folder as `--repo`, its Artifacts
356    // perhaps only in subfolders.
357    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}