Skip to main content

wipe_core/
vcs.rs

1//! Version-control-agnostic identity resolution.
2//!
3//! wipe stores its board in a repo, but that repo isn't always git: teams on
4//! Plastic SCM (Unity VCS), Mercurial, SVN, Fossil, or Jujutsu should still get
5//! real attribution instead of "unknown". This module detects the VCS in use and
6//! asks it who the current user is, shelling out to each tool best-effort (a
7//! missing tool or non-repo simply yields `None`, never an error).
8
9use std::path::{Path, PathBuf};
10use std::process::Command;
11use std::sync::mpsc;
12use std::time::Duration;
13
14/// Hard cap on any VCS probe. A hung tool (e.g. Plastic's `cm whoami` against an
15/// unreachable server) must never block a `wipe` command or a daemon request.
16const PROBE_TIMEOUT: Duration = Duration::from_secs(3);
17/// Cap on history walked when discovering authors, so large repos stay fast.
18const AUTHOR_SCAN_LIMIT: &str = "2000";
19
20/// A recognized version-control system hosting the repo.
21#[derive(Debug, Clone, Copy, PartialEq, Eq)]
22pub enum Vcs {
23    Git,
24    /// Plastic SCM / Unity Version Control (`cm`).
25    Plastic,
26    Mercurial,
27    Svn,
28    Fossil,
29    Jujutsu,
30    /// No recognized VCS at or above the path.
31    None,
32}
33
34impl Vcs {
35    /// Short, stable machine name (e.g. for `--json` / diagnostics).
36    pub fn name(self) -> &'static str {
37        match self {
38            Vcs::Git => "git",
39            Vcs::Plastic => "plastic",
40            Vcs::Mercurial => "mercurial",
41            Vcs::Svn => "svn",
42            Vcs::Fossil => "fossil",
43            Vcs::Jujutsu => "jujutsu",
44            Vcs::None => "none",
45        }
46    }
47}
48
49/// Walk up from `start` looking for a VCS marker. The first match wins; `.git`
50/// and `.plastic` are checked together at each level so a nested layout resolves
51/// to the closest enclosing workspace.
52pub fn detect(start: &Path) -> Vcs {
53    let abs = std::fs::canonicalize(start).unwrap_or_else(|_| start.to_path_buf());
54    let mut cur: Option<PathBuf> = Some(abs);
55    while let Some(dir) = cur {
56        // Marker directories.
57        for (marker, vcs) in [
58            (".git", Vcs::Git),
59            (".plastic", Vcs::Plastic),
60            (".hg", Vcs::Mercurial),
61            (".svn", Vcs::Svn),
62            (".jj", Vcs::Jujutsu),
63        ] {
64            if dir.join(marker).exists() {
65                return vcs;
66            }
67        }
68        // Fossil uses a checkout marker file rather than a directory.
69        if dir.join(".fslckout").exists() || dir.join("_FOSSIL_").exists() {
70            return Vcs::Fossil;
71        }
72        cur = dir.parent().map(Path::to_path_buf);
73    }
74    Vcs::None
75}
76
77/// The current user's identity as reported by the repo's VCS (`Name <email>` when
78/// available), or `None` if there's no VCS, the tool is missing, or it's unset.
79pub fn identity(start: &Path) -> Option<String> {
80    match detect(start) {
81        Vcs::Git => git_identity(start),
82        Vcs::Plastic => plastic_identity(start),
83        Vcs::Mercurial => hg_identity(start),
84        Vcs::Fossil => fossil_identity(start),
85        Vcs::Jujutsu => jj_identity(start),
86        // SVN has no reliable "current user" query; fall back to the OS user.
87        Vcs::Svn | Vcs::None => None,
88    }
89}
90
91/// Distinct historical authors as `(name, email)`, best-effort. Only git and
92/// Mercurial expose this cheaply; others return an empty list.
93pub fn authors(start: &Path) -> Vec<(String, String)> {
94    match detect(start) {
95        Vcs::Git => git_authors(start),
96        Vcs::Mercurial => hg_authors(start),
97        _ => Vec::new(),
98    }
99}
100
101/// The OS account name (`$USERNAME` on Windows, `$USER` elsewhere), a last-ditch
102/// identity when no VCS can supply one.
103pub fn system_user() -> Option<String> {
104    std::env::var("USERNAME")
105        .or_else(|_| std::env::var("USER"))
106        .ok()
107        .map(|s| s.trim().to_string())
108        .filter(|s| !s.is_empty())
109}
110
111// --- per-VCS implementations ----------------------------------------------
112
113/// Strip Windows' `\\?\` verbatim prefix, which some CLIs (git `-C`) reject.
114/// Handles UNC verbatim paths (`\\?\UNC\server\share` → `\\server\share`) so repos
115/// on network shares still resolve.
116pub(crate) fn plain(root: &Path) -> PathBuf {
117    let s = root.to_string_lossy();
118    if let Some(rest) = s.strip_prefix(r"\\?\UNC\") {
119        return PathBuf::from(format!(r"\\{rest}"));
120    }
121    match s.strip_prefix(r"\\?\") {
122        Some(rest) => PathBuf::from(rest),
123        None => root.to_path_buf(),
124    }
125}
126
127/// Run a prepared command with a hard timeout, returning trimmed stdout on success.
128/// On timeout (or spawn/exec failure) returns `None`; the abandoned child is left to
129/// exit on its own rather than blocking us - identity is best-effort.
130fn run_timed(mut cmd: Command) -> Option<String> {
131    let (tx, rx) = mpsc::channel();
132    std::thread::spawn(move || {
133        let _ = tx.send(cmd.output());
134    });
135    match rx.recv_timeout(PROBE_TIMEOUT) {
136        Ok(Ok(out)) => finish(out),
137        _ => None,
138    }
139}
140
141/// Run `program args...` in `dir` (via `-C`), returning trimmed stdout on success.
142fn output_in(dir: &Path, program: &str, args: &[&str]) -> Option<String> {
143    let mut cmd = Command::new(program);
144    cmd.arg("-C").arg(plain(dir)).args(args);
145    run_timed(cmd)
146}
147
148/// Run a command that takes its working directory via `current_dir` (tools
149/// without a `-C` flag), returning trimmed stdout on success.
150fn output_cwd(dir: &Path, program: &str, args: &[&str]) -> Option<String> {
151    let mut cmd = Command::new(program);
152    cmd.current_dir(plain(dir)).args(args);
153    run_timed(cmd)
154}
155
156fn finish(out: std::process::Output) -> Option<String> {
157    if !out.status.success() {
158        return None;
159    }
160    let s = String::from_utf8_lossy(&out.stdout).trim().to_string();
161    if s.is_empty() {
162        None
163    } else {
164        Some(s)
165    }
166}
167
168fn combine(name: Option<String>, email: Option<String>) -> Option<String> {
169    match (name, email) {
170        (Some(n), Some(e)) => Some(format!("{n} <{e}>")),
171        (Some(n), None) => Some(n),
172        (None, Some(e)) => Some(e),
173        (None, None) => None,
174    }
175}
176
177fn git_identity(root: &Path) -> Option<String> {
178    let name = output_in(root, "git", &["config", "user.name"]);
179    let email = output_in(root, "git", &["config", "user.email"]);
180    combine(name, email)
181}
182
183fn git_authors(root: &Path) -> Vec<(String, String)> {
184    let Some(out) = output_in(
185        root,
186        "git",
187        &[
188            "--no-pager",
189            "log",
190            "-n",
191            AUTHOR_SCAN_LIMIT,
192            "--format=%an\t%ae",
193        ],
194    ) else {
195        return Vec::new();
196    };
197    let mut seen = std::collections::HashSet::new();
198    let mut authors = Vec::new();
199    for line in out.lines() {
200        if let Some((name, email)) = line.split_once('\t') {
201            if seen.insert(email.to_string()) {
202                authors.push((name.to_string(), email.to_string()));
203            }
204        }
205    }
206    authors
207}
208
209fn plastic_identity(root: &Path) -> Option<String> {
210    // `cm whoami` prints the current Plastic user (often an email-like seid).
211    output_cwd(root, "cm", &["whoami"])
212}
213
214fn hg_identity(root: &Path) -> Option<String> {
215    // `hg config ui.username` yields a ready-made "Name <email>".
216    output_in(root, "hg", &["config", "ui.username"])
217}
218
219fn hg_authors(root: &Path) -> Vec<(String, String)> {
220    let Some(out) = output_in(
221        root,
222        "hg",
223        &["log", "-l", AUTHOR_SCAN_LIMIT, "--template", "{author}\n"],
224    ) else {
225        return Vec::new();
226    };
227    let mut seen = std::collections::HashSet::new();
228    let mut authors = Vec::new();
229    for line in out.lines() {
230        let line = line.trim();
231        if line.is_empty() {
232            continue;
233        }
234        // "Name <email>" → split; otherwise treat the whole thing as the name.
235        let (name, email) = match (line.find('<'), line.find('>')) {
236            (Some(a), Some(b)) if b > a => {
237                (line[..a].trim().to_string(), line[a + 1..b].to_string())
238            }
239            _ => (line.to_string(), line.to_string()),
240        };
241        if seen.insert(email.clone()) {
242            authors.push((name, email));
243        }
244    }
245    authors
246}
247
248fn fossil_identity(root: &Path) -> Option<String> {
249    output_cwd(root, "fossil", &["user", "default"])
250}
251
252fn jj_identity(root: &Path) -> Option<String> {
253    let name = output_cwd(root, "jj", &["config", "get", "user.name"]);
254    let email = output_cwd(root, "jj", &["config", "get", "user.email"]);
255    combine(name, email)
256}
257
258#[cfg(test)]
259mod tests {
260    use super::*;
261
262    #[test]
263    fn detects_git_and_resolves_identity() {
264        let dir = tempfile::tempdir().unwrap();
265        let root = dir.path();
266        let git = |args: &[&str]| {
267            Command::new("git")
268                .arg("-C")
269                .arg(root)
270                .args(args)
271                .output()
272                .unwrap();
273        };
274        git(&["init", "-q"]);
275        git(&["config", "user.name", "Ada Lovelace"]);
276        git(&["config", "user.email", "ada@example.com"]);
277
278        assert_eq!(detect(root), Vcs::Git);
279        assert_eq!(
280            identity(root).as_deref(),
281            Some("Ada Lovelace <ada@example.com>")
282        );
283    }
284
285    #[test]
286    fn no_vcs_yields_none() {
287        let dir = tempfile::tempdir().unwrap();
288        assert_eq!(detect(dir.path()), Vcs::None);
289        assert_eq!(identity(dir.path()), None);
290    }
291
292    #[test]
293    fn vcs_names_are_stable() {
294        assert_eq!(Vcs::Plastic.name(), "plastic");
295        assert_eq!(Vcs::Git.name(), "git");
296        assert_eq!(Vcs::None.name(), "none");
297    }
298}