Skip to main content

sloc_git/
ops.rs

1// SPDX-License-Identifier: AGPL-3.0-or-later
2// Copyright (C) 2026 Nima Shafie <nimzshafie@gmail.com>
3
4use std::io::Read as _;
5use std::net::ToSocketAddrs;
6use std::path::Path;
7use std::process::Stdio;
8use std::sync::OnceLock;
9use std::time::{Duration, Instant};
10
11use anyhow::{Context, Result, bail};
12
13use crate::{GitCommit, GitRef, GitRefKind, RepoRefs};
14
15/// Optional positive host allowlist for clone targets, parsed once from
16/// `SLOC_GIT_HOST_ALLOWLIST` (comma-separated, lowercased hostnames). When empty,
17/// `validate_clone_url` runs in denylist mode (metadata/loopback blocking only).
18fn git_host_allowlist() -> &'static [String] {
19    static ALLOW: OnceLock<Vec<String>> = OnceLock::new();
20    ALLOW.get_or_init(|| {
21        std::env::var("SLOC_GIT_HOST_ALLOWLIST")
22            .unwrap_or_default()
23            .split(',')
24            .map(|s| s.trim().to_lowercase())
25            .filter(|s| !s.is_empty())
26            .collect()
27    })
28}
29
30/// When `SLOC_GIT_REQUIRE_ALLOWLIST` is truthy, clones are refused unless
31/// `SLOC_GIT_HOST_ALLOWLIST` names the target host. This lets internet-facing or
32/// multi-tenant deployments run allowlist-only (fail closed): only explicitly listed
33/// hostnames are clonable, so a hostname that resolves to an internal address only at
34/// clone time cannot slip through the validate-time resolution check. Unset by default,
35/// so denylist-mode deployments are unaffected.
36fn require_host_allowlist() -> bool {
37    static REQ: OnceLock<bool> = OnceLock::new();
38    *REQ.get_or_init(|| {
39        std::env::var("SLOC_GIT_REQUIRE_ALLOWLIST")
40            .is_ok_and(|v| v == "1" || v.eq_ignore_ascii_case("true"))
41    })
42}
43
44/// When `SLOC_GIT_SSL_NO_VERIFY` is set (any value), TLS certificate verification is
45/// disabled for git network operations via `-c http.sslVerify=false`. This is the escape
46/// hatch for corporate networks whose VPN/proxy performs TLS inspection with a self-signed
47/// CA that is not in the machine's trust store — the common reason a Bitbucket/GitHub fetch
48/// fails on an internal network. Off by default; a startup warning is printed when set.
49fn ssl_no_verify() -> bool {
50    static NO_VERIFY: OnceLock<bool> = OnceLock::new();
51    *NO_VERIFY.get_or_init(|| std::env::var_os("SLOC_GIT_SSL_NO_VERIFY").is_some())
52}
53
54/// Wall-clock ceiling for a single git subprocess, from `SLOC_GIT_TIMEOUT` (seconds).
55/// Defaults to 300s. Guarantees a stalled clone/fetch (dead VPN, black-holed proxy) fails
56/// with a clear error instead of hanging the web request forever.
57fn git_timeout() -> Duration {
58    static TIMEOUT: OnceLock<Duration> = OnceLock::new();
59    *TIMEOUT.get_or_init(|| {
60        let secs = std::env::var("SLOC_GIT_TIMEOUT")
61            .ok()
62            .and_then(|v| v.parse::<u64>().ok())
63            .filter(|&s| s > 0)
64            .unwrap_or(300);
65        Duration::from_secs(secs)
66    })
67}
68
69/// `-c key=value` config flags applied to every network-touching git invocation
70/// (clone/fetch). Makes internal/corporate repos work with zero configuration:
71/// - `http.sslBackend=schannel` (Windows only) — validate TLS against the Windows system
72///   certificate store instead of Git for Windows' own bundled CA file. The system store
73///   already holds the enterprise/proxy root CAs that IT deploys, so a TLS-inspecting
74///   corporate proxy or VPN is trusted automatically — the same reason the repo opens fine
75///   in a browser. This is why a fetch that used to need `SLOC_GIT_SSL_NO_VERIFY` now just
76///   works, and it keeps certificate verification ON (no security downgrade). On Linux/macOS
77///   git already uses the system trust store, so nothing extra is needed there.
78/// - `http.followRedirects=false` — never follow an HTTP redirect into an SSRF target.
79/// - `http.lowSpeedLimit`/`http.lowSpeedTime` — abort a transfer that drops below ~1 KB/s
80///   for 30s, so a flaky VPN/proxy fails fast rather than hanging.
81/// - `http.sslVerify=false` — last-resort override, only when `SLOC_GIT_SSL_NO_VERIFY` is set
82///   (a self-signed cert that isn't in any trust store). Rarely needed now.
83fn network_git_config() -> Vec<String> {
84    let mut cfg = vec![
85        "http.followRedirects=false".to_owned(),
86        "http.lowSpeedLimit=1000".to_owned(),
87        "http.lowSpeedTime=30".to_owned(),
88    ];
89    if cfg!(windows) {
90        cfg.push("http.sslBackend=schannel".to_owned());
91    }
92    if ssl_no_verify() {
93        cfg.push("http.sslVerify=false".to_owned());
94    }
95    cfg
96}
97
98/// Prepend `-c <cfg>` pairs to a git argument list, borrowing from `cfg`.
99fn with_config<'a>(cfg: &'a [String], tail: &[&'a str]) -> Vec<&'a str> {
100    let mut v = Vec::with_capacity(cfg.len() * 2 + tail.len());
101    for c in cfg {
102        v.push("-c");
103        v.push(c.as_str());
104    }
105    v.extend_from_slice(tail);
106    v
107}
108
109/// Persist the network config into the freshly-cloned repo's local git config.
110/// Blobless clones fetch file contents lazily (the promisor kicks in when a ref is checked
111/// out into a worktree), and that implicit fetch reads the repo config — not our per-command
112/// `-c` flags. Writing them here makes the SSL bypass and low-speed abort apply to those
113/// lazy fetches too, so scanning a ref works on the same corporate network the clone did.
114/// Best-effort: a failure here doesn't invalidate an otherwise-successful clone.
115fn persist_repo_config(dest: &Path, cfg: &[String]) {
116    for kv in cfg {
117        if let Some((key, value)) = kv.split_once('=') {
118            let _ = run_git(dest, &["config", key, value]);
119        }
120    }
121}
122
123// ── low-level git runner ───────────────────────────────────────────────────────
124
125fn run_git(repo: &Path, args: &[&str]) -> Result<String> {
126    let mut cmd = std::process::Command::new("git");
127    // Force non-interactive operation. Without this, a `clone`/`fetch` that hits an
128    // authentication challenge (e.g. a rate-limited anonymous clone returning 401, or a
129    // private repo) blocks indefinitely waiting for input that never arrives — git asks on
130    // the terminal and Git Credential Manager pops a GUI dialog, neither of which a
131    // background server subprocess can answer. The request then hangs forever and the web
132    // UI spins on "Fetching repository…". These variables make git fail fast with an error
133    // instead. They suppress only *interactive* prompts; already-stored credentials (SSH
134    // agent, cached HTTPS tokens) are still used, so configured private repos keep working.
135    cmd.env("GIT_TERMINAL_PROMPT", "0")
136        .env("GCM_INTERACTIVE", "never")
137        .env("GIT_ASKPASS", "")
138        .env("SSH_ASKPASS", "")
139        .args(args)
140        .current_dir(repo)
141        .stdin(Stdio::null())
142        .stdout(Stdio::piped())
143        .stderr(Stdio::piped());
144    let mut child = cmd.spawn().context("failed to spawn git process")?;
145
146    // Drain stdout/stderr on dedicated threads: a chatty git process (clone progress,
147    // large logs) can otherwise fill a fixed-size OS pipe buffer and block on write while
148    // we poll for the timeout below — a deadlock that would look exactly like a hang.
149    let mut out_pipe = child.stdout.take();
150    let mut err_pipe = child.stderr.take();
151    let out_handle = std::thread::spawn(move || {
152        let mut buf = Vec::new();
153        if let Some(p) = out_pipe.as_mut() {
154            let _ = p.read_to_end(&mut buf);
155        }
156        buf
157    });
158    let err_handle = std::thread::spawn(move || {
159        let mut buf = Vec::new();
160        if let Some(p) = err_pipe.as_mut() {
161            let _ = p.read_to_end(&mut buf);
162        }
163        buf
164    });
165
166    // Poll for completion, killing the process if it exceeds the wall-clock ceiling.
167    let timeout = git_timeout();
168    let start = Instant::now();
169    let status = loop {
170        if let Some(status) = child.try_wait().context("failed to poll git process")? {
171            break status;
172        }
173        if start.elapsed() >= timeout {
174            let _ = child.kill();
175            let _ = child.wait();
176            bail!(
177                "git {} timed out after {}s — the remote did not respond in time. \
178                 On a corporate network this usually means a proxy or VPN is slow or \
179                 blocking the connection. Raise the ceiling with SLOC_GIT_TIMEOUT=<seconds>, \
180                 or check your proxy/VPN configuration.",
181                args.first().copied().unwrap_or(""),
182                timeout.as_secs()
183            );
184        }
185        std::thread::sleep(Duration::from_millis(100));
186    };
187
188    let stdout = out_handle.join().unwrap_or_default();
189    let stderr = err_handle.join().unwrap_or_default();
190    if !status.success() {
191        let stderr = String::from_utf8_lossy(&stderr);
192        bail!(
193            "git {}: {}",
194            args.first().copied().unwrap_or(""),
195            stderr.trim()
196        );
197    }
198    Ok(String::from_utf8_lossy(&stdout).trim().to_owned())
199}
200
201// ── URL normalization ─────────────────────────────────────────────────────────
202
203/// Convert a repository browse URL into a clonable git URL.
204///
205/// Handles Bitbucket Server/Data Center (`/projects/{PROJ}/repos/{REPO}/...`),
206/// GitLab (`/path/repo/-/tree/...`), GitHub (`github.com/{owner}/{repo}/tree/...`),
207/// and Bitbucket Cloud (`bitbucket.org/{ws}/{repo}/src/...`). SSH URLs and URLs
208/// that already look like clone targets are returned unchanged.
209#[must_use]
210pub fn normalize_git_url(raw: &str) -> String {
211    let url = raw.trim();
212    if url.starts_with("git@") || url.starts_with("ssh://") {
213        return url.to_owned();
214    }
215    let scheme = if url.starts_with("https://") {
216        "https"
217    } else if url.starts_with("http://") {
218        "http"
219    } else {
220        return url.to_owned();
221    };
222    let authority_and_path = &url[scheme.len() + 3..];
223    let (host, path) = authority_and_path
224        .find('/')
225        .map_or((authority_and_path, "/"), |i| {
226            (&authority_and_path[..i], &authority_and_path[i..])
227        });
228    let path = path.trim_end_matches('/');
229
230    try_normalize_bitbucket_server(scheme, host, path)
231        .or_else(|| try_normalize_gitlab(scheme, host, path))
232        .or_else(|| try_normalize_github(scheme, host, path))
233        .or_else(|| try_normalize_bitbucket_cloud(scheme, host, path))
234        .unwrap_or_else(|| url.to_owned())
235}
236
237// ── Bitbucket Server / Data Center ────────────────────────────────────────────
238// Browse URL: /{context}/projects/{PROJECT}/repos/{REPO}[/...]
239// Clone URL:  /{context}/scm/{project_lower}/{repo}.git
240fn try_normalize_bitbucket_server(scheme: &str, host: &str, path: &str) -> Option<String> {
241    let path_lower = path.to_lowercase();
242    let proj_pos = path_lower.find("/projects/")?;
243    let after = &path[proj_pos + "/projects/".len()..];
244    let parts: Vec<&str> = after.splitn(4, '/').collect();
245    if parts.len() < 3 || !parts[1].eq_ignore_ascii_case("repos") {
246        return None;
247    }
248    let context = &path[..proj_pos];
249    let project = parts[0].to_lowercase();
250    let repo = parts[2].trim_end_matches(".git");
251    Some(format!(
252        "{scheme}://{host}{context}/scm/{project}/{repo}.git"
253    ))
254}
255
256// ── GitLab (any host) ─────────────────────────────────────────────────────────
257// Browse URL: /path/to/repo/-/tree/branch  →  Clone URL: /path/to/repo.git
258fn try_normalize_gitlab(scheme: &str, host: &str, path: &str) -> Option<String> {
259    let idx = path.find("/-/")?;
260    let repo_path = path[..idx].trim_end_matches(".git");
261    Some(format!("{scheme}://{host}{repo_path}.git"))
262}
263
264// ── GitHub ────────────────────────────────────────────────────────────────────
265// Browse URL: github.com/{owner}/{repo}/{tree|blob|...}/...
266fn try_normalize_github(scheme: &str, host: &str, path: &str) -> Option<String> {
267    if host != "github.com" && !host.ends_with(".github.com") {
268        return None;
269    }
270    let p = path.trim_start_matches('/');
271    let parts: Vec<&str> = p.splitn(4, '/').collect();
272    if parts.len() < 3
273        || !matches!(
274            parts[2],
275            "tree" | "blob" | "commits" | "commit" | "releases" | "tags" | "branches"
276        )
277    {
278        return None;
279    }
280    let owner = parts[0];
281    let repo = parts[1].trim_end_matches(".git");
282    Some(format!("{scheme}://{host}/{owner}/{repo}.git"))
283}
284
285// ── Bitbucket Cloud ───────────────────────────────────────────────────────────
286// Browse URL: bitbucket.org/{workspace}/{repo}/src/...
287fn try_normalize_bitbucket_cloud(scheme: &str, host: &str, path: &str) -> Option<String> {
288    if host != "bitbucket.org" {
289        return None;
290    }
291    let p = path.trim_start_matches('/');
292    let parts: Vec<&str> = p.splitn(4, '/').collect();
293    if parts.len() < 3 || parts[2] != "src" {
294        return None;
295    }
296    let ws = parts[0];
297    let repo = parts[1].trim_end_matches(".git");
298    Some(format!("{scheme}://{host}/{ws}/{repo}.git"))
299}
300
301// ── clone / fetch ─────────────────────────────────────────────────────────────
302
303fn validate_clone_url(url: &str) -> Result<()> {
304    let lower = url.to_lowercase();
305    // http:// excluded: prevents SSRF against plaintext internal HTTP services.
306    // file:// excluded: prevents local filesystem access.
307    let allowed = ["https://", "git://", "ssh://", "git@"];
308    if !allowed.iter().any(|p| lower.starts_with(p)) {
309        bail!(
310            "git URL rejected: only https://, git://, ssh://, and git@ URLs are \
311             permitted (got {url:?})"
312        );
313    }
314    // SSRF protection: block loopback, link-local, and cloud-metadata hosts.
315    // RFC 1918 private ranges are intentionally ALLOWED so the tool can scan
316    // internal/corporate git servers (10.x, 192.168.x, 172.16-31.x); the real
317    // threat is cloud-metadata and loopback, not "any private IP".
318    // The check is host-scoped (not a whole-URL substring match) so legitimate
319    // paths/tags such as "release-v10.2" are never mistaken for an IP.
320    let Some(host) = host_of_git_url(url) else {
321        return Ok(());
322    };
323    check_host_allowed(&host)?;
324    check_resolved_ips(&host, url)?;
325    Ok(())
326}
327
328/// Host-level SSRF gate: positive allowlist (when configured) plus the
329/// loopback/link-local/cloud-metadata denylist. Split out of `validate_clone_url`
330/// to keep that function's cognitive complexity low.
331fn check_host_allowed(host: &str) -> Result<()> {
332    // Positive allowlist (durable SSRF control): when SLOC_GIT_HOST_ALLOWLIST is
333    // configured, only those hosts may be cloned. This closes the validate-vs-clone
334    // DNS TOCTOU — an attacker cannot point an *allowed name* at an internal IP and
335    // have it accepted unless the name itself is allowlisted. Empty = denylist mode
336    // (loopback/link-local/metadata blocking only), preserving prior behaviour.
337    let allow = git_host_allowlist();
338    if allow.is_empty() {
339        if require_host_allowlist() {
340            bail!(
341                "git URL rejected: SLOC_GIT_REQUIRE_ALLOWLIST is set but \
342                 SLOC_GIT_HOST_ALLOWLIST is empty (no hosts are permitted)"
343            );
344        }
345    } else if !allow.iter().any(|h| h == host) {
346        bail!("git URL rejected: host {host:?} is not in SLOC_GIT_HOST_ALLOWLIST");
347    }
348    if is_ssrf_blocked_host(host) {
349        bail!(
350            "git URL rejected: loopback, link-local, and cloud-metadata \
351             addresses are not permitted (host {host:?})"
352        );
353    }
354    Ok(())
355}
356
357/// Defence against DNS-rebinding: a hostname that is not itself an IP literal can
358/// still resolve to an SSRF-sensitive address. Resolve it now and reject if *any*
359/// resolved IP is blocked. A resolution failure is not fatal (the host may only be
360/// resolvable by git's own resolver in some air-gapped setups) — git will then fail
361/// or succeed on its own; the residual is the documented validate-vs-clone TOCTOU.
362fn check_resolved_ips(host: &str, url: &str) -> Result<()> {
363    let Some(port) = port_of_git_url(url) else {
364        return Ok(());
365    };
366    let Ok(addrs) = (host, port).to_socket_addrs() else {
367        return Ok(());
368    };
369    for addr in addrs {
370        if is_ssrf_blocked_ip(addr.ip()) {
371            bail!(
372                "git URL rejected: host {host:?} resolves to a blocked \
373                 address {} (loopback/link-local/cloud-metadata)",
374                addr.ip()
375            );
376        }
377    }
378    Ok(())
379}
380
381/// Extract the host (lowercased, brackets stripped) from a git clone URL.
382/// Handles `git@host:path`, `scheme://[user@]host[:port]/path`, and IPv6 literals.
383fn host_of_git_url(url: &str) -> Option<String> {
384    let u = url.trim();
385    // scp-like syntax: git@host:path (no scheme)
386    if let Some(rest) = u.strip_prefix("git@") {
387        let host = rest.split(':').next().unwrap_or(rest);
388        return Some(host.to_lowercase());
389    }
390    // scheme://[user@]host[:port]/path
391    let after_scheme = u.split("://").nth(1)?;
392    let authority = after_scheme.split('/').next().unwrap_or(after_scheme);
393    // Strip any userinfo (user[:pass]@).
394    let authority = authority.rsplit('@').next().unwrap_or(authority);
395    // IPv6 literal: [::1]:port → ::1
396    let host = authority.strip_prefix('[').map_or_else(
397        || authority.split(':').next().unwrap_or(authority).to_string(),
398        |stripped| stripped.split(']').next().unwrap_or(stripped).to_string(),
399    );
400    Some(host.to_lowercase())
401}
402
403/// Best-effort port extraction for DNS-rebinding resolution. Returns the explicit
404/// port if present, otherwise the scheme default (https 443, git 9418, ssh 22).
405/// `None` only when no host/scheme can be determined.
406fn port_of_git_url(url: &str) -> Option<u16> {
407    let u = url.trim();
408    // scp-like git@host:path — git over ssh, port 22 (path after ':' is not a port).
409    if u.starts_with("git@") {
410        return Some(22);
411    }
412    let (scheme, after_scheme) = u.split_once("://")?;
413    let authority = after_scheme.split('/').next().unwrap_or(after_scheme);
414    let authority = authority.rsplit('@').next().unwrap_or(authority);
415    // Explicit port: take the segment after the last ':' that is not inside [..].
416    let explicit = authority.strip_prefix('[').map_or_else(
417        // No '[' prefix: take the segment after the last ':'.
418        || {
419            authority
420                .rsplit_once(':')
421                .and_then(|(_, p)| p.parse::<u16>().ok())
422        },
423        // IPv6 literal: [host]:port
424        |stripped| {
425            stripped
426                .split_once("]:")
427                .and_then(|(_, p)| p.parse::<u16>().ok())
428        },
429    );
430    explicit.or_else(|| match scheme.to_lowercase().as_str() {
431        "https" => Some(443),
432        "git" => Some(9418),
433        "ssh" => Some(22),
434        _ => None,
435    })
436}
437
438/// Known cloud-metadata / instance-data hostnames that must never be reachable.
439const BLOCKED_METADATA_HOSTNAMES: &[&str] = &[
440    "metadata.google.internal",
441    "metadata.internal",
442    "instance-data",
443];
444
445/// Returns true when `host` (a hostname or IP literal) is an SSRF-sensitive
446/// loopback, link-local, unspecified, multicast, or cloud-metadata target.
447/// RFC 1918 / IPv6 unique-local private ranges are NOT blocked.
448fn is_ssrf_blocked_host(host: &str) -> bool {
449    let h = host
450        .trim()
451        .trim_start_matches('[')
452        .trim_end_matches(']')
453        .to_lowercase();
454    if h == "localhost" || BLOCKED_METADATA_HOSTNAMES.contains(&h.as_str()) {
455        return true;
456    }
457    h.parse::<std::net::IpAddr>().is_ok_and(is_ssrf_blocked_ip)
458}
459
460/// IP-level SSRF classification. Blocks loopback, link-local, unspecified,
461/// broadcast, multicast, and the Alibaba metadata IP. Allows RFC 1918 / ULA.
462fn is_ssrf_blocked_ip(ip: std::net::IpAddr) -> bool {
463    match ip {
464        std::net::IpAddr::V4(v4) => {
465            v4.is_loopback()
466                || v4.is_link_local()
467                || v4.is_unspecified()
468                || v4.is_broadcast()
469                || v4.is_multicast()
470                || v4.octets() == [100, 100, 100, 200] // Alibaba Cloud metadata
471        }
472        std::net::IpAddr::V6(v6) => {
473            v6.is_loopback()
474                || v6.is_unspecified()
475                || v6.is_multicast()
476                || (v6.segments()[0] & 0xffc0) == 0xfe80 // link-local fe80::/10
477        }
478    }
479}
480
481/// Clone `url` into `dest`, or fetch all refs if the repo already exists.
482///
483/// Browse URLs (GitHub, GitLab, Bitbucket web pages) are automatically converted
484/// to their corresponding git clone URLs before cloning.
485///
486/// # Errors
487/// Returns an error if the URL is rejected, the clone directory cannot be created,
488/// or the underlying `git clone` / `git fetch` command fails.
489pub fn clone_or_fetch(url: &str, dest: &Path) -> Result<()> {
490    let normalized = normalize_git_url(url);
491    let url = normalized.as_str();
492    validate_clone_url(url)?;
493    // `network_git_config()` supplies `http.followRedirects=false` (SSRF hardening — a
494    // redirect can't escape the validated host), the low-speed abort (a stalled VPN/proxy
495    // fails fast), and optional `http.sslVerify=false` for TLS-inspecting corporate proxies.
496    let cfg = network_git_config();
497    if dest.join(".git").exists() {
498        let args = with_config(&cfg, &["fetch", "--all", "--tags", "--prune"]);
499        run_git(dest, &args)?;
500        return Ok(());
501    }
502
503    std::fs::create_dir_all(dest).context("failed to create clone directory")?;
504    let dest_str = dest.to_str().unwrap_or(".");
505    let parent = dest.parent().unwrap_or(dest);
506
507    // Fast path: a blobless (`--filter=blob:none`), no-checkout clone. Only commit and tree
508    // metadata is downloaded — no file blobs, no working tree — which is all that ref
509    // listing needs, and is dramatically faster than a full clone on large repos and slow
510    // corporate links (the original `--depth=50 --no-single-branch` still pulled every
511    // blob for HEAD across every branch). File contents are fetched lazily by the promisor
512    // when a ref is later scanned into a worktree. `--no-tags` is NOT passed: the Tags tab
513    // needs them.
514    let fast = with_config(
515        &cfg,
516        &[
517            "clone",
518            "--filter=blob:none",
519            "--no-checkout",
520            "--no-single-branch",
521            url,
522            dest_str,
523        ],
524    );
525    if let Err(e) = run_git(parent, &fast) {
526        // A handful of older self-hosted servers (e.g. legacy Bitbucket Server) reject
527        // object filtering outright instead of degrading to a full clone. Only in that
528        // specific case do we clean up the partial directory and retry without the filter —
529        // a genuine network/auth failure is surfaced directly rather than paying a second
530        // timeout.
531        let msg = e.to_string().to_lowercase();
532        if !(msg.contains("filter") || msg.contains("partial")) {
533            return Err(e);
534        }
535        let _ = std::fs::remove_dir_all(dest);
536        std::fs::create_dir_all(dest).context("failed to re-create clone directory")?;
537        let full = with_config(
538            &cfg,
539            &[
540                "clone",
541                "--no-checkout",
542                "--no-single-branch",
543                url,
544                dest_str,
545            ],
546        );
547        run_git(parent, &full)?;
548    }
549    persist_repo_config(dest, &cfg);
550    Ok(())
551}
552
553/// Resolve `ref_name` to its full SHA in `repo`.
554///
555/// # Errors
556/// Returns an error if `git rev-parse` fails (e.g. the ref does not exist).
557pub fn get_sha(repo: &Path, ref_name: &str) -> Result<String> {
558    run_git(repo, &["rev-parse", ref_name])
559}
560
561// ── worktree helpers ──────────────────────────────────────────────────────────
562
563/// Resolve a user-facing ref name to a concrete commit SHA the worktree/scan commands accept.
564///
565/// A clone only materialises a *local* branch for the repository's default branch;
566/// every other branch exists solely as a remote-tracking ref (`refs/remotes/origin/<name>`).
567/// Ref listing strips the `origin/` prefix for display, so a bare branch name like "test"
568/// won't resolve directly — we fall back to the remote-tracking form. Tags and raw SHAs
569/// resolve on the first candidate. Peeling with `^{commit}` also dereferences annotated tags.
570///
571/// # Errors
572/// Returns an error if none of the candidate spellings resolve to a commit.
573pub fn resolve_committish(repo: &Path, ref_name: &str) -> Result<String> {
574    let candidates = [
575        ref_name.to_owned(),
576        format!("origin/{ref_name}"),
577        format!("refs/remotes/origin/{ref_name}"),
578    ];
579    for cand in &candidates {
580        let spec = format!("{cand}^{{commit}}");
581        if let Ok(sha) = run_git(repo, &["rev-parse", "--verify", "-q", &spec])
582            && !sha.is_empty()
583        {
584            return Ok(sha);
585        }
586    }
587    bail!(
588        "ref {ref_name:?} not found in repository (tried it directly, as origin/{ref_name}, \
589         and as refs/remotes/origin/{ref_name})"
590    );
591}
592
593/// Create a detached worktree at `worktree_path` pointing at `ref_name`.
594///
595/// `ref_name` is resolved via [`resolve_committish`] first, so a bare branch name that
596/// only exists as a remote-tracking ref (every branch except the default one, in a fresh
597/// clone) still checks out correctly instead of failing with "invalid reference".
598///
599/// # Errors
600/// Returns an error if `ref_name` cannot be resolved or `git worktree add` fails.
601pub fn create_worktree(repo: &Path, ref_name: &str, worktree_path: &Path) -> Result<()> {
602    let wt = worktree_path.to_str().unwrap_or(".");
603    let committish = resolve_committish(repo, ref_name)?;
604    run_git(repo, &["worktree", "add", "--detach", wt, &committish])?;
605    Ok(())
606}
607
608/// Remove a worktree previously created with [`create_worktree`].
609///
610/// # Errors
611/// This function always succeeds; the underlying git command failure is intentionally ignored.
612pub fn destroy_worktree(repo: &Path, worktree_path: &Path) -> Result<()> {
613    let wt = worktree_path.to_str().unwrap_or(".");
614    let _ = run_git(repo, &["worktree", "remove", "--force", wt]);
615    Ok(())
616}
617
618// ── ref listing ───────────────────────────────────────────────────────────────
619
620/// Return all branches, tags, and recent commits for `repo`.
621///
622/// # Errors
623/// Returns an error if any underlying git command fails.
624pub fn list_refs(repo: &Path) -> Result<RepoRefs> {
625    Ok(RepoRefs {
626        branches: list_branches(repo)?,
627        tags: list_tags(repo)?,
628        recent_commits: list_commits(repo, "HEAD", 40)?,
629    })
630}
631
632fn list_branches(repo: &Path) -> Result<Vec<GitRef>> {
633    // `%(symref)` is the leading column and is non-empty only for symbolic refs such as the
634    // remote's default-branch pointer `origin/HEAD`. We must filter on it rather than on the
635    // ref name: `%(refname:short)` collapses `refs/remotes/origin/HEAD` down to bare `origin`,
636    // which is neither "HEAD" nor "*/HEAD", so a name-based filter lets it through and renders
637    // a phantom duplicate of the default branch (same SHA, displayed as "origin").
638    let fmt = "%(symref)|%(refname:short)|%(objectname:short)|%(creatordate:iso-strict)|%(subject)";
639    // Use -r (remote-tracking only) to avoid local/remote duplicates.
640    // Strip the leading remote name (e.g. "origin/") from each ref so the
641    // displayed name matches what the upstream repository calls the branch.
642    let out = run_git(repo, &["branch", "-r", &format!("--format={fmt}")])?;
643    let refs = out
644        .lines()
645        .filter(|l| !l.trim().is_empty())
646        // Split off the symref column; skip the line entirely when it is a symbolic ref.
647        .filter_map(|l| {
648            let (symref, rest) = l.split_once('|')?;
649            if symref.trim().is_empty() {
650                Some(rest)
651            } else {
652                None
653            }
654        })
655        .map(|l| parse_ref_line(l, GitRefKind::Branch))
656        .map(|mut r| {
657            // Strip the remote prefix ("origin/", "upstream/", etc.).
658            if let Some(slash) = r.name.find('/') {
659                r.name = r.name[slash + 1..].to_owned();
660            }
661            r
662        })
663        .collect::<Vec<_>>();
664    Ok(refs)
665}
666
667fn list_tags(repo: &Path) -> Result<Vec<GitRef>> {
668    let fmt = "%(refname:short)|%(objectname:short)|%(creatordate:iso-strict)|%(subject)";
669    let out = run_git(
670        repo,
671        &["tag", "--sort=-creatordate", &format!("--format={fmt}")],
672    )?;
673    Ok(out
674        .lines()
675        .filter(|l| !l.trim().is_empty())
676        .map(|l| parse_ref_line(l, GitRefKind::Tag))
677        .collect())
678}
679
680fn parse_ref_line(line: &str, kind: GitRefKind) -> GitRef {
681    let parts: Vec<&str> = line.splitn(4, '|').collect();
682    let name = parts.first().copied().unwrap_or("").to_owned();
683    let sha = parts.get(1).copied().unwrap_or("").to_owned();
684    let date = parts.get(2).copied().and_then(parse_git_date);
685    let message = parts.get(3).map(|s| (*s).to_owned());
686    GitRef {
687        kind,
688        name,
689        sha,
690        date,
691        message,
692    }
693}
694
695// ── commit listing ────────────────────────────────────────────────────────────
696
697/// Return up to `limit` commits reachable from `ref_name`.
698///
699/// # Errors
700/// Returns an error if `git log` fails.
701pub fn list_commits(repo: &Path, ref_name: &str, limit: usize) -> Result<Vec<GitCommit>> {
702    let fmt = "%H|%h|%an|%aI|%s";
703    let n = format!("-{limit}");
704    let out = run_git(repo, &["log", ref_name, &format!("--format={fmt}"), &n])?;
705    Ok(out
706        .lines()
707        .filter(|l| !l.trim().is_empty())
708        .map(parse_commit_line)
709        .collect())
710}
711
712fn parse_commit_line(line: &str) -> GitCommit {
713    let p: Vec<&str> = line.splitn(5, '|').collect();
714    let sha = p.first().copied().unwrap_or("").to_owned();
715    let short_sha = p.get(1).copied().unwrap_or("").to_owned();
716    let author = p.get(2).copied().unwrap_or("").to_owned();
717    let date = p
718        .get(3)
719        .copied()
720        .and_then(parse_git_date)
721        .unwrap_or_default();
722    let subject = p.get(4).copied().unwrap_or("").to_owned();
723    GitCommit {
724        sha,
725        short_sha,
726        author,
727        date,
728        subject,
729    }
730}
731
732fn parse_git_date(s: &str) -> Option<chrono::DateTime<chrono::Utc>> {
733    chrono::DateTime::parse_from_rfc3339(s)
734        .ok()
735        .map(|d| d.with_timezone(&chrono::Utc))
736}
737
738#[cfg(test)]
739mod tests {
740    use super::*;
741    use crate::GitRefKind;
742    use chrono::Timelike as _;
743
744    // ── SSRF host classification ───────────────────────────────────────────────
745
746    #[test]
747    fn is_ssrf_blocked_host_blocks_localhost_and_metadata() {
748        assert!(is_ssrf_blocked_host("localhost"));
749        assert!(is_ssrf_blocked_host("metadata.google.internal"));
750        assert!(is_ssrf_blocked_host("metadata.internal"));
751        assert!(is_ssrf_blocked_host("instance-data"));
752        // Case/whitespace/bracket normalisation.
753        assert!(is_ssrf_blocked_host("  LOCALHOST  "));
754        // IP literals: loopback and link-local blocked.
755        assert!(is_ssrf_blocked_host("127.0.0.1"));
756        assert!(is_ssrf_blocked_host("[::1]"));
757        assert!(is_ssrf_blocked_host("169.254.169.254"));
758    }
759
760    #[test]
761    fn require_host_allowlist_defaults_false() {
762        // With SLOC_GIT_REQUIRE_ALLOWLIST unset, allowlist enforcement is off.
763        assert!(!require_host_allowlist());
764    }
765
766    #[test]
767    fn check_host_allowed_denylist_mode_permits_public_blocks_sensitive() {
768        // Empty allowlist + enforcement off: public hosts pass, SSRF-sensitive hosts fail.
769        assert!(check_host_allowed("github.com").is_ok());
770        assert!(check_host_allowed("localhost").is_err());
771    }
772
773    #[test]
774    fn is_ssrf_blocked_host_allows_public_hosts() {
775        assert!(!is_ssrf_blocked_host("github.com"));
776        assert!(!is_ssrf_blocked_host("example.com"));
777        // RFC 1918 private ranges are intentionally NOT blocked.
778        assert!(!is_ssrf_blocked_host("192.168.1.10"));
779        assert!(!is_ssrf_blocked_host("10.0.0.1"));
780    }
781
782    // ── network config helpers ────────────────────────────────────────────────
783
784    #[test]
785    fn network_git_config_always_hardens_redirects_and_lowspeed() {
786        let cfg = network_git_config();
787        assert!(cfg.iter().any(|c| c == "http.followRedirects=false"));
788        assert!(cfg.iter().any(|c| c == "http.lowSpeedLimit=1000"));
789        assert!(cfg.iter().any(|c| c == "http.lowSpeedTime=30"));
790    }
791
792    #[cfg(windows)]
793    #[test]
794    fn network_git_config_uses_schannel_on_windows() {
795        // On Windows we validate against the system certificate store so corporate
796        // root CAs are trusted automatically — no SLOC_GIT_SSL_NO_VERIFY required.
797        let cfg = network_git_config();
798        assert!(cfg.iter().any(|c| c == "http.sslBackend=schannel"));
799    }
800
801    #[test]
802    fn with_config_interleaves_dash_c_pairs_before_tail() {
803        let cfg = vec!["a=1".to_owned(), "b=2".to_owned()];
804        let args = with_config(&cfg, &["clone", "url", "dest"]);
805        assert_eq!(args, vec!["-c", "a=1", "-c", "b=2", "clone", "url", "dest"]);
806    }
807
808    #[test]
809    fn with_config_empty_cfg_is_just_the_tail() {
810        let cfg: Vec<String> = Vec::new();
811        assert_eq!(with_config(&cfg, &["fetch"]), vec!["fetch"]);
812    }
813
814    #[test]
815    fn git_timeout_is_positive() {
816        // Default (or env-provided) timeout is always a positive duration.
817        assert!(git_timeout().as_secs() > 0);
818    }
819
820    // ── normalize_git_url ─────────────────────────────────────────────────────
821
822    #[test]
823    fn normalize_github_tree_url() {
824        assert_eq!(
825            normalize_git_url("https://github.com/owner/repo/tree/main"),
826            "https://github.com/owner/repo.git"
827        );
828    }
829
830    #[test]
831    fn normalize_github_blob_url() {
832        assert_eq!(
833            normalize_git_url("https://github.com/owner/repo/blob/main/README.md"),
834            "https://github.com/owner/repo.git"
835        );
836    }
837
838    #[test]
839    fn normalize_github_commits_url() {
840        assert_eq!(
841            normalize_git_url("https://github.com/owner/repo/commits/main"),
842            "https://github.com/owner/repo.git"
843        );
844    }
845
846    #[test]
847    fn normalize_github_releases_url() {
848        assert_eq!(
849            normalize_git_url("https://github.com/owner/repo/releases"),
850            "https://github.com/owner/repo.git"
851        );
852    }
853
854    #[test]
855    fn normalize_github_tags_url() {
856        assert_eq!(
857            normalize_git_url("https://github.com/owner/repo/tags"),
858            "https://github.com/owner/repo.git"
859        );
860    }
861
862    #[test]
863    fn normalize_github_branches_url() {
864        assert_eq!(
865            normalize_git_url("https://github.com/owner/repo/branches"),
866            "https://github.com/owner/repo.git"
867        );
868    }
869
870    #[test]
871    fn normalize_github_plain_clone_url_unchanged() {
872        let url = "https://github.com/owner/repo.git";
873        assert_eq!(normalize_git_url(url), url);
874    }
875
876    #[test]
877    fn normalize_gitlab_tree_url() {
878        assert_eq!(
879            normalize_git_url("https://gitlab.com/group/subgroup/repo/-/tree/main"),
880            "https://gitlab.com/group/subgroup/repo.git"
881        );
882    }
883
884    #[test]
885    fn normalize_gitlab_blob_url() {
886        assert_eq!(
887            normalize_git_url("https://gitlab.com/org/repo/-/blob/main/src/lib.rs"),
888            "https://gitlab.com/org/repo.git"
889        );
890    }
891
892    #[test]
893    fn normalize_gitlab_self_hosted() {
894        assert_eq!(
895            normalize_git_url("https://gitlab.corp.com/team/project/-/tree/develop"),
896            "https://gitlab.corp.com/team/project.git"
897        );
898    }
899
900    #[test]
901    fn normalize_bitbucket_server_browse_url() {
902        assert_eq!(
903            normalize_git_url("https://bitbucket.corp.com/projects/MYPROJ/repos/myrepo/browse"),
904            "https://bitbucket.corp.com/scm/myproj/myrepo.git"
905        );
906    }
907
908    #[test]
909    fn normalize_bitbucket_server_with_context() {
910        assert_eq!(
911            normalize_git_url("https://host.com/ctx/projects/PROJ/repos/repo/browse"),
912            "https://host.com/ctx/scm/proj/repo.git"
913        );
914    }
915
916    #[test]
917    fn normalize_bitbucket_cloud_src_url() {
918        assert_eq!(
919            normalize_git_url("https://bitbucket.org/workspace/repo/src/main/README.md"),
920            "https://bitbucket.org/workspace/repo.git"
921        );
922    }
923
924    #[test]
925    fn normalize_ssh_url_unchanged() {
926        let url = "git@github.com:owner/repo.git";
927        assert_eq!(normalize_git_url(url), url);
928    }
929
930    #[test]
931    fn normalize_ssh_protocol_url_unchanged() {
932        let url = "ssh://git@github.com/owner/repo.git";
933        assert_eq!(normalize_git_url(url), url);
934    }
935
936    #[test]
937    fn normalize_trims_leading_trailing_whitespace() {
938        assert_eq!(
939            normalize_git_url("  https://github.com/owner/repo/tree/main  "),
940            "https://github.com/owner/repo.git"
941        );
942    }
943
944    #[test]
945    fn normalize_http_url_without_match_returned_unchanged() {
946        let url = "http://internal.corp.com/repo.git";
947        assert_eq!(normalize_git_url(url), url);
948    }
949
950    // ── validate_clone_url ────────────────────────────────────────────────────
951
952    #[test]
953    fn validate_https_url_ok() {
954        assert!(validate_clone_url("https://github.com/owner/repo.git").is_ok());
955    }
956
957    #[test]
958    fn validate_git_protocol_url_ok() {
959        assert!(validate_clone_url("git://github.com/owner/repo.git").is_ok());
960    }
961
962    #[test]
963    fn validate_ssh_protocol_url_ok() {
964        assert!(validate_clone_url("ssh://git@github.com/owner/repo.git").is_ok());
965    }
966
967    #[test]
968    fn validate_git_at_url_ok() {
969        assert!(validate_clone_url("git@github.com:owner/repo.git").is_ok());
970    }
971
972    #[test]
973    fn validate_http_plain_rejected() {
974        assert!(
975            validate_clone_url("http://github.com/owner/repo.git").is_err(),
976            "plain http:// must be rejected"
977        );
978    }
979
980    #[test]
981    fn validate_link_local_169_254_rejected() {
982        assert!(validate_clone_url("https://169.254.169.254/latest/meta-data/").is_err());
983    }
984
985    #[test]
986    fn validate_google_metadata_endpoint_rejected() {
987        assert!(
988            validate_clone_url("https://metadata.google.internal/computeMetadata/v1/").is_err()
989        );
990    }
991
992    #[test]
993    fn validate_alibaba_metadata_rejected() {
994        assert!(validate_clone_url("https://100.100.100.200/latest/meta-data/").is_err());
995    }
996
997    #[test]
998    fn validate_ipv6_fe80_link_local_rejected() {
999        assert!(validate_clone_url("https://[fe80::1]/repo").is_err());
1000    }
1001
1002    #[test]
1003    fn validate_file_protocol_rejected() {
1004        assert!(validate_clone_url("file:///etc/passwd").is_err());
1005    }
1006
1007    #[test]
1008    fn validate_empty_string_rejected() {
1009        assert!(validate_clone_url("").is_err());
1010    }
1011
1012    #[test]
1013    fn validate_rfc1918_10_allowed() {
1014        // RFC 1918 private ranges are allowed (internal corporate git servers).
1015        assert!(validate_clone_url("https://10.0.0.1/repo.git").is_ok());
1016    }
1017
1018    #[test]
1019    fn validate_rfc1918_192_168_allowed() {
1020        assert!(validate_clone_url("https://192.168.1.1/repo.git").is_ok());
1021    }
1022
1023    #[test]
1024    fn validate_rfc1918_172_16_allowed() {
1025        assert!(validate_clone_url("https://172.16.0.1/repo.git").is_ok());
1026    }
1027
1028    #[test]
1029    fn validate_rfc1918_172_31_allowed() {
1030        assert!(validate_clone_url("https://172.31.255.255/repo.git").is_ok());
1031    }
1032
1033    #[test]
1034    fn validate_ipv6_ula_fd_allowed() {
1035        // IPv6 unique-local (fc00::/7) is the private-range equivalent — allowed.
1036        assert!(validate_clone_url("https://[fd12:3456:789a::1]/repo").is_ok());
1037    }
1038
1039    // ── port_of_git_url (DNS-rebind resolution helper) ────────────────────────
1040    #[test]
1041    fn port_https_default() {
1042        assert_eq!(port_of_git_url("https://github.com/o/r.git"), Some(443));
1043    }
1044
1045    #[test]
1046    fn port_explicit_overrides_default() {
1047        assert_eq!(
1048            port_of_git_url("https://gitlab.corp:8443/o/r.git"),
1049            Some(8443)
1050        );
1051    }
1052
1053    #[test]
1054    fn port_git_scheme_default() {
1055        assert_eq!(port_of_git_url("git://example.com/r.git"), Some(9418));
1056    }
1057
1058    #[test]
1059    fn port_scp_like_is_ssh() {
1060        assert_eq!(port_of_git_url("git@github.com:owner/repo.git"), Some(22));
1061    }
1062
1063    #[test]
1064    fn port_ipv6_with_explicit_port() {
1065        assert_eq!(port_of_git_url("https://[fd00::1]:7000/r"), Some(7000));
1066    }
1067
1068    #[test]
1069    fn port_ipv6_default() {
1070        assert_eq!(port_of_git_url("https://[fd00::1]/r"), Some(443));
1071    }
1072
1073    #[test]
1074    fn validate_metadata_ip_literal_still_rejected() {
1075        // IP-literal path remains blocked regardless of the new DNS resolution step.
1076        assert!(validate_clone_url("https://169.254.169.254/latest/meta-data/").is_err());
1077    }
1078
1079    #[test]
1080    fn validate_loopback_127_rejected() {
1081        assert!(validate_clone_url("https://127.0.0.1/repo.git").is_err());
1082    }
1083
1084    #[test]
1085    fn validate_localhost_rejected() {
1086        assert!(validate_clone_url("https://localhost/repo.git").is_err());
1087    }
1088
1089    #[test]
1090    fn validate_unspecified_0_0_0_0_rejected() {
1091        assert!(validate_clone_url("https://0.0.0.0/repo.git").is_err());
1092    }
1093
1094    // ── host_of_git_url ───────────────────────────────────────────────────────
1095
1096    // The URL embeds userinfo purely to prove the parser drops it and returns
1097    // only the host — no real secret, this is a parsing fixture.
1098    #[test]
1099    fn host_of_git_url_https_with_port_and_creds() {
1100        assert_eq!(
1101            host_of_git_url("https://user:pw@gitlab.corp.com:8443/team/repo.git").as_deref(),
1102            Some("gitlab.corp.com")
1103        );
1104    }
1105
1106    #[test]
1107    fn host_of_git_url_scp_syntax() {
1108        assert_eq!(
1109            host_of_git_url("git@github.com:owner/repo.git").as_deref(),
1110            Some("github.com")
1111        );
1112    }
1113
1114    #[test]
1115    fn host_of_git_url_ipv6_literal() {
1116        assert_eq!(
1117            host_of_git_url("https://[fe80::1]:443/repo").as_deref(),
1118            Some("fe80::1")
1119        );
1120    }
1121
1122    #[test]
1123    fn validate_clone_url_path_with_version_number_not_blocked() {
1124        // Regression: a path/tag containing "10." must not be mistaken for an IP.
1125        assert!(validate_clone_url("https://github.com/acme/release-v10.2.git").is_ok());
1126        assert!(validate_clone_url("https://github.com/foo/bar-127-baz.git").is_ok());
1127    }
1128
1129    // ── try_normalize_bitbucket_server ────────────────────────────────────────
1130
1131    #[test]
1132    fn bitbucket_server_uppercase_project_lowercased() {
1133        let r = try_normalize_bitbucket_server(
1134            "https",
1135            "bb.corp.com",
1136            "/projects/PROJ/repos/myrepo/browse",
1137        );
1138        assert_eq!(
1139            r,
1140            Some("https://bb.corp.com/scm/proj/myrepo.git".to_owned())
1141        );
1142    }
1143
1144    #[test]
1145    fn bitbucket_server_without_projects_returns_none() {
1146        assert!(
1147            try_normalize_bitbucket_server("https", "bb.corp.com", "/scm/proj/repo.git").is_none()
1148        );
1149    }
1150
1151    #[test]
1152    fn bitbucket_server_missing_repos_segment_returns_none() {
1153        assert!(
1154            try_normalize_bitbucket_server("https", "bb.corp.com", "/projects/PROJ/browse")
1155                .is_none()
1156        );
1157    }
1158
1159    // ── try_normalize_gitlab ──────────────────────────────────────────────────
1160
1161    #[test]
1162    fn gitlab_dash_tree_normalized() {
1163        let r = try_normalize_gitlab("https", "gitlab.com", "/group/repo/-/tree/main");
1164        assert_eq!(r, Some("https://gitlab.com/group/repo.git".to_owned()));
1165    }
1166
1167    #[test]
1168    fn gitlab_no_dash_returns_none() {
1169        assert!(try_normalize_gitlab("https", "gitlab.com", "/group/repo").is_none());
1170    }
1171
1172    #[test]
1173    fn gitlab_strips_existing_dot_git_before_readding() {
1174        let r = try_normalize_gitlab("https", "gitlab.com", "/group/repo.git/-/tree/main");
1175        assert_eq!(r, Some("https://gitlab.com/group/repo.git".to_owned()));
1176    }
1177
1178    // ── try_normalize_github ──────────────────────────────────────────────────
1179
1180    #[test]
1181    fn github_tree_normalized() {
1182        let r = try_normalize_github("https", "github.com", "/owner/repo/tree/main");
1183        assert_eq!(r, Some("https://github.com/owner/repo.git".to_owned()));
1184    }
1185
1186    #[test]
1187    fn github_non_github_host_returns_none() {
1188        assert!(try_normalize_github("https", "gitlab.com", "/owner/repo/tree/main").is_none());
1189    }
1190
1191    #[test]
1192    fn github_plain_two_segment_path_returns_none() {
1193        assert!(try_normalize_github("https", "github.com", "/owner/repo").is_none());
1194    }
1195
1196    #[test]
1197    fn github_unknown_third_segment_returns_none() {
1198        assert!(try_normalize_github("https", "github.com", "/owner/repo/wiki").is_none());
1199    }
1200
1201    // ── try_normalize_bitbucket_cloud ─────────────────────────────────────────
1202
1203    #[test]
1204    fn bitbucket_cloud_src_normalized() {
1205        let r = try_normalize_bitbucket_cloud(
1206            "https",
1207            "bitbucket.org",
1208            "/workspace/repo/src/main/README.md",
1209        );
1210        assert_eq!(
1211            r,
1212            Some("https://bitbucket.org/workspace/repo.git".to_owned())
1213        );
1214    }
1215
1216    #[test]
1217    fn bitbucket_cloud_non_bitbucket_host_returns_none() {
1218        assert!(
1219            try_normalize_bitbucket_cloud("https", "github.com", "/ws/repo/src/main").is_none()
1220        );
1221    }
1222
1223    #[test]
1224    fn bitbucket_cloud_without_src_segment_returns_none() {
1225        assert!(try_normalize_bitbucket_cloud("https", "bitbucket.org", "/ws/repo").is_none());
1226    }
1227
1228    // ── parse_ref_line ────────────────────────────────────────────────────────
1229
1230    #[test]
1231    fn parse_ref_line_all_fields() {
1232        let line = "main|abc1234|2024-01-15T10:00:00+00:00|Initial commit";
1233        let r = parse_ref_line(line, GitRefKind::Branch);
1234        assert_eq!(r.name, "main");
1235        assert_eq!(r.sha, "abc1234");
1236        assert!(r.date.is_some());
1237        assert_eq!(r.message.as_deref(), Some("Initial commit"));
1238        assert!(matches!(r.kind, GitRefKind::Branch));
1239    }
1240
1241    #[test]
1242    fn parse_ref_line_tag_kind() {
1243        let line = "v1.0.0|deadbeef|2024-01-01T00:00:00+00:00|Release v1.0.0";
1244        let r = parse_ref_line(line, GitRefKind::Tag);
1245        assert_eq!(r.name, "v1.0.0");
1246        assert!(matches!(r.kind, GitRefKind::Tag));
1247    }
1248
1249    #[test]
1250    fn parse_ref_line_name_only() {
1251        let r = parse_ref_line("main", GitRefKind::Branch);
1252        assert_eq!(r.name, "main");
1253        assert_eq!(r.sha, "");
1254        assert!(r.date.is_none());
1255        assert!(r.message.is_none());
1256    }
1257
1258    #[test]
1259    fn parse_ref_line_invalid_date_gives_none() {
1260        let r = parse_ref_line("main|abc|not-a-date|msg", GitRefKind::Branch);
1261        assert!(r.date.is_none());
1262        assert_eq!(r.message.as_deref(), Some("msg"));
1263    }
1264
1265    #[test]
1266    fn parse_ref_line_empty_string() {
1267        let r = parse_ref_line("", GitRefKind::Branch);
1268        assert_eq!(r.name, "");
1269    }
1270
1271    // ── parse_commit_line ─────────────────────────────────────────────────────
1272
1273    #[test]
1274    fn parse_commit_line_all_fields() {
1275        let line =
1276            "abc1234567890abcdef|abc1234|Alice Smith|2024-01-15T10:00:00+00:00|Fix critical bug";
1277        let c = parse_commit_line(line);
1278        assert_eq!(c.sha, "abc1234567890abcdef");
1279        assert_eq!(c.short_sha, "abc1234");
1280        assert_eq!(c.author, "Alice Smith");
1281        assert_eq!(c.subject, "Fix critical bug");
1282    }
1283
1284    #[test]
1285    fn parse_commit_line_empty() {
1286        let c = parse_commit_line("");
1287        assert_eq!(c.sha, "");
1288        assert_eq!(c.short_sha, "");
1289        assert_eq!(c.author, "");
1290        assert_eq!(c.subject, "");
1291    }
1292
1293    #[test]
1294    fn parse_commit_line_partial_fields() {
1295        let c = parse_commit_line("sha1|sha_short");
1296        assert_eq!(c.sha, "sha1");
1297        assert_eq!(c.short_sha, "sha_short");
1298        assert_eq!(c.author, "");
1299    }
1300
1301    #[test]
1302    fn parse_commit_line_subject_with_pipe() {
1303        // splitn(5, '|') keeps everything in the 5th slot
1304        let line = "sha|short|author|2024-01-01T00:00:00+00:00|subject with | pipe inside";
1305        let c = parse_commit_line(line);
1306        assert_eq!(c.subject, "subject with | pipe inside");
1307    }
1308
1309    // ── parse_git_date ────────────────────────────────────────────────────────
1310
1311    #[test]
1312    fn parse_git_date_valid_rfc3339() {
1313        let dt = parse_git_date("2024-01-15T10:30:00+00:00");
1314        assert!(dt.is_some());
1315    }
1316
1317    #[test]
1318    fn parse_git_date_invalid_returns_none() {
1319        assert!(parse_git_date("not-a-date").is_none());
1320        assert!(parse_git_date("").is_none());
1321    }
1322
1323    #[test]
1324    fn parse_git_date_with_offset_converts_to_utc() {
1325        let dt = parse_git_date("2024-06-01T12:00:00+05:00").unwrap();
1326        // +05:00 offset means UTC is 12:00 - 5:00 = 07:00
1327        assert_eq!(dt.time().hour(), 7);
1328    }
1329
1330    #[test]
1331    fn port_of_git_url_unknown_scheme_returns_none() {
1332        // A recognised scheme with no explicit port falls back to its default…
1333        assert_eq!(port_of_git_url("https://host/repo"), Some(443));
1334        assert_eq!(port_of_git_url("ssh://host/repo"), Some(22));
1335        assert_eq!(port_of_git_url("git://host/repo"), Some(9418));
1336        // …but an unknown scheme with no explicit port yields None.
1337        assert_eq!(port_of_git_url("file://host/repo"), None);
1338        assert_eq!(port_of_git_url("ftp://host/repo"), None);
1339    }
1340}
1341
1342// ── git subprocess integration tests ─────────────────────────────────────────
1343//
1344// These tests exercise run_git, clone_or_fetch, get_sha, list_refs,
1345// list_commits, create_worktree, and destroy_worktree against a real git
1346// repository created in a temp directory.  They require git to be on PATH
1347// (always true in this project's development and CI environments).
1348#[cfg(test)]
1349mod git_integration {
1350    use super::*;
1351    use std::path::Path;
1352    use tempfile::tempdir;
1353
1354    // ── helpers ───────────────────────────────────────────────────────────────
1355
1356    fn git(dir: &Path, args: &[&str]) {
1357        let status = std::process::Command::new("git")
1358            .args(args)
1359            .current_dir(dir)
1360            .env("GIT_AUTHOR_NAME", "Test")
1361            .env("GIT_AUTHOR_EMAIL", "test@example.com")
1362            .env("GIT_COMMITTER_NAME", "Test")
1363            .env("GIT_COMMITTER_EMAIL", "test@example.com")
1364            .status()
1365            .expect("git must be on PATH");
1366        assert!(status.success(), "git {args:?} failed");
1367    }
1368
1369    /// Initialise a bare-minimum git repo with a single commit on branch `main`.
1370    fn make_repo(dir: &Path) {
1371        git(dir, &["init", "-b", "main"]);
1372        std::fs::write(dir.join("hello.txt"), "hello\n").unwrap();
1373        git(dir, &["add", "hello.txt"]);
1374        git(dir, &["commit", "--no-gpg-sign", "-m", "initial"]);
1375    }
1376
1377    // ── run_git ───────────────────────────────────────────────────────────────
1378
1379    #[test]
1380    fn run_git_success_returns_stdout() {
1381        let dir = tempdir().unwrap();
1382        make_repo(dir.path());
1383        // `git rev-parse HEAD` is the simplest command that produces output
1384        let sha = run_git(dir.path(), &["rev-parse", "HEAD"]).unwrap();
1385        assert_eq!(sha.len(), 40, "full SHA must be 40 hex chars: {sha}");
1386    }
1387
1388    #[test]
1389    fn run_git_failure_returns_error() {
1390        let dir = tempdir().unwrap();
1391        make_repo(dir.path());
1392        let result = run_git(dir.path(), &["rev-parse", "nonexistent-ref-xyz"]);
1393        assert!(result.is_err(), "nonexistent ref must return an error");
1394    }
1395
1396    // ── clone_or_fetch ────────────────────────────────────────────────────────
1397
1398    #[test]
1399    fn clone_or_fetch_clones_local_repo() {
1400        let src = tempdir().unwrap();
1401        make_repo(src.path());
1402
1403        let dest_root = tempdir().unwrap();
1404        let dest = dest_root.path().join("clone");
1405
1406        // Use the file:// URL so validate_clone_url accepts it ... but wait,
1407        // file:// is NOT in the allowlist.  Use https:// scheme bypass: pass the
1408        // raw path directly and let normalize_git_url pass it through unchanged,
1409        // then test validate_clone_url separately.
1410        // Instead: bypass validate_clone_url by calling run_git directly for the
1411        // clone, then test clone_or_fetch on a subsequent fetch.
1412
1413        // Set up the clone manually so we can test the fetch branch.
1414        std::fs::create_dir_all(&dest).unwrap();
1415        let src_str = src.path().to_str().unwrap();
1416        let dest_str = dest.to_str().unwrap();
1417        run_git(src.path(), &["clone", src_str, dest_str]).unwrap();
1418        assert!(dest.join(".git").exists(), "clone must create .git dir");
1419
1420        // Now the dest exists; add a second commit to src and fetch.
1421        std::fs::write(src.path().join("second.txt"), "v2\n").unwrap();
1422        git(src.path(), &["add", "second.txt"]);
1423        git(src.path(), &["commit", "--no-gpg-sign", "-m", "second"]);
1424
1425        // clone_or_fetch on existing dest → runs git fetch
1426        // We bypass URL validation by calling the underlying path directly
1427        // (validate_clone_url would reject local paths; test the fetch branch
1428        // via run_git directly since it's already covered by run_git tests above)
1429        run_git(&dest, &["fetch", "--all", "--tags", "--prune"]).unwrap();
1430    }
1431
1432    #[test]
1433    fn list_branches_excludes_origin_head_symref() {
1434        // A fresh clone carries `origin/HEAD -> origin/main`. `%(refname:short)` shortens that
1435        // symref to bare `origin`, which a name-based filter misses — it would surface as a
1436        // phantom branch duplicating the default branch. Verify it is dropped.
1437        let src = tempdir().unwrap();
1438        let inner = src.path().join("inner");
1439        std::fs::create_dir_all(&inner).unwrap();
1440        make_repo(&inner);
1441        git(&inner, &["branch", "feature-x"]);
1442
1443        let dest_root = tempdir().unwrap();
1444        let dest = dest_root.path().join("clone");
1445        let src_str = inner.to_str().unwrap();
1446        let dest_str = dest.to_str().unwrap();
1447        run_git(src.path(), &["clone", src_str, dest_str]).unwrap();
1448        // Ensure the remote HEAD symref exists (some git versions set it on clone already).
1449        let _ = run_git(&dest, &["remote", "set-head", "origin", "--auto"]);
1450
1451        let branches = list_branches(&dest).unwrap();
1452        let names: Vec<&str> = branches.iter().map(|b| b.name.as_str()).collect();
1453        assert!(
1454            !names.contains(&"origin"),
1455            "origin/HEAD symref must not appear as a branch: {names:?}"
1456        );
1457        assert!(
1458            names.contains(&"main"),
1459            "main branch must be listed: {names:?}"
1460        );
1461        assert!(
1462            names.contains(&"feature-x"),
1463            "real branches must still be listed: {names:?}"
1464        );
1465    }
1466
1467    #[test]
1468    fn clone_or_fetch_rejects_http_plain_url() {
1469        let dest = tempdir().unwrap();
1470        let result = clone_or_fetch("http://example.com/repo.git", dest.path());
1471        assert!(
1472            result.is_err(),
1473            "http:// must be rejected by validate_clone_url"
1474        );
1475    }
1476
1477    #[test]
1478    fn clone_or_fetch_rejects_link_local_url() {
1479        let dest = tempdir().unwrap();
1480        let result = clone_or_fetch("https://169.254.169.254/repo", dest.path());
1481        assert!(result.is_err());
1482    }
1483
1484    // ── get_sha ───────────────────────────────────────────────────────────────
1485
1486    #[test]
1487    fn get_sha_returns_full_commit_hash() {
1488        let dir = tempdir().unwrap();
1489        make_repo(dir.path());
1490        let sha = get_sha(dir.path(), "HEAD").unwrap();
1491        assert_eq!(sha.len(), 40);
1492        assert!(sha.chars().all(|c| c.is_ascii_hexdigit()));
1493    }
1494
1495    #[test]
1496    fn get_sha_nonexistent_ref_errors() {
1497        let dir = tempdir().unwrap();
1498        make_repo(dir.path());
1499        assert!(get_sha(dir.path(), "refs/heads/nonexistent").is_err());
1500    }
1501
1502    // ── list_commits ──────────────────────────────────────────────────────────
1503
1504    #[test]
1505    fn list_commits_returns_at_least_one_commit() {
1506        let dir = tempdir().unwrap();
1507        make_repo(dir.path());
1508        let commits = list_commits(dir.path(), "HEAD", 10).unwrap();
1509        assert!(
1510            !commits.is_empty(),
1511            "must return at least the initial commit"
1512        );
1513        let c = &commits[0];
1514        assert_eq!(c.sha.len(), 40);
1515        assert!(!c.short_sha.is_empty());
1516        assert_eq!(c.author, "Test");
1517        assert_eq!(c.subject, "initial");
1518    }
1519
1520    #[test]
1521    fn list_commits_respects_limit() {
1522        let dir = tempdir().unwrap();
1523        make_repo(dir.path());
1524        // Add a second commit
1525        std::fs::write(dir.path().join("b.txt"), "b\n").unwrap();
1526        git(dir.path(), &["add", "b.txt"]);
1527        git(dir.path(), &["commit", "--no-gpg-sign", "-m", "second"]);
1528
1529        let one = list_commits(dir.path(), "HEAD", 1).unwrap();
1530        assert_eq!(one.len(), 1, "limit=1 must return exactly 1 commit");
1531
1532        let two = list_commits(dir.path(), "HEAD", 10).unwrap();
1533        assert_eq!(two.len(), 2, "limit=10 must return both commits");
1534    }
1535
1536    // ── list_refs (branches + tags) ───────────────────────────────────────────
1537
1538    #[test]
1539    fn list_refs_returns_main_branch() {
1540        let src = tempdir().unwrap();
1541        make_repo(src.path());
1542
1543        // Clone so we have remote-tracking refs (list_branches uses -r)
1544        let dest_root = tempdir().unwrap();
1545        let dest = dest_root.path().join("clone");
1546        let src_str = src.path().to_str().unwrap();
1547        let dest_str = dest.to_str().unwrap();
1548        run_git(src.path(), &["clone", src_str, dest_str]).unwrap();
1549
1550        let refs = list_refs(&dest).unwrap();
1551        let branch_names: Vec<&str> = refs.branches.iter().map(|b| b.name.as_str()).collect();
1552        assert!(
1553            branch_names.contains(&"main"),
1554            "branches must include 'main', got: {branch_names:?}"
1555        );
1556    }
1557
1558    #[test]
1559    fn list_refs_returns_tag() {
1560        let src = tempdir().unwrap();
1561        make_repo(src.path());
1562        git(src.path(), &["tag", "v1.0.0"]);
1563
1564        let dest_root = tempdir().unwrap();
1565        let dest = dest_root.path().join("clone");
1566        let src_str = src.path().to_str().unwrap();
1567        run_git(src.path(), &["clone", src_str, dest.to_str().unwrap()]).unwrap();
1568        // Fetch tags explicitly
1569        run_git(&dest, &["fetch", "--tags"]).unwrap();
1570
1571        let refs = list_refs(&dest).unwrap();
1572        let tag_names: Vec<&str> = refs.tags.iter().map(|t| t.name.as_str()).collect();
1573        assert!(
1574            tag_names.contains(&"v1.0.0"),
1575            "tags must include 'v1.0.0', got: {tag_names:?}"
1576        );
1577    }
1578
1579    // ── create_worktree / destroy_worktree ────────────────────────────────────
1580
1581    #[test]
1582    fn create_and_destroy_worktree() {
1583        let repo = tempdir().unwrap();
1584        make_repo(repo.path());
1585
1586        let sha = get_sha(repo.path(), "HEAD").unwrap();
1587
1588        let wt_root = tempdir().unwrap();
1589        let wt_path = wt_root.path().join("worktree");
1590
1591        create_worktree(repo.path(), &sha, &wt_path).unwrap();
1592        assert!(
1593            wt_path.exists(),
1594            "worktree directory must exist after creation"
1595        );
1596        assert!(
1597            wt_path.join("hello.txt").exists(),
1598            "worktree must contain committed files"
1599        );
1600
1601        destroy_worktree(repo.path(), &wt_path).unwrap();
1602        assert!(
1603            !wt_path.exists(),
1604            "worktree directory must be removed after destroy"
1605        );
1606    }
1607
1608    #[test]
1609    fn destroy_worktree_on_nonexistent_path_succeeds() {
1610        // destroy_worktree intentionally ignores errors
1611        let repo = tempdir().unwrap();
1612        make_repo(repo.path());
1613        let nonexistent = repo.path().join("does_not_exist");
1614        assert!(destroy_worktree(repo.path(), &nonexistent).is_ok());
1615    }
1616
1617    #[test]
1618    fn create_worktree_resolves_non_default_remote_branch() {
1619        // A fresh clone only materialises a local branch for the default branch; every other
1620        // branch exists solely as origin/<name>. Ref listing shows the bare name, so scanning
1621        // a non-default branch must still resolve — the regression the infra test caught.
1622        let src = tempdir().unwrap();
1623        let inner = src.path().join("inner");
1624        std::fs::create_dir_all(&inner).unwrap();
1625        make_repo(&inner);
1626        git(&inner, &["checkout", "-b", "feature-x"]);
1627        std::fs::write(inner.join("feat.txt"), "feature\n").unwrap();
1628        git(&inner, &["add", "feat.txt"]);
1629        git(&inner, &["commit", "--no-gpg-sign", "-m", "feature commit"]);
1630        git(&inner, &["checkout", "main"]);
1631
1632        let dest_root = tempdir().unwrap();
1633        let dest = dest_root.path().join("clone");
1634        run_git(
1635            src.path(),
1636            &["clone", inner.to_str().unwrap(), dest.to_str().unwrap()],
1637        )
1638        .unwrap();
1639
1640        // Bare "feature-x" is only a remote-tracking ref in the clone; must still check out.
1641        let wt_root = tempdir().unwrap();
1642        let wt = wt_root.path().join("wt");
1643        create_worktree(&dest, "feature-x", &wt).unwrap();
1644        assert!(
1645            wt.join("feat.txt").exists(),
1646            "worktree must contain the feature branch's file"
1647        );
1648        destroy_worktree(&dest, &wt).unwrap();
1649    }
1650
1651    #[test]
1652    fn resolve_committish_falls_back_to_origin_and_rejects_unknown() {
1653        let src = tempdir().unwrap();
1654        let inner = src.path().join("inner");
1655        std::fs::create_dir_all(&inner).unwrap();
1656        make_repo(&inner);
1657        git(&inner, &["branch", "release-1"]);
1658
1659        let dest_root = tempdir().unwrap();
1660        let dest = dest_root.path().join("clone");
1661        run_git(
1662            src.path(),
1663            &["clone", inner.to_str().unwrap(), dest.to_str().unwrap()],
1664        )
1665        .unwrap();
1666
1667        // Non-default branch resolves via the origin/ fallback to a 40-char SHA.
1668        let sha = resolve_committish(&dest, "release-1").unwrap();
1669        assert_eq!(sha.len(), 40, "must resolve to a full SHA: {sha}");
1670        // A genuinely absent ref is an error, not a silent empty string.
1671        assert!(resolve_committish(&dest, "no-such-branch").is_err());
1672    }
1673}