diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9fcb494d..dbc7f852 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -591,6 +591,15 @@ jobs: bin.install "git-remote-gitlawb" end + def caveats + <<~CAVEATS + oh-my-zsh's git plugin aliases gl='git pull', which shadows this + binary in interactive shells. If \`gl\` prints "fatal: not a git + repository", run: + echo 'unalias gl 2>/dev/null' >> ~/.zshrc && source ~/.zshrc + CAVEATS + end + test do assert_match version.to_s, shell_output("#{bin}/gl --version") end diff --git a/crates/gl/Cargo.toml b/crates/gl/Cargo.toml index 9b11d573..d6e9fcf9 100644 --- a/crates/gl/Cargo.toml +++ b/crates/gl/Cargo.toml @@ -13,6 +13,7 @@ path = "src/main.rs" [dependencies] gitlawb-core = { path = "../gitlawb-core" } icaptcha-client = { path = "../icaptcha-client" } +base64 = { workspace = true } tokio = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } diff --git a/crates/gl/src/cert.rs b/crates/gl/src/cert.rs index 09823d5b..87ad5aec 100644 --- a/crates/gl/src/cert.rs +++ b/crates/gl/src/cert.rs @@ -41,6 +41,16 @@ pub enum CertCmd { node: String, #[arg(long)] dir: Option, + /// Exit non-zero unless the Ed25519 signature verifies AND the + /// issuing node matches the queried node (or --expect-node) + #[arg(long)] + verify: bool, + /// Expected issuing node DID for --verify. A valid signature alone + /// only proves the cert is internally consistent — signed by whatever + /// key it names — so --verify also anchors the issuer to a DID you + /// trust: this value when given, else the queried node's DID. + #[arg(long, requires = "verify")] + expect_node: Option, }, } @@ -52,7 +62,9 @@ pub async fn run(args: CertArgs) -> Result<()> { id, node, dir, - } => cmd_show(repo, id, node, dir).await, + verify, + expect_node, + } => cmd_show(repo, id, node, dir, verify, expect_node).await, } } @@ -114,7 +126,14 @@ async fn cmd_list(repo: String, node: String, dir: Option) -> Result<() Ok(()) } -async fn cmd_show(repo: String, id: String, node: String, dir: Option) -> Result<()> { +async fn cmd_show( + repo: String, + id: String, + node: String, + dir: Option, + require_valid: bool, + expect_node: Option, +) -> Result<()> { let (owner, name) = resolve_repo(&repo, &node, dir.as_deref()).await?; let client = signed_client(&node, dir.as_deref()); @@ -148,39 +167,124 @@ async fn cmd_show(repo: String, id: String, node: String, dir: Option) println!(" Signature: {signature}"); println!(); - // Reconstruct the signing payload and verify - // Fetch the node's current public key to verify - let info: Value = client - .get("/") - .await? - .json() - .await - .context("failed to fetch node info")?; - let current_node_did = info["did"].as_str().unwrap_or(""); + // Verify the Ed25519 signature: rebuild the exact canonical payload the + // node signed (see gitlawb-node/src/cert.rs::issue_ref_certificate) and + // check it against the public key embedded in the certificate's node DID. + // This proves the cert is internally authentic — signed by the key it + // names; the node-DID comparison below covers *which* node that is. + let repo_id = cert["repo_id"].as_str().unwrap_or(""); + let verdict = verify_signature( + repo_id, ref_name, old_sha, new_sha, pusher, node_did, issued_at, signature, + ); println!("Signature verification:"); - println!(" Signing payload would be:"); - println!(" {{\"repo_id\": ..., \"ref\": \"{ref_name}\", \"old\": \"{old_sha}\","); - println!(" \"new\": \"{new_sha}\", \"pusher\": \"{pusher}\","); - println!(" \"node\": \"{node_did}\", \"ts\": \"{issued_at}\"}}"); - println!(); + match &verdict { + Ok(()) => { + println!( + " VALID — Ed25519 signature verified against the key the certificate names ({node_did})" + ); + } + Err(reason) => { + println!(" INVALID — {reason}"); + } + } - if current_node_did == node_did { - println!(" Node DID matches current node. Signature is an Ed25519/base64url value."); - println!(" To verify offline, use the node's Ed25519 public key derived from:"); - println!(" did:key → {node_did}"); - } else { - println!(" WARNING: Certificate node DID ({node_did}) does not match"); - println!(" current node DID ({current_node_did})."); - println!(" This certificate was issued by a different node."); + // Contextual only — the verdict above stands on its own, so a node-info + // hiccup here must not turn a successfully displayed certificate into an + // error exit. + let current_node_did = match client.get("/").await { + Ok(resp) => resp + .json::() + .await + .ok() + .and_then(|info| info["did"].as_str().map(str::to_string)), + Err(_) => None, + }; + match current_node_did.as_deref() { + Some(current) if current == node_did => { + println!(" Issuing node DID matches the node being queried."); + } + Some(current) => { + println!(" WARNING: Certificate node DID ({node_did}) does not match"); + println!(" current node DID ({current})."); + println!(" This certificate was issued by a different node."); + } + None => { + println!(" NOTE: could not fetch current node info — skipping node-DID comparison."); + } } - println!(); - println!(" Signature (base64url): {signature}"); + if require_valid { + if let Err(reason) = verdict { + anyhow::bail!("certificate signature did not verify: {reason}"); + } + // A valid signature proves internal consistency only: the payload was + // signed by whatever key the certificate itself names. A hostile + // source can mint a keypair, put its DID in node_did, and self-sign. + // --verify therefore also anchors the issuer to a trusted DID: + // --expect-node when given, else the DID of the node being queried. + let expected = expect_node.as_deref().or(current_node_did.as_deref()); + match expected { + Some(expected) if expected == node_did => {} + Some(expected) => anyhow::bail!( + "certificate is signed by {node_did}, but the expected issuer is {expected} — \ + a valid signature alone proves internal consistency, not a trusted issuer" + ), + None => anyhow::bail!( + "cannot anchor the issuer: node info is unreachable and no --expect-node was given" + ), + } + } Ok(()) } +/// Rebuild the node's canonical signing payload (field order must match +/// gitlawb-node/src/cert.rs::issue_ref_certificate exactly) and verify the +/// certificate's Ed25519 signature against the key embedded in `node_did`. +#[allow(clippy::too_many_arguments)] +fn verify_signature( + repo_id: &str, + ref_name: &str, + old_sha: &str, + new_sha: &str, + pusher: &str, + node_did: &str, + issued_at: &str, + signature_b64: &str, +) -> std::result::Result<(), String> { + use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine}; + use std::str::FromStr; + + let payload = serde_json::json!({ + "repo_id": repo_id, + "ref": ref_name, + "old": old_sha, + "new": new_sha, + "pusher": pusher, + "node": node_did, + "ts": issued_at, + }); + let payload_bytes = + serde_json::to_vec(&payload).map_err(|e| format!("could not serialize payload: {e}"))?; + + let did = + gitlawb_core::did::Did::from_str(node_did).map_err(|e| format!("bad node DID: {e}"))?; + let verifying_key = did + .to_verifying_key() + .map_err(|e| format!("cannot derive a public key from {node_did}: {e}"))?; + + let sig_vec = URL_SAFE_NO_PAD + .decode(signature_b64) + .map_err(|e| format!("signature is not valid base64url: {e}"))?; + let sig_bytes: [u8; 64] = sig_vec + .try_into() + .map_err(|_| "signature is not 64 bytes".to_string())?; + + gitlawb_core::identity::verify(&verifying_key, &payload_bytes, &sig_bytes) + .map_err(|_| "Ed25519 signature does not match the signed payload".to_string()) +} + async fn resolve_cert_id(client: &NodeClient, owner: &str, name: &str, id: &str) -> Result { if id.len() >= 36 { return Ok(id.to_string()); @@ -209,3 +313,88 @@ async fn resolve_cert_id(client: &NodeClient, owner: &str, name: &str, id: &str) _ => anyhow::bail!("certificate prefix {id} matches multiple certificates"), } } + +#[cfg(test)] +mod tests { + use super::*; + + /// Pins gl's payload reconstruction to the frozen canonical byte form the + /// node signs (default serde_json maps = alphabetically ordered keys). If + /// serialization drifts — a field added, or a preserve_order feature + /// landing anywhere in the workspace (feature unification flips every + /// crate at once) — this literal stops matching and the test fails, + /// instead of every real certificate silently rendering INVALID. + #[test] + fn payload_serialization_matches_frozen_canonical_form() { + let payload = serde_json::json!({ + "repo_id": "repo-1", + "ref": "refs/heads/main", + "old": "oldsha", + "new": "newsha", + "pusher": "did:key:z6MkPusher", + "node": "did:key:z6MkNode", + "ts": "2026-07-22T00:00:00+00:00", + }); + let frozen = concat!( + r#"{"new":"newsha","node":"did:key:z6MkNode","old":"oldsha","#, + r#""pusher":"did:key:z6MkPusher","ref":"refs/heads/main","#, + r#""repo_id":"repo-1","ts":"2026-07-22T00:00:00+00:00"}"#, + ); + assert_eq!(serde_json::to_string(&payload).unwrap(), frozen); + } + + /// Signing exactly as the node does must round-trip through + /// verify_signature; any field tampering must fail it. + #[test] + fn verify_signature_round_trip_and_tamper() { + let kp = gitlawb_core::identity::Keypair::generate(); + let node_did = kp.did().as_str().to_string(); + + let payload = serde_json::json!({ + "repo_id": "repo-1", + "ref": "refs/heads/main", + "old": "0".repeat(40), + "new": "a".repeat(40), + "pusher": "did:key:z6MkPusher", + "node": node_did, + "ts": "2026-07-22T00:00:00+00:00", + }); + let sig = kp.sign_b64(&serde_json::to_vec(&payload).unwrap()); + + let ok = verify_signature( + "repo-1", + "refs/heads/main", + &"0".repeat(40), + &"a".repeat(40), + "did:key:z6MkPusher", + &node_did, + "2026-07-22T00:00:00+00:00", + &sig, + ); + assert!(ok.is_ok(), "expected valid signature, got: {ok:?}"); + + let tampered = verify_signature( + "repo-1", + "refs/heads/main", + &"0".repeat(40), + &"b".repeat(40), // new_sha changed after signing + "did:key:z6MkPusher", + &node_did, + "2026-07-22T00:00:00+00:00", + &sig, + ); + assert!(tampered.is_err(), "tampered payload must not verify"); + + let garbage = verify_signature( + "repo-1", + "refs/heads/main", + &"0".repeat(40), + &"a".repeat(40), + "did:key:z6MkPusher", + &node_did, + "2026-07-22T00:00:00+00:00", + "not-base64url!!!", + ); + assert!(garbage.is_err(), "malformed signature must not verify"); + } +} diff --git a/crates/gl/src/doctor.rs b/crates/gl/src/doctor.rs index 1a5d96be..86f50334 100644 --- a/crates/gl/src/doctor.rs +++ b/crates/gl/src/doctor.rs @@ -142,18 +142,20 @@ pub async fn run(args: DoctorArgs) -> Result<()> { // ── 3. GITLAWB_NODE env var ─────────────────────────────────────────── match std::env::var("GITLAWB_NODE") { - Ok(v) if !v.is_empty() && !v.contains("127.0.0.1") && !v.contains("localhost") => { - checks.push(Check::pass("GITLAWB_NODE", v.to_string())); - } - Ok(v) if v.contains("127.0.0.1") || v.contains("localhost") => { - checks.push(Check::fail( + // A loopback host is a legitimate setup (self-hosted node, dev + // harness) — the connectivity check below fails loudly if it is not + // actually reachable, so don't red-flag the configuration itself. + Ok(v) if is_loopback_url(&v) => { + checks.push(Check::pass( "GITLAWB_NODE", format!( - "set to local address ({v}) — git push/clone will fail against remote nodes" + "{v} (local node — intentional for self-hosting/dev; unset to target the public network)" ), - "export GITLAWB_NODE=https://node.gitlawb.com", )); } + Ok(v) if !v.is_empty() => { + checks.push(Check::pass("GITLAWB_NODE", v.to_string())); + } _ => { checks.push(Check::fail( "GITLAWB_NODE", @@ -243,6 +245,15 @@ pub async fn run(args: DoctorArgs) -> Result<()> { )); } + // ── 5b. shell alias shadowing ───────────────────────────────────────── + // oh-my-zsh's default `git` plugin aliases gl='git pull', which silently + // shadows this binary in every interactive zsh — the classic symptom is + // `gl` printing "fatal: not a git repository". Aliases beat PATH, so no + // install method can fix it; the user's rc file has to unalias. + if let Some(check) = check_shell_alias_shadowing(dirs::home_dir()) { + checks.push(check); + } + // ── 6. git ──────────────────────────────────────────────────────────── match std::process::Command::new("git") .arg("--version") @@ -309,6 +320,83 @@ pub async fn run(args: DoctorArgs) -> Result<()> { } /// Check if a binary name exists anywhere on PATH. +/// True when the rc file contains a real `unalias` command naming `gl` — +/// not a comment, and not a longer word like `unalias global`. Ordering +/// relative to oh-my-zsh loading is not modeled (an unalias placed before the +/// plugin loads would be ineffective); acceptable slack for a warning check. +fn rc_unaliases_gl(rc: &str) -> bool { + rc.lines().any(|line| { + let line = line.trim_start(); + if line.starts_with('#') { + return false; + } + line.split([';', '&', '|']).any(|cmd| { + // A trailing comment must not count: `unalias foo # gl` + let cmd = cmd.split('#').next().unwrap_or(""); + let mut tokens = cmd.split_whitespace(); + tokens.next() == Some("unalias") && tokens.any(|t| t == "gl") + }) + }) +} + +/// True when the URL's host is exactly a loopback address. Substring checks +/// misclassify hosts like `localhost.example` or URLs with "localhost" in the +/// path, so parse and compare the actual host. +fn is_loopback_url(value: &str) -> bool { + reqwest::Url::parse(value) + .ok() + .and_then(|u| { + u.host_str() + .map(|h| h == "localhost" || h == "127.0.0.1" || h == "[::1]" || h == "::1") + }) + .unwrap_or(false) +} + +/// Detect interactive-shell setups where an alias shadows `gl` (aliases beat +/// PATH, so no install method can fix this). Best-effort heuristic over +/// ~/.zshrc: an explicit `alias gl=`, or oh-my-zsh's `git` plugin (which +/// ships gl='git pull'). Returns None when nothing suspicious is found or the +/// rc file already contains an `unalias gl`. +fn check_shell_alias_shadowing(home: Option) -> Option { + let home = home?; + let rc = std::fs::read_to_string(home.join(".zshrc")).ok()?; + if rc_unaliases_gl(&rc) { + return None; + } + + let explicit_alias = rc.lines().any(|l| l.trim_start().starts_with("alias gl=")); + // Single-line `plugins=(git ...)` is the overwhelmingly common form; a + // multi-line plugins array slips past this heuristic, which is acceptable + // for a warning-level check. + let omz_git_plugin = home.join(".oh-my-zsh/plugins/git").exists() + && rc.lines().any(|l| { + let l = l.trim_start(); + l.starts_with("plugins=") + && l.trim_start_matches("plugins=(") + .trim_end_matches(')') + .split_whitespace() + .any(|p| p == "git") + }); + + if explicit_alias || omz_git_plugin { + let source = if explicit_alias { + "~/.zshrc defines `alias gl=`" + } else { + "oh-my-zsh's git plugin aliases gl='git pull'" + }; + Some(Check::warn( + "shell alias", + format!( + "{source} — interactive shells run that instead of this binary \ + (symptom: `gl` prints \"fatal: not a git repository\")" + ), + "echo 'unalias gl 2>/dev/null' >> ~/.zshrc && source ~/.zshrc (must come after oh-my-zsh loads)", + )) + } else { + None + } +} + fn which_in_path(name: &str) -> bool { std::env::var_os("PATH") .map(|paths| { @@ -393,6 +481,88 @@ fn is_newer(latest: &str, current: &str) -> bool { mod tests { use super::*; + fn fake_home(zshrc: Option<&str>, with_omz_git: bool) -> tempfile::TempDir { + let home = tempfile::TempDir::new().unwrap(); + if let Some(rc) = zshrc { + std::fs::write(home.path().join(".zshrc"), rc).unwrap(); + } + if with_omz_git { + std::fs::create_dir_all(home.path().join(".oh-my-zsh/plugins/git")).unwrap(); + } + home + } + + #[test] + fn alias_shadowing_flags_omz_git_plugin() { + let home = fake_home(Some("plugins=(git z)\nsource $ZSH/oh-my-zsh.sh\n"), true); + let check = check_shell_alias_shadowing(Some(home.path().to_path_buf())); + assert!(check.is_some(), "omz git plugin without unalias must warn"); + } + + #[test] + fn alias_shadowing_flags_explicit_alias() { + let home = fake_home(Some("alias gl='git pull'\n"), false); + assert!(check_shell_alias_shadowing(Some(home.path().to_path_buf())).is_some()); + } + + #[test] + fn alias_shadowing_silent_when_unaliased() { + let home = fake_home( + Some("plugins=(git z)\nsource $ZSH/oh-my-zsh.sh\nunalias gl 2>/dev/null\n"), + true, + ); + assert!(check_shell_alias_shadowing(Some(home.path().to_path_buf())).is_none()); + } + + #[test] + fn alias_shadowing_ignores_fake_unaliases() { + // None of these actually free `gl` — the warning must survive them. + for rc in [ + "plugins=(git)\n# unalias gl\n", // commented out + "plugins=(git)\nunalias global\n", // longer word + "plugins=(git)\nunalias foo # gl\n", // gl only in a comment + "plugins=(git)\necho unalias-gl-later\n", // not an unalias command + ] { + let home = fake_home(Some(rc), true); + assert!( + check_shell_alias_shadowing(Some(home.path().to_path_buf())).is_some(), + "must still warn for rc: {rc:?}" + ); + } + // Real unalias forms that do free it — all must silence the warning. + for rc in [ + "plugins=(git)\nunalias gl\n", + "plugins=(git)\nunalias gl 2>/dev/null\n", + "plugins=(git)\ntrue; unalias gl\n", + "plugins=(git)\nunalias glog gl gst\n", + ] { + let home = fake_home(Some(rc), true); + assert!( + check_shell_alias_shadowing(Some(home.path().to_path_buf())).is_none(), + "must be silent for rc: {rc:?}" + ); + } + } + + #[test] + fn loopback_url_detection_is_host_exact() { + assert!(is_loopback_url("http://127.0.0.1:7545")); + assert!(is_loopback_url("http://localhost:7545")); + assert!(is_loopback_url("http://[::1]:7545")); + assert!(!is_loopback_url("https://localhost.example")); + assert!(!is_loopback_url("https://node.gitlawb.com/localhost")); + assert!(!is_loopback_url("not a url")); + } + + #[test] + fn alias_shadowing_silent_without_signals() { + let home = fake_home(Some("plugins=(z)\nexport EDITOR=vim\n"), false); + assert!(check_shell_alias_shadowing(Some(home.path().to_path_buf())).is_none()); + let no_rc = fake_home(None, false); + assert!(check_shell_alias_shadowing(Some(no_rc.path().to_path_buf())).is_none()); + assert!(check_shell_alias_shadowing(None).is_none()); + } + #[test] fn test_is_newer_minor_bump() { assert!(is_newer("0.2.0", "0.1.0")); diff --git a/crates/gl/src/init.rs b/crates/gl/src/init.rs index d187c128..1bc3c406 100644 --- a/crates/gl/src/init.rs +++ b/crates/gl/src/init.rs @@ -38,13 +38,23 @@ pub async fn run(args: InitArgs) -> Result<()> { let git_dir = cwd.join(".git"); if !git_dir.exists() { println!("Initializing git repository..."); + // -b main: the push flow targets `main`; without it a fresh repo uses + // the user's init.defaultBranch (often `master`). Older git (<2.28) + // lacks the flag, so fall back to a plain init. let status = std::process::Command::new("git") - .args(["init"]) + .args(["init", "-b", "main"]) .current_dir(&cwd) .status() .context("failed to run git init")?; if !status.success() { - anyhow::bail!("git init failed"); + let status = std::process::Command::new("git") + .args(["init"]) + .current_dir(&cwd) + .status() + .context("failed to run git init")?; + if !status.success() { + anyhow::bail!("git init failed"); + } } } else { println!("Git repository detected."); @@ -165,9 +175,47 @@ pub async fn run(args: InitArgs) -> Result<()> { } } + // The hint must match the repo's actual state: with no commits yet, a bare + // `git push` fails with "src refspec ... does not match any" — exactly the + // trap a zero-to-push command must not set. + let has_commits = std::process::Command::new("git") + .args(["rev-parse", "--verify", "HEAD"]) + .current_dir(&cwd) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .status() + .map(|s| s.success()) + .unwrap_or(false); + // symbolic-ref succeeds on a branch (including an unborn one) and fails + // on a detached HEAD — where guessing "main" could push the wrong ref. + let branch = std::process::Command::new("git") + .args(["symbolic-ref", "--short", "HEAD"]) + .current_dir(&cwd) + .stderr(std::process::Stdio::null()) + .output() + .ok() + .filter(|o| o.status.success()) + .and_then(|o| String::from_utf8(o.stdout).ok()) + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()); + println!(); - println!("Ready! Push with:"); - println!(" git push gitlawb main"); + match (branch, has_commits) { + (Some(branch), true) => { + println!("Ready! Push with:"); + println!(" git push gitlawb {branch}"); + } + (Some(branch), false) => { + println!("Ready! Nothing is committed yet — commit, then push:"); + println!(" git add -A && git commit -m \"initial commit\""); + println!(" git push gitlawb {branch}"); + } + (None, _) => { + println!("Ready! HEAD is detached — create or switch to a branch, then push:"); + println!(" git switch -c main"); + println!(" git push gitlawb main"); + } + } Ok(()) } diff --git a/crates/gl/src/status.rs b/crates/gl/src/status.rs index 2c77adab..cf477cc6 100644 --- a/crates/gl/src/status.rs +++ b/crates/gl/src/status.rs @@ -59,7 +59,7 @@ pub async fn run(args: StatusArgs) -> Result<()> { println!(" repo {short_did}/{repo}"); } None => { - println!(" repo (not in a gitlawb repo — no gitlawb:// origin)"); + println!(" repo (not in a gitlawb repo — no gitlawb:// remote)"); } } @@ -143,29 +143,68 @@ fn trust_bar(score: f64) -> String { format!("{}{}", "█".repeat(filled), "░".repeat(empty)) } -/// Parse `gitlawb:///` from the git origin remote. +/// Find a `gitlawb:///` remote in the current repo. +/// +/// Checks `origin` first (clones), then `gitlawb` (the name `gl init` adds), +/// then any other remote with a gitlawb:// URL. fn detect_gitlawb_remote() -> Option<(String, String)> { let out = std::process::Command::new("git") - .args(["remote", "get-url", "origin"]) + .args(["remote"]) .stderr(std::process::Stdio::null()) .output() .ok()?; if !out.status.success() { return None; } - let url = String::from_utf8(out.stdout).ok()?; - let rest = url.trim().strip_prefix("gitlawb://")?; - let slash = rest.rfind('/')?; - let did = rest[..slash].to_string(); - let repo = rest[slash + 1..].to_string(); - if did.is_empty() || repo.is_empty() { - return None; + let names: Vec = String::from_utf8(out.stdout) + .ok()? + .lines() + .map(str::trim) + .filter(|l| !l.is_empty()) + .map(str::to_string) + .collect(); + + let preferred = ["origin", "gitlawb"]; + let ordered = preferred + .iter() + .filter(|p| names.iter().any(|n| n == *p)) + .map(|p| p.to_string()) + .chain( + names + .iter() + .filter(|n| !preferred.contains(&n.as_str())) + .cloned(), + ); + + for name in ordered { + // A remote can carry several URLs, and the gitlawb:// one may be a + // push URL behind an https fetch URL — check every fetch and push URL. + for extra in [&["--all"][..], &["--push", "--all"][..]] { + let mut args = vec!["remote", "get-url"]; + args.extend_from_slice(extra); + args.push(&name); + let Ok(out) = std::process::Command::new("git") + .args(&args) + .stderr(std::process::Stdio::null()) + .output() + else { + continue; + }; + if !out.status.success() { + continue; + } + let Ok(urls) = String::from_utf8(out.stdout) else { + continue; + }; + if let Some(parsed) = urls.lines().find_map(parse_gitlawb_url) { + return Some(parsed); + } + } } - Some((did, repo)) + None } -/// Parse a gitlawb:// URL string into (did, repo) — extracted for testing. -#[cfg(test)] +/// Parse a gitlawb:// URL string into (did, repo). fn parse_gitlawb_url(url: &str) -> Option<(String, String)> { let rest = url.trim().strip_prefix("gitlawb://")?; let slash = rest.rfind('/')?; diff --git a/install.sh b/install.sh index 80bd3601..416e7c9f 100644 --- a/install.sh +++ b/install.sh @@ -193,5 +193,19 @@ else echo " gl quickstart" fi +# oh-my-zsh's default git plugin aliases gl='git pull', which silently shadows +# the gl binary in every interactive zsh (aliases beat PATH). Detect the common +# setup and say so now, at install time, instead of letting the first `gl` +# print git's baffling "fatal: not a git repository". +if [ -f "$HOME/.zshrc" ] && [ -d "$HOME/.oh-my-zsh/plugins/git" ] \ + && grep -Eq '^[[:space:]]*plugins=\(([^)]*[[:space:]])?git([[:space:]][^)]*)?\)' "$HOME/.zshrc" \ + && ! grep -Eq '^([^#]*[[:space:];&|])?unalias[[:space:]]+([^;&|#]*[[:space:]])?gl([[:space:]]|$)' "$HOME/.zshrc"; then + echo "" + echo "NOTE: oh-my-zsh's git plugin aliases gl='git pull', which will shadow" + echo "the gl command in interactive shells. To use gl by name, run:" + echo "" + echo " echo 'unalias gl 2>/dev/null' >> ~/.zshrc && source ~/.zshrc" +fi + echo "" echo "Docs: https://docs.gitlawb.com"