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:
12 files changed
+781
No files matched your search
@@ -0,0 +1 @@
|
|||||||
|
/target
|
||||||
@@ -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
@@ -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
@@ -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
|
||||||
@@ -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.
|
||||||
@@ -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
@@ -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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -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
@@ -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)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
Reference in new issue
Block a user