1use std::path::{Path, PathBuf};
6use std::process::Command;
7
8use anyhow::{bail, Context, Result};
9
10pub struct Checkout {
12 pub dir: PathBuf,
13 pub target: String,
15 pub commit: String,
16 pub stale: Option<String>,
18}
19
20#[derive(Clone)]
22pub struct Auth {
23 pub username: String,
24 pub token: String,
25}
26
27#[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
51pub fn token_user(host: &str) -> &'static str {
53 if host == "github.com" {
54 "x-access-token"
55 } else {
56 "oauth2"
57 }
58}
59
60pub trait Credentials: Send + Sync {
62 fn token(&self, host: &str) -> Option<String>;
63}
64
65pub 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
80pub 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
107fn git_auth(dir: &Path, args: &[&str], auth: Option<&Auth>) -> Result<String> {
109 finish(args, command(dir, args, auth).output())
110}
111
112fn 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 .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
143pub 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
154pub 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
182pub 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
195pub 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
221pub 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
230pub 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
243pub 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 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
311pub 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
321pub 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
345pub 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
375pub 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
390pub 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 .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}