Skip to main content

comindly_core/
worktree.rs

1//! comindly's own checkout of each repository's default branch: a detached git worktree under
2//! `~/.cache/comindly/worktrees`, so indexing never touches your working folder or branches. Uses the
3//! `git` command, so your SSH keys and credential helpers apply.
4
5use std::path::{Path, PathBuf};
6use std::process::Command;
7
8use anyhow::{bail, Context, Result};
9
10/// A checkout ready to index.
11pub struct Checkout {
12    pub dir: PathBuf,
13    /// What was checked out, e.g. `origin/main`.
14    pub target: String,
15    pub commit: String,
16    /// Set when fetching failed and the last fetched state was used instead.
17    pub stale: Option<String>,
18}
19
20/// An HTTPS username and access token, handed to git for one command only.
21#[derive(Clone)]
22pub struct Auth {
23    pub username: String,
24    pub token: String,
25}
26
27/// Returned when an HTTPS remote needs a token that neither git's credential helper nor the environment has.
28#[derive(Debug)]
29pub struct NeedsToken {
30    pub host: String,
31    pub username: String,
32}
33
34impl std::fmt::Display for NeedsToken {
35    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
36        write!(f, "{} needs an access token", self.host)
37    }
38}
39
40impl std::error::Error for NeedsToken {}
41
42fn https_host(url: &str) -> Option<String> {
43    let rest = url
44        .strip_prefix("https://")
45        .or_else(|| url.strip_prefix("http://"))?;
46    let authority = rest.split('/').next()?;
47    let host = authority.rsplit('@').next()?.split(':').next()?;
48    (!host.is_empty()).then(|| host.to_lowercase())
49}
50
51/// The username a host expects with an access token.
52pub fn token_user(host: &str) -> &'static str {
53    if host == "github.com" {
54        "x-access-token"
55    } else {
56        "oauth2"
57    }
58}
59
60/// Access tokens for HTTPS remotes by host; without one, git's own credential helper answers.
61pub trait Credentials: Send + Sync {
62    fn token(&self, host: &str) -> Option<String>;
63}
64
65/// No tokens: git's credential helper and SSH keys only.
66pub struct NoCredentials;
67
68impl Credentials for NoCredentials {
69    fn token(&self, _: &str) -> Option<String> {
70        None
71    }
72}
73
74impl<F: Fn(&str) -> Option<String> + Send + Sync> Credentials for F {
75    fn token(&self, host: &str) -> Option<String> {
76        self(host)
77    }
78}
79
80/// A token for cloning or fetching `url` without asking.
81pub fn auth_for(url: &str, creds: &dyn Credentials) -> Option<Auth> {
82    let host = https_host(url)?;
83    creds.token(&host).map(|token| Auth {
84        username: token_user(&host).into(),
85        token,
86    })
87}
88
89fn auth_failed(stderr: &str) -> bool {
90    [
91        "could not read Username",
92        "could not read Password",
93        "Authentication failed",
94        "Access denied",
95        "HTTP Basic",
96        "returned error: 401",
97        "returned error: 403",
98    ]
99    .iter()
100    .any(|m| stderr.contains(m))
101}
102
103fn git(dir: &Path, args: &[&str]) -> Result<String> {
104    git_auth(dir, args, None)
105}
106
107/// Run git; with `auth`, a credential helper that answers with it is tried after your own helpers.
108fn git_auth(dir: &Path, args: &[&str], auth: Option<&Auth>) -> Result<String> {
109    finish(args, command(dir, args, auth).output())
110}
111
112/// A git command in `dir` that never prompts and gives up on a stalled transfer.
113fn command(dir: &Path, args: &[&str], auth: Option<&Auth>) -> Command {
114    let mut cmd = Command::new("git");
115    if let Some(a) = auth {
116        cmd.arg("-c")
117            .arg(r#"credential.helper=!f() { test "$1" = get && printf 'username=%s\npassword=%s\n' "$COMINDLY_GIT_USER" "$COMINDLY_GIT_TOKEN"; }; f"#)
118            .env("COMINDLY_GIT_USER", &a.username)
119            .env("COMINDLY_GIT_TOKEN", &a.token);
120    }
121    cmd.arg("-C")
122        .arg(dir)
123        .args(args)
124        .env("GIT_TERMINAL_PROMPT", "0")
125        // Give up on a stalled fetch instead of waiting on a struggling server.
126        .env("GIT_HTTP_LOW_SPEED_LIMIT", "1000")
127        .env("GIT_HTTP_LOW_SPEED_TIME", "20");
128    cmd
129}
130
131fn finish(args: &[&str], out: std::io::Result<std::process::Output>) -> Result<String> {
132    let out = out.context("run git (is it installed?)")?;
133    if !out.status.success() {
134        bail!(
135            "git {}: {}",
136            args.join(" "),
137            String::from_utf8_lossy(&out.stderr).trim()
138        );
139    }
140    Ok(String::from_utf8_lossy(&out.stdout).trim().to_string())
141}
142
143/// The repository's name: its folder's name.
144pub fn name(repo: &Path) -> String {
145    repo.file_name()
146        .map(|n| n.to_string_lossy().into_owned())
147        .unwrap_or_else(|| "repo".into())
148}
149
150fn has_origin(repo: &Path) -> bool {
151    git(repo, &["remote", "get-url", "origin"]).is_ok()
152}
153
154/// The branch to index: `origin`'s default (from `origin/HEAD`), else `main` or `master`.
155pub fn default_branch(repo: &Path) -> Result<String> {
156    if let Ok(head) = git(
157        repo,
158        &["symbolic-ref", "--short", "refs/remotes/origin/HEAD"],
159    ) {
160        if let Some(b) = head.strip_prefix("origin/") {
161            return Ok(b.to_string());
162        }
163    }
164    let prefix = if has_origin(repo) {
165        "refs/remotes/origin/"
166    } else {
167        "refs/heads/"
168    };
169    for b in ["main", "master"] {
170        if git(
171            repo,
172            &["rev-parse", "--verify", "--quiet", &format!("{prefix}{b}")],
173        )
174        .is_ok()
175        {
176            return Ok(b.to_string());
177        }
178    }
179    bail!("no main or master branch in {}", repo.display())
180}
181
182/// The remote's default branch and its commit, asked from the remote; also records it as `origin/HEAD`.
183pub fn remote_default(repo: &Path, creds: &dyn Credentials) -> Result<(String, String)> {
184    let auth = remote_url(repo).and_then(|u| auth_for(&u, creds));
185    let out = git_auth(
186        repo,
187        &["ls-remote", "--symref", "origin", "HEAD"],
188        auth.as_ref(),
189    )?;
190    let (branch, commit) = parse_head(&out)?;
191    let _ = git(repo, &["remote", "set-head", "origin", &branch]);
192    Ok((branch, commit))
193}
194
195/// The default branch and its commit of the repository at `url`, without a local clone.
196pub fn remote_head(url: &str, creds: &dyn Credentials) -> Result<(String, String)> {
197    let out = git_auth(
198        &std::env::temp_dir(),
199        &["ls-remote", "--symref", url, "HEAD"],
200        auth_for(url, creds).as_ref(),
201    )?;
202    parse_head(&out)
203}
204
205fn parse_head(out: &str) -> Result<(String, String)> {
206    let branch = out
207        .lines()
208        .find_map(|l| l.strip_prefix("ref: refs/heads/"))
209        .and_then(|l| l.split_whitespace().next())
210        .context("the remote does not say which branch is its default")?
211        .to_string();
212    let commit = out
213        .lines()
214        .find(|l| !l.starts_with("ref:") && l.ends_with("HEAD"))
215        .and_then(|l| l.split_whitespace().next())
216        .context("the remote's default branch has no commit")?
217        .to_string();
218    Ok((branch, commit))
219}
220
221/// The branch checked out in your own folder (for display).
222pub fn current_branch(repo: &Path) -> Option<String> {
223    git(repo, &["rev-parse", "--abbrev-ref", "HEAD"]).ok()
224}
225
226pub fn remote_url(repo: &Path) -> Option<String> {
227    git(repo, &["remote", "get-url", "origin"]).ok()
228}
229
230/// comindly's checkout of `repo`'s default branch under `cache`, if one has been made.
231pub fn checkout_dir(cache: &Path, repo: &Path) -> Option<PathBuf> {
232    worktree_dir(cache, repo)
233        .ok()
234        .filter(|d| d.join(".git").exists())
235}
236
237fn worktree_dir(cache: &Path, repo: &Path) -> Result<PathBuf> {
238    let abs = std::fs::canonicalize(repo).with_context(|| format!("{}", repo.display()))?;
239    let key = blake3::hash(abs.to_string_lossy().as_bytes()).to_hex();
240    Ok(cache.join("worktrees").join(&key[..12]).join(name(repo)))
241}
242
243/// Fetch the remote's default branch and check it out in comindly's worktree, detached, so your
244/// folder and branches are never touched. Offline, the last known default branch is used.
245pub fn prepare(cache: &Path, repo: &Path, creds: &dyn Credentials) -> Result<Checkout> {
246    if !repo.join(".git").exists() {
247        bail!("{} is not a git repository", repo.display());
248    }
249    let mut stale = None;
250    let branch = if has_origin(repo) {
251        match remote_default(repo, creds) {
252            Ok((b, _)) => b,
253            Err(e) => {
254                stale = Some(format!("{e:#}"));
255                default_branch(repo)?
256            }
257        }
258    } else {
259        default_branch(repo)?
260    };
261    // Also used for checking out: a partial clone fetches file contents then, with the same token.
262    let auth = has_origin(repo)
263        .then(|| git(repo, &["remote", "get-url", "origin"]).ok())
264        .flatten()
265        .and_then(|u| auth_for(&u, creds));
266    let target = if has_origin(repo) {
267        let target = format!("origin/{branch}");
268        if let Err(e) = git_auth(
269            repo,
270            &["fetch", "--quiet", "origin", &branch],
271            auth.as_ref(),
272        ) {
273            let why = format!("{e:#}");
274            git(repo, &["rev-parse", "--verify", "--quiet", &target])
275                .map_err(|_| e.context("and no earlier fetch to fall back to"))?;
276            stale = Some(why);
277        }
278        target
279    } else {
280        branch.clone()
281    };
282    let dir = worktree_dir(cache, repo)?;
283    let _ = git(repo, &["worktree", "prune"]);
284    if dir.join(".git").exists() {
285        git_auth(
286            &dir,
287            &["checkout", "--quiet", "--detach", "--force", &target],
288            auth.as_ref(),
289        )?;
290        git(&dir, &["clean", "-fdq"])?;
291    } else {
292        std::fs::create_dir_all(dir.parent().context("worktree parent")?)?;
293        let d = dir.to_string_lossy();
294        git_auth(
295            repo,
296            &[
297                "worktree", "add", "--quiet", "--detach", "--force", &d, &target,
298            ],
299            auth.as_ref(),
300        )?;
301    }
302    let commit = git(&dir, &["rev-parse", "--short", "HEAD"])?;
303    Ok(Checkout {
304        dir,
305        target,
306        commit,
307        stale,
308    })
309}
310
311/// Remove comindly's worktree for `repo` (your folder is untouched).
312pub fn remove(cache: &Path, repo: &Path) -> Result<()> {
313    let dir = worktree_dir(cache, repo)?;
314    if dir.exists() {
315        let d = dir.to_string_lossy();
316        git(repo, &["worktree", "remove", "--force", &d])?;
317    }
318    Ok(())
319}
320
321/// Git repositories under `dir`, up to `depth` levels down (not inside other repositories).
322pub fn scan(dir: &Path, depth: usize) -> Vec<PathBuf> {
323    let mut out = Vec::new();
324    let Ok(entries) = std::fs::read_dir(dir) else {
325        return out;
326    };
327    let mut entries: Vec<PathBuf> = entries.flatten().map(|e| e.path()).collect();
328    entries.sort();
329    for path in entries.into_iter().filter(|p| p.is_dir()) {
330        let hidden = path
331            .file_name()
332            .is_some_and(|n| n.to_string_lossy().starts_with('.'));
333        if hidden || path.ends_with("node_modules") {
334            continue;
335        }
336        if path.join(".git").exists() {
337            out.push(path);
338        } else if depth > 1 {
339            out.extend(scan(&path, depth - 1));
340        }
341    }
342    out
343}
344
345/// Clone `url` into `parent/<name>` with your git credentials; returns the new folder.
346/// comindly's own copy of `url` at `dest`: cloned once, without a checkout (indexing uses its worktree)
347/// and without file contents until needed; later runs only fetch.
348pub fn ensure_clone(url: &str, dest: &Path, creds: &dyn Credentials) -> Result<()> {
349    if dest.join(".git").exists() || dest.join("HEAD").exists() {
350        return Ok(());
351    }
352    let parent = dest.parent().context("clone folder")?;
353    std::fs::create_dir_all(parent)?;
354    let auth = auth_for(url, creds);
355    let d = dest.to_string_lossy();
356    let args = [
357        "clone",
358        "--quiet",
359        "--filter=blob:none",
360        "--no-checkout",
361        url,
362        &d,
363    ];
364    if let Err(e) = git_auth(parent, &args, auth.as_ref()) {
365        let _ = std::fs::remove_dir_all(dest);
366        if let Some(host) = https_host(url).filter(|_| auth_failed(&format!("{e:#}"))) {
367            let username = token_user(&host).into();
368            return Err(NeedsToken { host, username }.into());
369        }
370        return Err(e);
371    }
372    Ok(())
373}
374
375/// What your git credential helper holds for `host`.
376pub fn helper_auth(host: &str) -> Option<Auth> {
377    let input = format!("protocol=https\nhost={host}\n\n");
378    let out = credential("fill", &input)?;
379    let field = |k: &str| {
380        out.lines()
381            .find_map(|l| l.strip_prefix(&format!("{k}=")))
382            .map(str::to_string)
383    };
384    Some(Auth {
385        username: field("username").unwrap_or_else(|| token_user(host).into()),
386        token: field("password")?,
387    })
388}
389
390/// Hand a token that worked to your git credential helper, as git does after a successful login.
391pub fn approve(host: &str, auth: &Auth) {
392    let input = format!(
393        "protocol=https\nhost={host}\nusername={}\npassword={}\n\n",
394        auth.username, auth.token
395    );
396    let _ = credential("approve", &input);
397}
398
399fn credential(action: &str, input: &str) -> Option<String> {
400    use std::io::Write;
401    let mut child = Command::new("git")
402        .args(["credential", action])
403        .env("GIT_TERMINAL_PROMPT", "0")
404        // Only read what is stored; Git Credential Manager would otherwise open a login window.
405        .env("GCM_INTERACTIVE", "never")
406        .stdin(std::process::Stdio::piped())
407        .stdout(std::process::Stdio::piped())
408        .stderr(std::process::Stdio::null())
409        .spawn()
410        .ok()?;
411    child.stdin.take()?.write_all(input.as_bytes()).ok()?;
412    let out = child.wait_with_output().ok()?;
413    out.status
414        .success()
415        .then(|| String::from_utf8_lossy(&out.stdout).into_owned())
416}
417
418#[cfg(test)]
419mod tests {
420    use super::*;
421
422    #[test]
423    fn hosts_and_token_users() {
424        assert_eq!(
425            https_host("https://user@gitlab.example.com:8443/g/r.git").as_deref(),
426            Some("gitlab.example.com")
427        );
428        assert_eq!(https_host("git@github.com:o/r.git"), None);
429        assert_eq!(token_user("github.com"), "x-access-token");
430        assert_eq!(token_user("gitlab.com"), "oauth2");
431        assert!(auth_failed(
432            "fatal: could not read Username for 'https://gitlab.com': terminal prompts disabled"
433        ));
434        assert!(!auth_failed("fatal: repository 'x' does not exist"));
435    }
436
437    #[test]
438    fn worktree_follows_main_and_leaves_the_folder_alone() {
439        let base = std::env::temp_dir().join(format!("comindly-wt-{}", std::process::id()));
440        let origin = base.join("origin");
441        let mine = base.join("mine");
442        std::fs::create_dir_all(&origin).unwrap();
443        let g = |d: &Path, a: &[&str]| git(d, a).unwrap();
444        g(&origin, &["init", "--quiet", "-b", "main"]);
445        g(&origin, &["config", "user.email", "t@t"]);
446        g(&origin, &["config", "user.name", "t"]);
447        std::fs::write(origin.join("a.py"), "x = 1\n").unwrap();
448        g(&origin, &["add", "."]);
449        g(&origin, &["commit", "--quiet", "-m", "one"]);
450        g(
451            &base,
452            &["clone", "--quiet", origin.to_str().unwrap(), "mine"],
453        );
454        g(&mine, &["checkout", "--quiet", "-b", "feature"]);
455        std::fs::write(mine.join("wip.py"), "draft\n").unwrap();
456
457        std::fs::write(origin.join("b.py"), "y = 2\n").unwrap();
458        g(&origin, &["add", "."]);
459        g(&origin, &["commit", "--quiet", "-m", "two"]);
460
461        let c = prepare(&base.join("cache"), &mine, &NoCredentials).unwrap();
462        assert_eq!(c.target, "origin/main");
463        assert!(
464            c.dir.join("b.py").exists(),
465            "the worktree has the newest main"
466        );
467        assert!(!c.dir.join("wip.py").exists());
468        assert_eq!(current_branch(&mine).as_deref(), Some("feature"));
469        assert!(mine.join("wip.py").exists(), "your folder is untouched");
470        g(&mine, &["checkout", "--quiet", "main"]);
471        assert!(
472            prepare(&base.join("cache"), &mine, &NoCredentials).is_ok(),
473            "a second run reuses the worktree"
474        );
475        remove(&base.join("cache"), &mine).unwrap();
476        assert!(!c.dir.exists());
477        assert_eq!(scan(&base, 1).len(), 2);
478        std::fs::remove_dir_all(&base).ok();
479    }
480
481    #[test]
482    fn indexes_the_remotes_default_branch_whatever_its_name() {
483        let base = std::env::temp_dir().join(format!("comindly-trunk-{}", std::process::id()));
484        let origin = base.join("origin");
485        std::fs::create_dir_all(&origin).unwrap();
486        let g = |d: &Path, a: &[&str]| git(d, a).unwrap();
487        g(&origin, &["init", "--quiet", "-b", "trunk"]);
488        g(&origin, &["config", "user.email", "t@t"]);
489        g(&origin, &["config", "user.name", "t"]);
490        std::fs::write(origin.join("a.py"), "x = 1\n").unwrap();
491        g(&origin, &["add", "."]);
492        g(&origin, &["commit", "--quiet", "-m", "one"]);
493        let url = format!("file://{}", origin.display());
494        let mine = base.join("managed").join("origin");
495        ensure_clone(&url, &mine, &NoCredentials).unwrap();
496        ensure_clone(&url, &mine, &NoCredentials).unwrap();
497        let (branch, commit) = remote_default(&mine, &NoCredentials).unwrap();
498        assert_eq!(
499            remote_head(&url, &NoCredentials).unwrap(),
500            (branch.clone(), commit.clone()),
501            "the same answer without a clone"
502        );
503        assert_eq!(branch, "trunk");
504        let c = prepare(&base.join("cache"), &mine, &NoCredentials).unwrap();
505        assert_eq!(c.target, "origin/trunk");
506        assert_eq!(c.commit, commit[..c.commit.len()]);
507        assert!(c.dir.join("a.py").exists());
508        std::fs::remove_dir_all(&base).ok();
509        std::fs::remove_dir_all(c.dir.parent().unwrap()).ok();
510    }
511}