@effected/git
v0.12.0
Published
Typed git introspection over Effect's ChildProcessSpawner: read a repository's state at any ref without checking it out, plus a marked mutating tier.
Maintainers
Readme
@effected/git
Typed git introspection as an Effect service. A read tier answers the questions monorepo tooling actually asks — Git.show reads a file's content at any ref without checking it out, Git.nameStatus types each changed path as added, renamed, deleted and so on, Git.workingChanges gathers the full working-tree delta, Git.commitInfo returns a commit's sha, signature verdict and raw message — and a clearly-marked mutating tier (checkout, fetch, the submodule pair, sparseCheckoutSet, configSet, add) changes repository state on purpose. Subprocesses run through Effect core's ChildProcessSpawner contract, required in R and provided once at your application's edge, so this package has zero runtime dependencies and zero node: imports.
Pre-release. This package is part of the
@effected/*kit, in pre-1.0.0development against a single pinned Effect v4 prerelease. Packages graduate to1.0.0once Effect4.0.0ships. To hold your owneffectversions at exactly the ones the kit is built and tested against, install@effected/pnpm-plugin-effect.Stability: unstable. This package's API surface is not yet considered complete and may change across
0.xreleases. Pin an exact version — even a package marked stable before1.0.0can introduce a breaking change by accident, and an exact pin turns that into a type-check error rather than a runtime surprise. Full policy: release strategy.
Why @effected/git
Shelling out to git looks easy until you have to interpret the answers. git speaks through exit codes and stderr prose, and the prose changes with the question: an unknown ref, a directory that is not a repository, a path absent at a ref, and a genuinely failed command all come back as "non-zero exit plus a sentence". Code that string-matches stderr at every call site gets this wrong somewhere, eventually, in a different way each time.
This package reads git's exit codes and stderr in exactly one classification step and hands you typed answers instead: a path absent at a valid ref is Option.none from show (a fact about the ref, not an error), a ref that does not resolve is false from refExists and a typed UnknownRefError elsewhere, a directory outside any work tree is NotARepositoryError, and everything else is a GitCommandError carrying the exit code and stderr intact. Spawn-level platform failures and the 30-second per-run ceiling are absorbed into that same taxonomy — no PlatformError and no timeout defect ever leaks from a Git method. Every command pins LC_ALL=C, so the classification is stable across locales, and tree listings use NUL-terminated output, so a path containing a space — or a newline — survives parsing.
GitCommand is exported alongside the service: 24 pure constructors producing Effect core Command values you can inspect, log, or test against without spawning anything.
Install
npm install @effected/git effectpnpm add @effected/git effectRequires Node.js >=24.11.0. effect v4 is a peer dependency, and it is the only one — git has no runtime dependencies of its own.
All @effected/* packages are ESM-only: the exports maps publish only import conditions, so require() — including tools that resolve in CJS mode — fails with Node's ERR_PACKAGE_PATH_NOT_EXPORTED rather than loading a CJS build that does not exist. Import from an ES module.
The subprocess spawner comes from Effect core's ChildProcessSpawner contract, not from a platform package. A consumer provides it once at the edge — NodeServices.layer from @effect/platform-node on Node — and a test provides a scripted spawner built with ChildProcessSpawner.make, no processes involved.
Quick start
Read a file at a ref, list what changed, and probe a branch — all without touching the working tree:
import { Git } from "@effected/git";
import { NodeServices } from "@effect/platform-node";
import { Effect, Layer, Option } from "effect";
const program = Effect.gen(function* () {
const git = yield* Git;
const manifest = yield* git.show("/repo", "v1.2.0", "package.json");
const changed = yield* git.changedFiles("/repo", { base: "main", head: "HEAD" });
const released = yield* git.refExists("/repo", "refs/tags/v1.2.0");
return { manifest: Option.getOrNull(manifest), changed, released };
});
const GitLive = Git.layer.pipe(Layer.provide(NodeServices.layer));
Effect.runPromise(program.pipe(Effect.provide(GitLive))).then(console.log);
// { manifest: "…the package.json as it was at v1.2.0…", changed: ["src/index.ts"], released: true }The error channel tells you what can actually happen — and show on a path that did not exist at the ref is not one of those things:
import { Git, NotARepositoryError, UnknownRefError } from "@effected/git";
import { Effect, Option } from "effect";
const contentAt = (cwd: string, ref: string, path: string) =>
Effect.gen(function* () {
const git = yield* Git;
return yield* git.show(cwd, ref, path);
}).pipe(
Effect.catchTag("UnknownRefError", () => Effect.succeed(Option.none<string>())),
Effect.catchTag("NotARepositoryError", (e) => Effect.die(e)),
);
// Effect<Option<string>, GitCommandError, Git> — absent-at-ref was already Option.none, no catch neededFeatures
Twenty-six service methods: eighteen that read repository state and eight that mutate it, all funneled through the same one-step classification.
- Content and trees:
Git.show(cwd, ref, path)— file content at a ref,Option.nonewhen the path is absent there — andGit.lsTree(cwd, ref)with an optional pathspec, returning typedLsTreeEntryvalues (mode, type, oid, path), NUL-parsed. - Diffs:
Git.changedFiles(cwd, { base, head })— paths changed across a range — andGit.nameStatus, which types each change as added, modified, deleted, renamed, copied and more, carriesoldPathon renames, and takes either abase...headrange or the working tree versus a single ref. - Working tree:
Git.unstagedChanges,Git.stagedChanges,Git.untrackedFilesandGit.workingChanges(their deduplicated union), plusGit.statusas typed porcelainStatusEntryvalues. - Probes:
Git.refExists(true/false, includingfalsefor refs that do not resolve at all),Git.mergeBaseandGit.revParse(resolved SHAs),Git.repoRoot, and theOption-answeringGit.defaultBranch(unset remote HEAD →Option.none, remote prefix stripped),Git.currentBranch(detached HEAD →Option.none),Git.configGetandGit.remoteUrl. - Configuration reads:
Git.configGet,Git.configGetAllandGit.configListtake an optionalscope("local" | "global" | "system" | "worktree", theGitConfigScopetype). Omitted still means the MERGED read — the value git itself would use, which includes whatever the machine's~/.gitconfigsets — so pass{ scope: "local" }to ask what this checkout alone declares.configListacceptsfileorscope, never both: git takes one source. - Commits:
Git.commitInfo(cwd, ref?)— a typedCommitInfowith the sha, the%G?signature verdict and the raw, untrimmed message. - History:
Git.log(cwd, { paths?, follow?, limit?, firstParentDiffMerges? })— the commit walk as typedCommitLogEntryvalues, each carrying the sha, both dates decoded toDateTime.Utc, the author identity and the paths that commit touched. Scope it with a pathspec, walk a single path across renames withfollow: true, and give merge commits a path listing withfirstParentDiffMerges. An unbornHEADand a pathspec no commit touched are both the empty listing, never a failure. - Mutating tier, each method marked as such:
Git.checkout(with a detach option),Git.fetch(remote, ref, depth, tag),Git.fetchAny(tries the tag form first, falls back to the plain form onUnknownRefErrororGitCommandError),Git.submoduleUpdate,Git.submoduleAdd,Git.sparseCheckoutSet(explicit cone flag),Git.configSet(the write is repository-local, always: a baregit configwrites the checkout's own.git/config, and no global or system scope is offered — a write has no defensible "effective value" default the way a read does) andGit.add. Nothing here serializes concurrent access — the caller owns that, per working tree. GitCommand.*— all 24 invocations as pure, inspectableCommandvalues.- Errors:
GitCommandError,NotARepositoryError,UnknownRefError— classification happens once, inside the service. A ref the remote does not have surfaces asUnknownRefErrortoo, the typed signal a tag-then-branch fetch fallback branches on withEffect.orElse.
Need a git command this package does not have?
Git's scope is closed by its consumers, not by git's porcelain, so it will
always be missing something. When you hit that, do not copy this package's
private src/internal/run.ts — reach for
@effected/commands' Run.collect, which is the public,
maintained, bounded version of the same "spawn one command and collect
stdout/stderr/exit-code concurrently under one scope" discipline:
import { Run } from "@effected/commands";
import { ChildProcess } from "effect/unstable/process";
const shortlog = ChildProcess.make("git", ["shortlog", "-sn", "HEAD"], {
// The same two pins Git makes on every invocation: LC_ALL=C keeps stderr
// classifiable, extendEnv keeps PATH.
env: { LC_ALL: "C" },
extendEnv: true,
}).pipe((command) => ChildProcess.setCwd(command, cwd));
const output = yield* Run.collect(shortlog);
// output.stdout / output.stderr / output.exitCode — a non-zero exit is DATA
// here, not an error, which is what lets you classify stderr the way Git does.Run.collect gets you three things the hand-rolled copy will not: the
{ concurrency: "unbounded" } triple-collect that keeps a full OS pipe buffer
from deadlocking the run, a 16 MiB per-stream capture ceiling instead of
unbounded memory, and redaction of declared secrets out of both the captured
output and any error it raises. @effected/commands is a boundary package with
effect as its only peer, so taking that edge costs a consumer nothing beyond
the ChildProcessSpawner layer it is already providing to Git.
Git itself deliberately does not take that edge — see
@effected/git's design doc for why — so the two implementations are parallel
by design. The one to build new code on is Run.collect.
