commit a059ee55a266009dd09356d4d14820f11cbc2af2 Author: Stephen Waits Date: Thu Apr 30 00:57:05 2026 -0600 feat: redirect git invocations to jj inside jj repos fuckgit installs as a Cargo bin literally named `git` (via `[[bin]] name = "git"`). When invoked inside a jj worktree by an AI agent, it prints the equivalent `jj` subcommand plus the live `jj help ` output to stderr and exits 2 — the AI sees a clear failure and the right correction. Outside a jj repo, or with no intercept marker, it is a transparent passthrough. The intercept gate fires only when: - effective cwd (after honoring `git -C`) is inside a `.jj` worktree - FUCKGIT_INTERCEPT=1 or an AI marker (CLAUDECODE) is set - subcommand is not on the plumbing allowlist (rev-parse, cat-file, for-each-ref, ...) - GIT_DIR / GIT_EXEC_PATH / CARGO are not set - no top-level --git-dir= / --work-tree= flag is present Otherwise we walk PATH, skip self by canonicalized current_exe(), and exec() the first other `git` we find — preserving signals, exit code, and TTY ownership. Escape hatches: FUCKGIT_PASSTHROUGH=1 env var, --fucking-git flag (stripped before exec). FUCKGIT_DEPTH guard aborts at depth >= 2 to prevent self-recursion. Linux and macOS, zero runtime dependencies. diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ea8c4bf --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +/target diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..58ba9bc --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,28 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +## [0.1.0] - 2026-04-30 + +### Added + +- Initial release. +- Cargo binary literally named `git` (via `[[bin]] name = "git"`) that shadows the real `git` on `PATH`. +- Default-safe passthrough: outside a jj worktree, or with no intercept marker, every invocation is forwarded to the real `git` via `exec()` — preserving signals, exit code, and TTY ownership. +- Intercept gate fires only when inside a `.jj` worktree (honoring `git -C `) **and** either `FUCKGIT_INTERCEPT=1` or an AI-agent marker env (currently `CLAUDECODE`) is set. +- Redirect output: prints the matching `jj` subcommand plus live `jj help ` output to stderr; exits with status `2`. +- Mapping table for 27 common git subcommands (`status`, `log`, `commit`, `add`, `checkout`, `switch`, `restore`, `branch`, `merge`, `rebase`, `pull`, `push`, `fetch`, `clone`, `init`, `diff`, `show`, `reset`, `revert`, `cherry-pick`, `stash`, `tag`, `blame`, `remote`, `bisect`, `config`, `reflog`). +- Plumbing allowlist (`rev-parse`, `symbolic-ref`, `for-each-ref`, `cat-file`, `update-ref`, `hash-object`, `ls-files`, `ls-tree`, `merge-base`, `show-ref`, `name-rev`, `var`, `credential`, `check-ref-format`, `version`) for transparent passthrough so tooling keeps working. +- Safety env passthroughs: `GIT_DIR`, `GIT_EXEC_PATH` (hook context); `CARGO` (cargo subprocess). +- Top-level argv pre-pass handles `-C `, `-c `, `--git-dir[=]`, `--work-tree[=]`, `--namespace`, `--config-env`, `--exec-path`, `--paginate`/`--no-pager`, `--bare`, `--version`/`-v`, `--help`/`-h`. +- Escape hatches: `FUCKGIT_PASSTHROUGH=1` env var, `--fucking-git` flag (stripped before exec). +- Self-recursion guard via `FUCKGIT_DEPTH` env counter (aborts at depth ≥ 2). +- Linux and macOS support. Zero runtime dependencies. + +[Unreleased]: https://github.com/swaits/fuckgit/compare/v0.1.0...HEAD +[0.1.0]: https://github.com/swaits/fuckgit/releases/tag/v0.1.0 diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..3452b86 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,7 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "fuckgit" +version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..e2d76fe --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,24 @@ +[package] +name = "fuckgit" +version = "0.1.0" +edition = "2024" +rust-version = "1.88" +authors = ["Stephen Waits "] +description = "A binary named `git` that redirects you to `jj` (Jujutsu) when invoked inside a jj repo." +license = "MIT" +readme = "README.md" +repository = "https://github.com/swaits/fuckgit" +homepage = "https://github.com/swaits/fuckgit" +keywords = ["jj", "jujutsu", "git", "vcs"] +categories = ["command-line-utilities", "development-tools"] + +[[bin]] +name = "git" +path = "src/main.rs" + +[dependencies] + +[profile.release] +lto = "thin" +strip = true +codegen-units = 1 diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..1c20050 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Stephen Waits + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..0973ecd --- /dev/null +++ b/README.md @@ -0,0 +1,96 @@ +# fuckgit + +A binary literally named `git` that, when run inside a [jj][] (Jujutsu) repo, +refuses to actually run git and instead tells you to use `jj`. + +[jj]: https://github.com/jj-vcs/jj + +## Why + +AI coding agents (Claude Code, Cursor, et al.) keep running `git` inside +jj-managed repos despite being told to use `jj`. In a colocated jj+git +layout, that corrupts state. `fuckgit` shadows the real `git` on `PATH` +and traps these invocations, redirecting them with the equivalent `jj` +subcommand and inline `jj help ` output. + +It works on humans too, when they ask for it. + +## Install + +```sh +cargo install fuckgit +``` + +This installs a binary named **`git`** (yes, really) to `~/.cargo/bin/git`. +Make sure `~/.cargo/bin` is ahead of `/usr/bin` on `PATH`. + +Linux and macOS only. + +## Behavior + +By default, `fuckgit` is a silent passthrough — every `git` invocation is +forwarded to the real `git` on `PATH`. The redirect *only* fires when +**all** of these are true: + +1. You're inside a jj worktree (a `.jj` directory exists in the cwd or an + ancestor — honoring `git -C `). +2. The intercept gate is open — at least one of: + - `FUCKGIT_INTERCEPT=1` is set, OR + - an AI-agent marker env var is set (currently just `CLAUDECODE`). +3. The subcommand isn't on the plumbing allowlist (`rev-parse`, `cat-file`, + `for-each-ref`, …). +4. None of the safety env vars are set: `GIT_DIR`, `GIT_EXEC_PATH`, `CARGO`. +5. No top-level `--git-dir=` or `--work-tree=` flag is present. + +If any check fails, `git` behaves exactly as if `fuckgit` weren't installed. + +When the redirect *does* fire, you'll see something like: + +``` +Don't use git. Use jj. + + git status → jj st + jj has no staging area; `jj st` shows the working-copy commit's diff. + +--- jj help status --- +Show high-level repo status [default alias: st] +… + +Override with FUCKGIT_PASSTHROUGH=1 or git --fucking-git +``` + +…and exit `2`. + +## Escape hatches + +When you need real `git` and the intercept is firing: + +- `FUCKGIT_PASSTHROUGH=1 git ` — env var override +- `git --fucking-git ` — flag override (the flag is stripped before + exec'ing real git) + +## Configuration + +| Variable | Effect | +|-------------------------|------------------------------------------------------------------| +| `FUCKGIT_INTERCEPT=1` | Open the intercept gate (opt in for your own jj repos). | +| `FUCKGIT_PASSTHROUGH=1` | Force passthrough even with the gate open. | +| `CLAUDECODE` | Set by Claude Code; opens the gate automatically. | + +To extend the AI-marker list, edit `AI_MARKERS` in `src/main.rs` and rebuild. + +## Supported subcommands + +`fuckgit` knows the `jj` equivalent for these `git` subcommands: + +`status`, `log`, `commit`, `add`, `checkout`, `switch`, `restore`, `branch`, +`merge`, `rebase`, `pull`, `push`, `fetch`, `clone`, `init`, `diff`, `show`, +`reset`, `revert`, `cherry-pick`, `stash`, `tag`, `blame`, `remote`, +`bisect`, `config`, `reflog`. + +Unknown subcommands fall through to a generic "use jj instead" + `jj help` +output. + +## License + +MIT — see [LICENSE](LICENSE). diff --git a/src/argv.rs b/src/argv.rs new file mode 100644 index 0000000..007d75a --- /dev/null +++ b/src/argv.rs @@ -0,0 +1,163 @@ +use std::env; +use std::ffi::OsString; +use std::path::{Path, PathBuf}; + +pub struct ParsedArgv { + pub effective_cwd: PathBuf, + pub has_git_dir_or_work_tree: bool, + pub subcommand: Option, + pub is_version_query: bool, +} + +const BOOL_TOP_FLAGS: &[&str] = &[ + "-p", + "-P", + "--paginate", + "--no-pager", + "--bare", + "--no-replace-objects", + "--html-path", + "--man-path", + "--info-path", +]; + +pub fn parse_top_level(argv: &[OsString]) -> ParsedArgv { + let mut effective_cwd = env::current_dir().unwrap_or_else(|_| PathBuf::from(".")); + let mut has_git_dir_or_work_tree = false; + let mut subcommand: Option = None; + let mut is_version_query = false; + + let mut i = 0; + while i < argv.len() { + let arg = &argv[i]; + + if arg == "-C" { + if let Some(p) = argv.get(i + 1) { + effective_cwd = effective_cwd.join(Path::new(p)); + i += 2; + continue; + } + break; + } + + if arg == "-c" { + i += 2; + continue; + } + + if arg == "--git-dir" || arg == "--work-tree" { + has_git_dir_or_work_tree = true; + i += 2; + continue; + } + + let Some(s) = arg.to_str() else { break }; + + if s.starts_with("--git-dir=") || s.starts_with("--work-tree=") { + has_git_dir_or_work_tree = true; + i += 1; + continue; + } + + if s.starts_with("--namespace=") + || s.starts_with("--config-env=") + || s.starts_with("--exec-path=") + || s == "--exec-path" + { + i += 1; + continue; + } + + if BOOL_TOP_FLAGS.contains(&s) { + i += 1; + continue; + } + + if s == "--version" || s == "-v" { + is_version_query = true; + i += 1; + continue; + } + + if s == "--help" || s == "-h" { + subcommand = Some("help".to_string()); + break; + } + + if !s.starts_with('-') { + subcommand = Some(s.to_string()); + break; + } + + // Unknown leading flag — bail out; it presumably belongs to a subcommand + // we couldn't identify. + break; + } + + ParsedArgv { + effective_cwd, + has_git_dir_or_work_tree, + subcommand, + is_version_query, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn parse(args: &[&str]) -> ParsedArgv { + let owned: Vec = args.iter().map(OsString::from).collect(); + parse_top_level(&owned) + } + + #[test] + fn bare_subcommand() { + let p = parse(&["status"]); + assert_eq!(p.subcommand.as_deref(), Some("status")); + assert!(!p.is_version_query); + assert!(!p.has_git_dir_or_work_tree); + } + + #[test] + fn version_query() { + let p = parse(&["--version"]); + assert!(p.is_version_query); + let p = parse(&["-v"]); + assert!(p.is_version_query); + } + + #[test] + fn dash_capital_c_changes_cwd() { + let p = parse(&["-C", "/tmp", "status"]); + assert_eq!(p.subcommand.as_deref(), Some("status")); + assert_eq!(p.effective_cwd, PathBuf::from("/tmp")); + } + + #[test] + fn git_dir_forces_passthrough_flag() { + let p = parse(&["--git-dir=/foo", "status"]); + assert!(p.has_git_dir_or_work_tree); + + let p = parse(&["--work-tree", "/foo", "status"]); + assert!(p.has_git_dir_or_work_tree); + } + + #[test] + fn config_kv_consumes_two_args() { + let p = parse(&["-c", "user.name=Foo", "log"]); + assert_eq!(p.subcommand.as_deref(), Some("log")); + } + + #[test] + fn paginate_skipped() { + let p = parse(&["--paginate", "log"]); + assert_eq!(p.subcommand.as_deref(), Some("log")); + } + + #[test] + fn help_short_and_long() { + assert_eq!(parse(&["--help"]).subcommand.as_deref(), Some("help")); + assert_eq!(parse(&["-h"]).subcommand.as_deref(), Some("help")); + } +} diff --git a/src/detect.rs b/src/detect.rs new file mode 100644 index 0000000..502d2b1 --- /dev/null +++ b/src/detect.rs @@ -0,0 +1,54 @@ +use std::path::Path; + +pub fn in_jj_repo(start: &Path) -> bool { + let mut cur = Some(start); + while let Some(dir) = cur { + if dir.join(".jj").is_dir() { + return true; + } + cur = dir.parent(); + } + false +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + use std::path::PathBuf; + + fn tmp(name: &str) -> PathBuf { + let p = std::env::temp_dir().join(format!( + "fuckgit-detect-{}-{name}", + std::process::id() + )); + let _ = fs::remove_dir_all(&p); + fs::create_dir_all(&p).unwrap(); + p + } + + #[test] + fn finds_jj_in_self() { + let root = tmp("self"); + fs::create_dir_all(root.join(".jj")).unwrap(); + assert!(in_jj_repo(&root)); + fs::remove_dir_all(&root).unwrap(); + } + + #[test] + fn finds_jj_in_parent() { + let root = tmp("parent"); + fs::create_dir_all(root.join(".jj")).unwrap(); + let nested = root.join("a/b/c"); + fs::create_dir_all(&nested).unwrap(); + assert!(in_jj_repo(&nested)); + fs::remove_dir_all(&root).unwrap(); + } + + #[test] + fn no_jj_returns_false() { + let root = tmp("nojj"); + assert!(!in_jj_repo(&root)); + fs::remove_dir_all(&root).unwrap(); + } +} diff --git a/src/educate.rs b/src/educate.rs new file mode 100644 index 0000000..bda61be --- /dev/null +++ b/src/educate.rs @@ -0,0 +1,67 @@ +use std::io::{self, IsTerminal, Write}; +use std::process::Command; + +use crate::mapping; + +pub fn redirect(subcommand: Option<&str>) { + let stderr = io::stderr(); + let use_color = stderr.is_terminal(); + let mut h = stderr.lock(); + + let red = if use_color { "\x1b[1;31m" } else { "" }; + let bold = if use_color { "\x1b[1m" } else { "" }; + let dim = if use_color { "\x1b[2m" } else { "" }; + let reset = if use_color { "\x1b[0m" } else { "" }; + + let _ = writeln!(h, "{red}Don't use git. Use jj.{reset}"); + let _ = writeln!(h); + + match subcommand.and_then(mapping::lookup) { + Some(m) => { + let _ = writeln!( + h, + " {bold}git {sub}{reset} → {bold}{jj}{reset}", + sub = m.git_subcommand, + jj = m.jj_equivalent, + ); + if !m.note.is_empty() { + let _ = writeln!(h, " {dim}{note}{reset}", note = m.note); + } + + if let Some(help_sub) = m.help_subcommand + && let Ok(out) = Command::new("jj").args(["help", help_sub]).output() + && out.status.success() + { + let _ = writeln!(h); + let _ = writeln!(h, "{dim}--- jj help {help_sub} ---{reset}"); + let _ = h.write_all(&out.stdout); + } + } + None => { + match subcommand { + Some(sub) if !sub.is_empty() => { + let _ = writeln!( + h, + " {bold}git {sub}{reset} has no listed jj equivalent.", + ); + } + _ => { + let _ = writeln!(h, " Run {bold}jj help{reset} to see what jj offers."); + } + } + if let Ok(out) = Command::new("jj").arg("help").output() + && out.status.success() + { + let _ = writeln!(h); + let _ = writeln!(h, "{dim}--- jj help ---{reset}"); + let _ = h.write_all(&out.stdout); + } + } + } + + let _ = writeln!(h); + let _ = writeln!( + h, + "{dim}Override with FUCKGIT_PASSTHROUGH=1 or git --fucking-git {reset}" + ); +} diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..27ba874 --- /dev/null +++ b/src/main.rs @@ -0,0 +1,101 @@ +#[cfg(not(unix))] +compile_error!("fuckgit currently supports only Linux and macOS"); + +mod argv; +mod detect; +mod educate; +mod mapping; +mod passthrough; + +use std::env; +use std::ffi::{OsStr, OsString}; +use std::process; + +const AI_MARKERS: &[&str] = &["CLAUDECODE"]; + +const PLUMBING: &[&str] = &[ + "rev-parse", + "symbolic-ref", + "for-each-ref", + "cat-file", + "update-ref", + "hash-object", + "ls-files", + "ls-tree", + "merge-base", + "show-ref", + "name-rev", + "var", + "credential", + "check-ref-format", + "version", +]; + +fn main() -> ! { + bump_recursion_guard_or_die(); + + let mut argv: Vec = env::args_os().skip(1).collect(); + let had_force_flag = strip_flag(&mut argv, "--fucking-git"); + + if had_force_flag + || env_is_one("FUCKGIT_PASSTHROUGH") + || env::var_os("GIT_DIR").is_some() + || env::var_os("GIT_EXEC_PATH").is_some() + || env::var_os("CARGO").is_some() + { + passthrough::exec_real_git(&argv); + } + + let parsed = argv::parse_top_level(&argv); + + if parsed.has_git_dir_or_work_tree || parsed.is_version_query { + passthrough::exec_real_git(&argv); + } + + if let Some(sub) = parsed.subcommand.as_deref() + && PLUMBING.contains(&sub) + { + passthrough::exec_real_git(&argv); + } + + if !detect::in_jj_repo(&parsed.effective_cwd) { + passthrough::exec_real_git(&argv); + } + + if !intercept_gate_open() { + passthrough::exec_real_git(&argv); + } + + educate::redirect(parsed.subcommand.as_deref()); + process::exit(2); +} + +fn env_is_one(key: &str) -> bool { + env::var_os(key).as_deref() == Some(OsStr::new("1")) +} + +fn intercept_gate_open() -> bool { + if env_is_one("FUCKGIT_INTERCEPT") { + return true; + } + AI_MARKERS.iter().any(|k| env::var_os(k).is_some()) +} + +fn strip_flag(argv: &mut Vec, flag: &str) -> bool { + let before = argv.len(); + argv.retain(|a| a != flag); + argv.len() != before +} + +fn bump_recursion_guard_or_die() { + let depth: u32 = env::var("FUCKGIT_DEPTH") + .ok() + .and_then(|s| s.parse().ok()) + .unwrap_or(0); + if depth >= 2 { + eprintln!("fuckgit: recursion detected (depth={depth}); aborting"); + process::exit(70); + } + // SAFETY: single-threaded at startup; child inherits the bumped value. + unsafe { env::set_var("FUCKGIT_DEPTH", (depth + 1).to_string()) }; +} diff --git a/src/mapping.rs b/src/mapping.rs new file mode 100644 index 0000000..dff76ab --- /dev/null +++ b/src/mapping.rs @@ -0,0 +1,175 @@ +pub struct Mapping { + pub git_subcommand: &'static str, + pub jj_equivalent: &'static str, + pub help_subcommand: Option<&'static str>, + pub note: &'static str, +} + +pub static MAPPINGS: &[Mapping] = &[ + Mapping { + git_subcommand: "status", + jj_equivalent: "jj st", + help_subcommand: Some("status"), + note: "jj has no staging area; `jj st` shows the working-copy commit's diff.", + }, + Mapping { + git_subcommand: "log", + jj_equivalent: "jj log", + help_subcommand: Some("log"), + note: "Default revset is the trunk-and-mutable view, not all-history.", + }, + Mapping { + git_subcommand: "commit", + jj_equivalent: "jj commit (or `jj describe` + `jj new`)", + help_subcommand: Some("commit"), + note: "jj auto-snapshots; `describe` sets @'s message, `new` starts the next change.", + }, + Mapping { + git_subcommand: "add", + jj_equivalent: "(no equivalent — jj auto-snapshots)", + help_subcommand: None, + note: "jj tracks the working copy automatically. Edit `.gitignore` to untrack.", + }, + Mapping { + git_subcommand: "checkout", + jj_equivalent: "jj edit / jj new", + help_subcommand: Some("edit"), + note: "`jj edit ` to amend in place; `jj new ` to start a child change.", + }, + Mapping { + git_subcommand: "switch", + jj_equivalent: "jj edit / jj new", + help_subcommand: Some("edit"), + note: "Same options as the checkout replacement.", + }, + Mapping { + git_subcommand: "restore", + jj_equivalent: "jj restore", + help_subcommand: Some("restore"), + note: "", + }, + Mapping { + git_subcommand: "branch", + jj_equivalent: "jj bookmark", + help_subcommand: Some("bookmark"), + note: "jj calls them bookmarks. They don't auto-move with new commits.", + }, + Mapping { + git_subcommand: "merge", + jj_equivalent: "jj new ", + help_subcommand: Some("new"), + note: "jj merges by creating a multi-parent change — no merge command.", + }, + Mapping { + git_subcommand: "rebase", + jj_equivalent: "jj rebase", + help_subcommand: Some("rebase"), + note: "Rebases entire change graphs in one go; conflicts are first-class.", + }, + Mapping { + git_subcommand: "pull", + jj_equivalent: "jj git fetch (then `jj rebase` if needed)", + help_subcommand: Some("git"), + note: "jj does not auto-merge on fetch.", + }, + Mapping { + git_subcommand: "push", + jj_equivalent: "jj git push", + help_subcommand: Some("git"), + note: "Push bookmarks to the colocated git remote.", + }, + Mapping { + git_subcommand: "fetch", + jj_equivalent: "jj git fetch", + help_subcommand: Some("git"), + note: "", + }, + Mapping { + git_subcommand: "clone", + jj_equivalent: "jj git clone", + help_subcommand: Some("git"), + note: "", + }, + Mapping { + git_subcommand: "init", + jj_equivalent: "jj git init --colocate", + help_subcommand: Some("git"), + note: "Use --colocate to keep a working .git alongside .jj.", + }, + Mapping { + git_subcommand: "diff", + jj_equivalent: "jj diff", + help_subcommand: Some("diff"), + note: "Default compares @ to its parent.", + }, + Mapping { + git_subcommand: "show", + jj_equivalent: "jj show", + help_subcommand: Some("show"), + note: "", + }, + Mapping { + git_subcommand: "reset", + jj_equivalent: "jj abandon / jj restore", + help_subcommand: Some("abandon"), + note: "`abandon` drops a change; `restore` reverts file content from another rev.", + }, + Mapping { + git_subcommand: "revert", + jj_equivalent: "jj backout", + help_subcommand: Some("backout"), + note: "", + }, + Mapping { + git_subcommand: "cherry-pick", + jj_equivalent: "jj duplicate / jj rebase -r", + help_subcommand: Some("duplicate"), + note: "", + }, + Mapping { + git_subcommand: "stash", + jj_equivalent: "(not needed — just `jj new`)", + help_subcommand: None, + note: "Prior change stays in the graph as its own commit.", + }, + Mapping { + git_subcommand: "tag", + jj_equivalent: "jj bookmark / fall back to `jj git`", + help_subcommand: None, + note: "jj has no native tag concept; use bookmarks or operate via the git backend.", + }, + Mapping { + git_subcommand: "blame", + jj_equivalent: "jj file annotate", + help_subcommand: Some("file"), + note: "", + }, + Mapping { + git_subcommand: "remote", + jj_equivalent: "jj git remote", + help_subcommand: Some("git"), + note: "", + }, + Mapping { + git_subcommand: "bisect", + jj_equivalent: "(no built-in — use `jj log` + `jj edit`)", + help_subcommand: None, + note: "", + }, + Mapping { + git_subcommand: "config", + jj_equivalent: "jj config", + help_subcommand: Some("config"), + note: "Different schema; see `jj config list`.", + }, + Mapping { + git_subcommand: "reflog", + jj_equivalent: "jj op log", + help_subcommand: Some("operation"), + note: "Captures full repo state per op, not just HEAD.", + }, +]; + +pub fn lookup(sub: &str) -> Option<&'static Mapping> { + MAPPINGS.iter().find(|m| m.git_subcommand == sub) +} diff --git a/src/passthrough.rs b/src/passthrough.rs new file mode 100644 index 0000000..598bfd1 --- /dev/null +++ b/src/passthrough.rs @@ -0,0 +1,44 @@ +use std::env; +use std::ffi::OsString; +use std::os::unix::fs::PermissionsExt; +use std::os::unix::process::CommandExt; +use std::path::{Path, PathBuf}; +use std::process::{self, Command}; + +pub fn exec_real_git(argv: &[OsString]) -> ! { + let real = find_real_git().unwrap_or_else(|| { + eprintln!("fuckgit: no real `git` found on PATH (other than self)"); + process::exit(127); + }); + let err = Command::new(&real).args(argv).exec(); + eprintln!("fuckgit: failed to exec {}: {err}", real.display()); + process::exit(127); +} + +fn find_real_git() -> Option { + let me = env::current_exe().ok().and_then(|p| p.canonicalize().ok()); + let path = env::var_os("PATH")?; + for dir in env::split_paths(&path) { + let candidate = dir.join("git"); + if !candidate.is_file() { + continue; + } + if !is_executable(&candidate) { + continue; + } + if let Some(canon) = candidate.canonicalize().ok() + && let Some(me) = &me + && &canon == me + { + continue; + } + return Some(candidate); + } + None +} + +fn is_executable(path: &Path) -> bool { + path.metadata() + .map(|m| m.permissions().mode() & 0o111 != 0) + .unwrap_or(false) +}