@rhombus.rocks/fngit
v2.0.0
Published
Locate git repos from short references and clone them the configured way — a library plus a decorator over the git CLI.
Readme
@rhombus.rocks/fngit
A library for locating git repos from short references, plus fngit — a CLI
that decorates git with that lookup.
The library resolves a user-typed reference (<name>, <name>@<owner>,
<owner>/<name>, gh:<owner>/<name>, a full URL, +workspace suffixes and
all) against your existing local clones and, failing that, your GitHub orgs —
returning either the local path or the clone URL, optionally cloning it for
you. fngit clone <ref> runs that resolution and clones the repo to its
configured destination; every other git subcommand and argument passes
straight through unchanged.
Install
npm install @rhombus.rocks/fngitAPI
locate
import { locate } from '@rhombus.rocks/fngit';
const repo = await locate('fnclaude', { clone: true });
repo.path; // the checkout on disklocate(input, options) resolves to a Located, or rejects with a
LocateError.
type Located = { type: 'local'; path: string; ref: RepoRef; } | {
type: 'remote';
url: string;
destination: string;
ref: RepoRef;
};local means a checkout already exists at path. remote means it doesn't:
url is where it would be fetched from and destination where it would land.
With clone: true a remote result is cloned first, so the call always
resolves local.
ref is the parsed reference — host, owner, name, workspace,
original. Its workspace is carried through untouched and never affects the
destination; a +workspace suffix names a worktree beside the clone, not a
different clone. When a bare name is settled by a clone on disk, owner is
filled in from the owner segment the scan recovered — the one exception is a
bare name matched at <dir>/<name> in an extra source root, which carries no
owner in its path, so owner stays empty there.
Options
| Field | Default | Meaning |
| ----------- | --------------------- | -------------------------------------------------------------------------- |
| clone | false | Clone a remote result before returning, so the result is always local. |
| settings | none | Per-field overlay on whatever the config file supplies. |
| home | os.homedir() | Root for ~ expansion and the config-file lookup. |
| gh | the real gh spawner | An IGitHubCli to call instead; inject a fake in tests. |
| cloneArgs | none | Extra arguments for git clone, honoured only alongside clone. |
Resolution order
- Parse the reference. Accepted forms are
<name>,<name>@<owner>,<owner>/<name>,gh:<owner>/<name>, anhttps:///http:///ssh://URL and thegit@host:owner/nameform, each with an optional+workspacesuffix. - Load the settings chain, then apply the
settingsoverlay field by field. A broken clone template — empty, an unknown placeholder, or a{host-short}with no alias — failsconfighere, before any disk scan or network call. - For a bare name, before any network call:
- scan the clone template with
{owner}wildcarded — wherever{owner}sits in it, at any depth — excluding worktree siblings, and recover each hit's owner segment onto the result; one hit resolveslocal, several are ambiguous; - search
additionalSrcDirs, each entry twice:<dir>/<name>(which carries no owner, so the result'sownerstays empty), then the clone template's last segment re-rooted there; - ask
ghwho owns it — the authenticated user first, then each organization in API order. Every candidate is probed, so a name two owners share is ambiguous rather than silently resolved.
- scan the clone template with
- With the owner known, compute the destination from
cloneTemplate. If it exists, that is thelocalresult. Otherwise searchadditionalSrcDirsonce more for that exact owner. Otherwise the result isremote. - With
clone: true, create the destination's parent and clone into it.
The clone template's root outranks additionalSrcDirs at every step, so a repo
present in both is never reported ambiguous. Those extra roots are search-only
and never become a clone destination.
LocateError
Every failure rejects with a LocateError carrying a structured failure, so
a caller can branch on the reason rather than parse the message.
| failure.reason | Raised when |
| ----------------- | --------------------------------------------------------------------------------------------------------------------- |
| unparseable | The input matches none of the accepted reference forms. |
| config | No cloneTemplate is configured, or expanding it failed — an unknown placeholder, or a {host-short} with no alias. |
| gh-failed | The owner lookup could not reach gh at all. |
| not-found | No owner reachable through gh has a repo by that name. |
| ambiguous-owner | Several owners have it; owners lists them. |
| ambiguous-local | Several checkouts on disk match; paths lists them. |
| clone-failed | The clone itself failed; repoNotFound says whether the repo simply doesn't exist. |
CLI
npm install -g @rhombus.rocks/fngitfngit decorates git clone: only clone is intercepted, and only when its
first argument is a bare reference — every other subcommand and argument
passes straight through to git unchanged.
fngit clone fnclaude # locate() resolves the ref, cloning if needed
fngit clone fnclaude --depth 1 # extra args forward to the underlying cloneDispatch rule
fngit clone <arg> … is decorated only when <arg>:
- doesn't start with
-(so a leading flag, e.g.fngit clone --depth 1 x, passes through — git owns its own flag parsing); - isn't a filesystem path (doesn't start with
.or/, e.g.fngit clone ./local-path); - parses as one of the reference forms above; and
- has no second positional after it (
fngit clone <ref> <dir>passes through — the user chose git's destination explicitly).
Anything else — fngit clone alone, two positionals, an unparseable or
path-like first argument — passes straight through to git clone. A
reference carrying a +workspace suffix is rejected (exit 2); worktree
support isn't implemented yet.
fngit clone somerepo ./some/path # two positionals — the user chose the
# destination, so this runs git unchangedUse it as git
fngit is safe to shadow your real git with, so every command you type
gets clone's lookup for free without changing how anything else behaves.
fngit install --shadow-git (see fngit install) sets this
up for you, via a git shim on PATH rather than a shell alias — do that
instead of the manual steps below unless you have a reason not to.
Alias it in your shell:
# bash / zsh
alias git=fngit
# fish
alias git fngit
# PowerShell
Set-Alias git fngitOr put a git symlink (Linux/macOS) or shim (Windows) ahead of the real
git on PATH. Either way, fngit always finds and runs the real git
underneath — it walks PATH itself, skipping any git that resolves back
into fngit's own install, so it never recurses into itself even when it is
the thing named git.
If your setup somehow still loops — git pointing at fngit, which finds
git on PATH and gets back to itself — fngit detects it and refuses to run
rather than recursing forever, exit 126. Set FNGIT_GIT to the real git
binary's path to sidestep the lookup entirely; it always wins outright and
skips the PATH walk.
Exit codes
| Code | Meaning |
| ------- | -------------------------------------------------------------------------------------------------------------------------------------------- |
| 0 | The clone resolved (or already existed); its path was printed to stdout. |
| 1 | locate() rejected with a LocateError; its message (and, for an ambiguous result, one candidate per line) went to stderr. |
| 2 | The reference carried a +workspace suffix, which fngit clone doesn't support yet; or fngit install was given an unrecognized argument. |
| 126 | fngit detected it would recurse into itself and refused to run — see Use it as git. |
| 127 | The real git isn't on PATH (set FNGIT_GIT to point at it directly). |
| other | A passed-through git invocation's own exit status. |
Everything that isn't a decorated clone is git, verbatim — same flags,
same stdout/stderr, same exit code, same behavior for a signal that kills it.
fngit install
fngit install — in every shape, including an unrecognized flag — is fngit's
own command, never passed through to git (a plain fngit install with no
git equivalent would otherwise just error out of git itself).
fngit install # interactive wizard
fngit install -y # never prompts — recommended values, or
# whatever's already configured, everywhere
fngit install -y --clone-template '~/src/{repo}@{owner}' \
--worktree-template '~/src/{repo}@{owner}+{input}' \
--additional-src-dirs '~/code,~/dev' \
--host-alias git.example.com=ex \
--no-plugin --shadow-git
fngit install --dry-run # print the plan, write nothing
fngit install --remove-shadow # remove the git shim and its PATH blocks| Flag | Meaning |
| -------------------------------------------------- | -------------------------------------------------------------------------------------------------------- |
| --clone-template <t> / --worktree-template <t> | Override those two templates. |
| --additional-src-dirs <a,b> | Comma-separated; repeatable, appending each time. |
| --host-alias <host>=<alias> | Repeatable. |
| --plugin / --no-plugin | Install the worktree-paths Claude Code plugin. |
| --shadow-git / --no-shadow-git | Put a git shim first on PATH (see below). |
| -y, --yes | Never prompts — every unanswered question takes its recommended value, or whatever's already configured. |
| --dry-run | Print the plan (describePlan) instead of writing anything. |
| --remove-shadow | Remove the shim and every PATH block it added, then exit. |
| --help | Print usage. |
With no flags and an interactive terminal, it prompts for whatever isn't
already configured, showing each recommended value as the default. Non-interactive
without -y and with an unanswered question is an error (exit 2).
Shadowing git via a PATH shim (--shadow-git): a git shim script
(exec fngit "$@" on POSIX, git.cmd → @fngit %* on Windows) is written to
$XDG_DATA_HOME/rhombus.rocks/fngit/shims (default
~/.local/share/rhombus.rocks/fngit/shims), and an idempotent marked block
prepending that directory to PATH is written to ~/.bashrc, ~/.zshrc, the
fish conf.d, and the PowerShell profile — whichever exist. resolveRealGit
skips that directory when searching PATH, so the shim never resolves to
itself. --remove-shadow undoes both.
The plugin (--plugin, on by default when claude is on PATH):
installs worktree-paths from the rhombus-rocks/claude-plugins marketplace,
detecting and swapping an old claude-code-worktree-paths@fnrhombus-plugins
install first. Re-running fngit install -y is a no-op wherever nothing
changed — settings, the shim, and the plugin state are each written only when
they'd actually change.
Driving it programmatically: fngit install is a thin CLI shell over a
pure plan builder, exported from the package so fnc install (or any other
caller) can drive the same logic without spawning a subprocess or prompting
anyone itself:
import { buildInstallPlan, type InstallAnswers,
type InstallEnv } from '@rhombus.rocks/fngit';
const plan = buildInstallPlan(answers, /* InstallAnswers */
env /* InstallEnv */);
// plan: readonly InstallAction[] — { kind, description, ...effect-specific fields }[]resolveInstallAnswers(options, env, prompter) turns InstallOptions (parsed
CLI flags) plus an IPrompter into InstallAnswers; buildInstallPlan
turns InstallAnswers plus InstallEnv (current settings, resolved config
path, shim dir, shadow targets, plugin state) into the ordered InstallAction[]
plan — write-settings, write-shim-script, write-shadow-block, sync-plugin —
each carrying enough to execute or describe it. Also exported: parseInstallArgs,
needsPrompting, currentHostAliasOverrides, describePlan, buildRemoveShadowPlan,
the RECOMMENDED_* defaults, and INSTALL_HELP.
Settings
fngit reads a shared config file at $XDG_CONFIG_HOME/rhombus.rocks/config.{json,jsonc,toml,yaml}
(default ~/.config/rhombus.rocks/config.json) — the first of those four
extensions that exists, in that order, parsed with
confbox. FNGIT_CONFIG=<path> overrides
the location outright, in whichever of those formats that path names.
{
"$schema": "https://json.schemastore.org/rhombus-rocks-config.json",
"repos": {
"cloneTemplate": "~/src/{repo}@{owner}",
"worktreeTemplate": "~/src/{repo}@{owner}+{input}",
"additionalSrcDirs": ["~/.local/src", "~/code"],
"hostAliases": { "git.example.com": "ex" }
}
}repos.cloneTemplate, repos.worktreeTemplate and repos.additionalSrcDirs
are read exactly as before (the last accepting a single path or a list).
repos.branchTemplate is read only by the worktree-paths Claude Code
plugin — fngit doesn't read it, but preserves it whenever it writes this file.
Every failure degrades silently: an unreadable, malformed, or wrong-shaped
file (or field) contributes nothing rather than failing the lookup.
Host aliases for the {host-short} placeholder ship with built-in
defaults — github.com=gh, gitlab.com=gl, bitbucket.org=bb,
codeberg.org=cb — overridden per-host by repos.hostAliases. A host with
neither a built-in default nor an override fails with an error naming
repos.hostAliases.
Migration: ~/.fngitrc (the older, flatter JSON shape — cloneTemplate,
worktreeTemplate, additionalSrcDirs, hostAliases at the top level, no
repos nesting) is read only when the new file is absent, and only by
locate()/fngit clone; fngit install moves its contents into the new
file. Once the new file exists, ~/.fngitrc is never consulted again.
Platforms
Linux, macOS, and Windows are supported. CI tests all three on every PR.
Templates are written with forward slashes (e.g. ~/src/{repo}@{owner});
expansion produces native paths via path.join/path.normalize. The config
file's location is computed with path.join, so it lands at the same
.config/rhombus.rocks/config.json-shaped path (native separators) on every
platform.
Development
Toolchain is pinned via mise — running any command inside the repo picks up the pinned Node and bun versions automatically.
bun install
bun run lint # typecheck + eslint
bun run test # bun test
bun run build # tsc emit to dist/
bun run format # dprint fmt
bun run format:check # dprint checkA pre-commit hook (.githooks/pre-commit, wired up automatically by mise on
directory entry) runs dprint check and bun run lint before every commit.
Release
Every PR merge to main runs the Release workflow's publish job:
semantic-release reads the conventional-commit history, determines the next
version, publishes it to npm under the @latest dist-tag with provenance,
and creates the GitHub release.
See CLAUDE.md for the full branch policy, TDD requirement, and commit
conventions this repo follows.
