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
132            .last()
133            .filter(|chip| chip.status == RunStatus::Running)
134    }
135
136    /// When its newest Run started.
137    pub fn latest(&self) -> Option<Timestamp> {
138        self.runs.last().map(|chip| chip.created_at)
139    }
140}
141
142/// Which project and branch a saved Run ran on, by name, for every Run whether or not its
143/// branch still lives: what a waiting review of it is listed with.
144#[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/// Cached Git facts. Identities of old Run records without a saved identity are asked of Git
153/// once; which worktrees exist is asked again on every [`Discovery::rescan`].
154#[derive(Default)]
155pub struct Discovery {
156    identities: BTreeMap<PathBuf, Identity>,
157    trees: BTreeMap<PathBuf, Vec<Worktree>>,
158}
159
160impl Discovery {
161    /// Ask Git again which worktrees exist, on the next listing.
162    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                        // Saved identities are canonical; a detached head is matched by path.
181                        path: crate::platform::canonicalize(&tree.path).unwrap_or(tree.path),
182                        branch: tree.branch,
183                    })
184                    .collect()
185            })
186            .clone()
187    }
188}
189
190/// Where `identity` (of the workspace `repo`) lives, and `repo` relative to its worktree.
191/// The head of the worktree at `tree` with `branch` checked out, or detached without one. A
192/// name Git reports that is no branch name is known by the worktree's folder instead.
193fn 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
226/// The project's name: a repository's main folder (the parent of its `.git`), a bare
227/// repository's own folder without `.git`, or the folder outside Git.
228fn 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
263/// What the listing of the branches found.
264pub struct Listing {
265    /// The live branches, the most recent Run first.
266    pub branches: Vec<Branch>,
267    pub labels: Labels,
268    /// The branch of the folder the app was started for, when it lives.
269    pub launch: Option<Place>,
270}
271
272/// A branch's Runs so far, and when its newest started with its workspace in the worktree.
273type Grouped = (Vec<Chip>, Option<(Timestamp, PathBuf)>);
274
275/// The live branches of the projects of `runs`, with their Runs, and the branch of `launch`
276/// (the folder the app was started for) even before its project has a Run.
277pub 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    // A Run's folder is live while it still holds declarations; the folder the app was started
336    // for is live while it exists, as the CLI takes any folder as `--repo`, its Artifacts
337    // perhaps only in subfolders.
338    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}