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}