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, 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/// 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
104/// One Run of a branch as its chip shows it.
105#[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/// A live branch and its Runs.
114#[derive(Debug, Clone, PartialEq, Eq)]
115pub struct Branch {
116    pub place: Place,
117    /// The project's name: its repository's or folder's.
118    pub project: String,
119    /// The branch, a detached worktree's folder name, or empty for a folder outside Git.
120    pub name: String,
121    /// Where its Current is scanned and its Artifacts' tools run: the worktree (or folder) as it
122    /// is now, at the workspace its newest Run used (or the app was started for) within it.
123    pub workspace: PathBuf,
124    /// Its Runs, oldest first.
125    pub runs: Vec<Chip>,
126}
127
128impl Branch {
129    /// The Run in progress a branch's timeline starts with: the newest Run, while it runs.
130    pub fn running(&self) -> Option<&Chip> {
131        self.runs.last().filter(|chip| chip.status.is_open())
132    }
133
134    /// When its newest Run started.
135    pub fn latest(&self) -> Option<Timestamp> {
136        self.runs.last().map(|chip| chip.created_at)
137    }
138}
139
140/// Which project and branch a saved Run ran on, by name, for every Run whether or not its
141/// branch still lives: what a waiting review of it is listed with.
142#[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/// Cached Git facts. Identities of old Run records without a saved identity are asked of Git
151/// once; which worktrees exist is asked again on every [`Discovery::rescan`].
152#[derive(Default)]
153pub struct Discovery {
154    identities: BTreeMap<PathBuf, Identity>,
155    trees: BTreeMap<PathBuf, Vec<Worktree>>,
156}
157
158impl Discovery {
159    /// Ask Git again which worktrees exist, on the next listing.
160    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                        // Saved identities are canonical; a detached head is matched by path.
179                        path: crate::platform::canonicalize(&tree.path).unwrap_or(tree.path),
180                        branch: tree.branch,
181                    })
182                    .collect()
183            })
184            .clone()
185    }
186}
187
188/// Where `identity` (of the workspace `repo`) lives, and `repo` relative to its worktree.
189/// The head of the worktree at `tree` with `branch` checked out, or detached without one. A
190/// name Git reports that is no branch name is known by the worktree's folder instead.
191fn 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
224/// The project's name: a repository's main folder (the parent of its `.git`), a bare
225/// repository's own folder without `.git`, or the folder outside Git.
226fn 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
261/// What the listing of the branches found.
262pub struct Listing {
263    /// The live branches, the most recent Run first.
264    pub branches: Vec<Branch>,
265    pub labels: Labels,
266    /// The branch of the folder the app was started for, when it lives.
267    pub launch: Option<Place>,
268}
269
270/// A branch's Runs so far, and when its newest started with its workspace in the worktree.
271type Grouped = (Vec<Chip>, Option<(Timestamp, PathBuf)>);
272
273/// The live branches of the projects of `runs`, with their Runs, and the branch of `launch`
274/// (the folder the app was started for) even before its project has a Run.
275pub 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    // A Run's folder is live while it still holds declarations; the folder the app was started
334    // for is live while it exists, as the CLI takes any folder as `--repo`, its Artifacts
335    // perhaps only in subfolders.
336    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}