Skip to main content

review_queue/vcs/
mod.rs

1//! VCS backends (milestone 2): everything shells out to the `git`/`jj` CLIs, since jj has no
2//! stable library API and `git worktree`/`git apply` support in gix/git2 is incomplete.
3//!
4//! Operations on the same canonical repo must be serialized (a per-repo mutex, added when the
5//! sync engine lands); different repos and sources can run concurrently.
6
7use std::path::Path;
8
9use anyhow::Result;
10
11use crate::source::Checkout;
12
13pub mod git;
14pub mod jj;
15
16pub trait Vcs {
17    /// Fetch `refspec_or_sha` into the canonical repo if not already present.
18    fn ensure_commit(&self, repo: &Path, refspec_or_sha: &str) -> Result<()>;
19
20    /// Create a new worktree/workspace at `ws` from the canonical `repo`, applying `checkout`.
21    /// `name` is the review's key slug (e.g. `moz/D12345`), used to namespace the ref that pins
22    /// this `version` so it survives future updates; see `update_workspace`. Returns the
23    /// resulting head id.
24    fn add_workspace(
25        &self,
26        repo: &Path,
27        ws: &Path,
28        checkout: &Checkout,
29        name: &str,
30        version: &str,
31    ) -> Result<String>;
32
33    /// Re-point an existing, clean workspace at a new `checkout`/`version`. The commit the
34    /// workspace pointed at before this call remains reachable afterwards (each call pins its
35    /// own resulting head under a `name`/`version`-namespaced ref rather than overwriting the
36    /// previous one), so old versions stay available for interdiffing until `remove_workspace`
37    /// drops them all. Returns the new head id.
38    fn update_workspace(
39        &self,
40        repo: &Path,
41        ws: &Path,
42        checkout: &Checkout,
43        name: &str,
44        version: &str,
45    ) -> Result<String>;
46
47    /// True if the workspace has local modifications, or its head isn't `expected_head` (the
48    /// stack tip) or one of its ancestors - either case means `sync` must not touch it silently.
49    /// Sitting on an earlier patch of the stack (see `position`) is not dirty.
50    fn is_dirty(&self, ws: &Path, expected_head: &str) -> Result<bool>;
51
52    /// Move a clean workspace to `commit`, one of the patches reachable from the stack tip. Never
53    /// rewrites any of the stack's commits, so the tip stays valid for `is_dirty`.
54    fn position(&self, ws: &Path, commit: &str) -> Result<()>;
55
56    /// Up to `limit` commits reachable from `tip` (newest first) as `(commit id, message)`, so a
57    /// caller can find the one carrying a particular review's patch.
58    fn commits(&self, ws: &Path, tip: &str, limit: usize) -> Result<Vec<(String, String)>>;
59
60    /// Remove the worktree/workspace and every ref/bookmark `add_workspace`/`update_workspace`
61    /// pinned for it under `name`. Callers only ever call this on a workspace `is_dirty` has
62    /// already confirmed is clean - there's no way to force-remove a dirty one.
63    fn remove_workspace(&self, repo: &Path, ws: &Path, name: &str) -> Result<()>;
64}
65
66/// Parse `commits()` output: records separated by `\x1e`, fields by `\x1f`.
67pub(crate) fn parse_commit_records(raw: &str) -> Vec<(String, String)> {
68    raw.split('\x1e')
69        .filter_map(|rec| {
70            let (id, msg) = rec.trim_start_matches('\n').split_once('\x1f')?;
71            Some((id.trim().to_string(), msg.to_string()))
72        })
73        .collect()
74}
75
76/// Render a child process's captured output as plain text. Progress spinners (like moz-phab's)
77/// redraw in place with `\r`, `\b` and ANSI escapes, which is just garbage once captured: apply
78/// the overwrites, drop escape sequences and other control characters.
79pub(crate) fn clean_output(raw: &[u8]) -> String {
80    let text = String::from_utf8_lossy(raw);
81    let mut lines = Vec::new();
82    let mut line: Vec<char> = Vec::new();
83    let mut col: usize = 0;
84    let mut chars = text.chars().peekable();
85    while let Some(c) = chars.next() {
86        match c {
87            '\n' => {
88                lines.push(std::mem::take(&mut line).into_iter().collect::<String>());
89                col = 0;
90            }
91            '\r' => col = 0,
92            '\u{8}' => col = col.saturating_sub(1),
93            '\u{1b}' => {
94                // CSI sequence: ESC [ params final-byte (0x40..=0x7e).
95                if chars.peek() == Some(&'[') {
96                    chars.next();
97                    for n in chars.by_ref() {
98                        if ('\u{40}'..='\u{7e}').contains(&n) {
99                            break;
100                        }
101                    }
102                }
103            }
104            c if c.is_control() || c == '\u{fffd}' => {}
105            c => {
106                if col < line.len() {
107                    line[col] = c;
108                } else {
109                    line.push(c);
110                }
111                col += 1;
112            }
113        }
114    }
115    lines.push(line.into_iter().collect());
116    lines
117        .iter()
118        .map(|l| l.trim_end())
119        .collect::<Vec<_>>()
120        .join("\n")
121        .trim()
122        .to_string()
123}
124
125#[cfg(test)]
126mod clean_output_tests {
127    use super::clean_output;
128
129    #[test]
130    fn applies_overwrites_and_strips_escapes() {
131        let raw = b"Starting up..  -\x08\\\x08|\nFetching\rDone!!!!\n\x1b[31mred\x1b[0m\n";
132        assert_eq!(clean_output(raw), "Starting up..  |\nDone!!!!\nred");
133    }
134}