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 <sub>` 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.
This commit is contained in:
swaits committed 2026-04-30 01:04:50 -06:00
commit 41d6951d6f
12 files changed
+781

No files matched your search

+1
View File
@@ -0,0 +1 @@
/target
+28
View File
@@ -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 <path>`) **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 <sub>` 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 <path>`, `-c <kv>`, `--git-dir[=<path>]`, `--work-tree[=<path>]`, `--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
Generated
+7
View File
@@ -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"
+24
View File
@@ -0,0 +1,24 @@
[package]
name = "fuckgit"
version = "0.1.0"
edition = "2024"
rust-version = "1.88"
authors = ["Stephen Waits <steve@waits.net>"]
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
+21
View File
@@ -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.
+96
View File
@@ -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 <subcmd>` 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 <path>`).
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 <args...>
```
…and exit `2`.
## Escape hatches
When you need real `git` and the intercept is firing:
- `FUCKGIT_PASSTHROUGH=1 git <args>` — env var override
- `git --fucking-git <args>` — 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).
+163
View File
@@ -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<String>,
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<String> = 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<OsString> = 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"));
}
}
+54
View File
@@ -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();
}
}
+67
View File
@@ -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 <args...>{reset}"
);
}
+101
View File
@@ -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<OsString> = 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<OsString>, 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()) };
}
+175
View File
@@ -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 <rev>` to amend in place; `jj new <rev>` 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 <rev1> <rev2>",
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)
}
+44
View File
@@ -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<PathBuf> {
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)
}