A clean Elixir wrapper for the git CLI. Provides a direct, idiomatic mapping
to git subcommands with fully parsed output structs and higher-level workflow
abstractions. Runs git through a leak-free runner by default that kills the
whole process group on timeout, and falls back to System.cmd/3 where that
runner is unavailable (see Execution and timeouts).
Installation
Add git to your dependencies in mix.exs:
def deps do
[
{:git, "~> 0.6"}
]
endQuick start
# Check repository status
{:ok, status} = Git.status()
status.branch #=> "main"
status.entries #=> [%{index: "M", working_tree: " ", path: "lib/foo.ex"}]
# Stage and commit
{:ok, :done} = Git.add(all: true)
{:ok, result} = Git.commit("feat: add new feature")
result.hash #=> "abc1234"
# Branch, push, pull
{:ok, _} = Git.checkout(branch: "feat/new", create: true)
{:ok, :done} = Git.push(remote: "origin", branch: "feat/new", set_upstream: true)
{:ok, _} = Git.pull(rebase: true, autostash: true)
# Inspect history
{:ok, commits} = Git.log(max_count: 5)
Enum.each(commits, fn c -> IO.puts("#{c.abbreviated_hash} #{c.subject}") end)Configuration
Pass a Git.Config struct via the :config option to control the working
directory, git binary path, environment variables, timeout, runner, and
per-invocation config:
config = Git.Config.new(
working_dir: "/path/to/repo",
timeout: 60_000,
runner: :forcola,
extra_config: [{"core.autocrlf", "false"}]
)
{:ok, status} = Git.status(config: config):runnerselects how git is executed::forcola(the default, leak-free),:system_cmd, or any module implementing theGit.Runnerbehaviour. See Execution and timeouts.:extra_configis a list of{key, value}pairs emitted asgit -c key=valueon every invocation, so you can override config without touching the repository's.git/config.
Identity, dates, and a scratch index
Author/committer identity, commit dates, and the index file are set through
:env and :extra_config (git reads them from the environment and
per-invocation config) rather than dedicated options:
# Set identity without mutating repo config
Git.Config.new(extra_config: [
{"user.name", "A U Thor"},
{"user.email", "author@example.com"}
])
# Pin author and committer dates (commit --date sets only the author date)
Git.Config.new(env: [
{"GIT_AUTHOR_DATE", "2020-01-01T00:00:00"},
{"GIT_COMMITTER_DATE", "2020-01-01T00:00:00"}
])
# Build a commit off to the side against a scratch index
Git.Config.new(env: [{"GIT_INDEX_FILE", "/tmp/scratch.index"}])Execution and timeouts
Every git command runs through a Git.Runner. The default,
Git.Runner.Forcola, runs git in its own process group and kills the whole
group (SIGTERM then SIGKILL) on timeout or BEAM death, so a {:error, :timeout}
means git is actually gone.
This matters because System.cmd/3 implements timeouts by closing the Erlang
port, which only closes the stdin/stdout pipes and never signals the git OS
process. Under that model a git command that times out, and anything git
spawned (ssh transports, credential helpers, hooks, sign helpers), can keep
running and hold repository locks such as .git/index.lock. The forcola runner
exists to close that gap, so it is the default.
forcola is an optional dependency. It is POSIX-only (macOS and Linux) and ships precompiled shim binaries, so no Rust toolchain is required on its supported targets. Add it to your dependencies to get the leak-free default:
# mix.exs
{:forcola, "~> 0.3"}When forcola is not installed, or on a platform it does not support (for
example Windows), the runner falls back to Git.Runner.SystemCmd, which uses
System.cmd/3 and works everywhere with no extra dependencies. To select it
explicitly:
config = Git.Config.new(runner: :system_cmd)
{:ok, status} = Git.status(config: config)The :runner value may also be any module implementing the Git.Runner
behaviour.
Commands
80 git commands with full option support and parsed output.
Core (snapshotting, branching, sharing)
| Function | git command | Returns |
|---|---|---|
status/1 | git status | Git.Status.t() |
log/1 | git log | [Git.Commit.t()] |
commit/2 | git commit | Git.CommitResult.t() |
add/1 | git add | :done |
branch/1 | git branch | [Git.Branch.t()] or :done |
checkout/1 | git checkout | Git.Checkout.t() or :done |
switch/1 | git switch | Git.Checkout.t() or :done |
restore/1 | git restore | :done |
diff/1 | git diff | Git.Diff.t() |
merge/2 | git merge | Git.MergeResult.t() or :done |
remote/1 | git remote | [Git.Remote.t()] or :done |
tag/1 | git tag | [Git.Tag.t()] or :done |
stash/1 | git stash | [Git.StashEntry.t()] or :done |
init/1 | git init | :done |
clone/2 | git clone | :done |
reset/1 | git reset | :done |
push/1 | git push | :done |
pull/1 | git pull | Git.PullResult.t() |
fetch/1 | git fetch | :done |
rebase/1 | git rebase | Git.RebaseResult.t() or :done |
cherry_pick/1 | git cherry-pick | Git.CherryPickResult.t() or :done |
revert/1 | git revert | Git.RevertResult.t() or :done |
submodule/1 | git submodule | varies |
notes/1 | git notes | varies |
clean/1 | git clean | [String.t()] |
mv/3 | git mv | :done |
rm/1 | git rm | :done |
Inspection and comparison
| Function | git command | Returns |
|---|---|---|
show/1 | git show | Git.ShowResult.t() |
rev_parse/1 | git rev-parse | String.t() |
blame/2 | git blame | [Git.BlameEntry.t()] |
grep/2 | git grep | [Git.GrepResult.t()] |
describe/1 | git describe | String.t() |
shortlog/1 | git shortlog | [Git.ShortlogEntry.t()] |
range_diff/1 | git range-diff | String.t() |
ls_files/1 | git ls-files | [String.t()] |
ls_remote/1 | git ls-remote | [Git.LsRemoteEntry.t()] |
ls_tree/1 | git ls-tree | [Git.TreeEntry.t()] |
version/1 | git version | Git.Version.t() |
count_objects/1 | git count-objects | Git.CountObjects.t() |
var/1 | git var | map() or String.t() |
name_rev/1 | git name-rev | String.t() |
check_ref_format/1 | git check-ref-format | true or String.t() |
Patching and email
| Function | git command | Returns |
|---|---|---|
apply_patch/1 | git apply | :done or String.t() |
am/1 | git am | :done |
format_patch/1 | git format-patch | [String.t()] or String.t() |
cherry/1 | git cherry | [Git.CherryEntry.t()] |
interpret_trailers/1 | git interpret-trailers | String.t() |
Administration and maintenance
| Function | git command | Returns |
|---|---|---|
gc/1 | git gc | :done |
fsck/1 | git fsck | [map()] |
reflog/1 | git reflog | [Git.ReflogEntry.t()] |
archive/1 | git archive | :done |
bundle/1 | git bundle | varies |
maintenance/1 | git maintenance | :done |
git_config/1 | git config | String.t() or [{k, v}] or :done |
bisect/1 | git bisect | Git.BisectResult.t() or :done |
worktree/1 | git worktree | [Git.Worktree.t()] or :done |
rerere/1 | git rerere | varies |
sparse_checkout/1 | git sparse-checkout | varies |
verify_commit/2 | git verify-commit | map() |
verify_tag/2 | git verify-tag | map() |
check_ignore/1 | git check-ignore | [String.t()] or [map()] |
check_attr/1 | git check-attr | [map()] |
Plumbing
| Function | git command | Returns |
|---|---|---|
cat_file/2 | git cat-file | varies |
commit_tree/1 | git commit-tree | String.t() |
write_tree/1 | git write-tree | String.t() |
read_tree/1 | git read-tree | :done |
update_index/1 | git update-index | :done |
mktree/1 | git mktree | String.t() |
merge_tree/3 | git merge-tree | Git.MergeTreeResult.t() |
merge_file/4 | git merge-file | non_neg_integer() |
diff_tree/1 | git diff-tree | [Git.DiffRawEntry.t()] |
diff_index/1 | git diff-index | [Git.DiffRawEntry.t()] or boolean() |
diff_files/1 | git diff-files | [Git.DiffRawEntry.t()] |
for_each_ref/1 | git for-each-ref | String.t() |
hash_object/1 | git hash-object | String.t() |
symbolic_ref/1 | git symbolic-ref | String.t() or :done |
update_ref/1 | git update-ref | :done |
rev_list/1 | git rev-list | [String.t()] or integer() |
merge_base/1 | git merge-base | String.t() or boolean() |
show_ref/1 | git show-ref | varies |
All functions return {:ok, result} on success or {:error, {stdout, exit_code}} on failure.
Higher-level modules
Git.Repo
Stateful repository struct for cleaner API usage:
{:ok, repo} = Git.Repo.open("/path/to/repo")
{:ok, status} = Git.Repo.status(repo)
{:ok, :done} = Git.Repo.add(repo, all: true)
{:ok, result} = Git.Repo.commit(repo, "feat: new feature")
{:ok, :done} = Git.Repo.push(repo)
# Pipeline helpers
Git.Repo.ok(repo)
|> Git.Repo.run(fn r ->
File.write!("new.txt", "content")
{:ok, :done} = Git.Repo.add(r, files: ["new.txt"])
{:ok, r}
end)
|> Git.Repo.run(fn r ->
{:ok, _} = Git.Repo.commit(r, "add file")
{:ok, r}
end)Git.Workflow
Composable multi-step workflows. Everyday commit helpers:
# Stage everything and commit in one call
Git.Workflow.commit_all("fix: patch bug", config: config)
# Amend the last commit; undo it (soft); squash the last N
Git.Workflow.amend(config: config)
Git.Workflow.undo_last_commit(config: config)
Git.Workflow.squash_last(3, "feat: combined change", config: config)
# Discard all uncommitted changes (tracked and untracked)
Git.Workflow.discard_all(config: config)Brackets and combinators (each threads :config and cleans up after itself):
# Feature branch with automatic merge + cleanup
Git.Workflow.feature_branch("feat/login", fn opts ->
File.write!("login.ex", "...")
{:ok, :done} = Git.add(Keyword.merge(opts, files: ["login.ex"]))
{:ok, _} = Git.commit("feat: add login", opts)
{:ok, :done}
end, merge: true, delete: true, config: config)
# Check out a ref, run a function, restore the original branch (even on raise)
Git.Workflow.with_branch("main", fn opts -> Git.log(opts) end, config: config)
# Stash, run a function, pop the stash
Git.Workflow.with_stash(fn opts -> Git.pull(opts) end, config: config)
# Run a runtime-built list of steps, short-circuiting on the first error
[
{:stage, fn o -> Git.add(Keyword.merge(o, all: true)) end},
{:commit, fn o -> Git.commit("chore: release", o) end}
]
|> Git.Workflow.chain(config: config)Integration, release, and collaboration:
# Sync with upstream (fetch + rebase, with autostash)
Git.Workflow.sync(config: config)
# Rebase / merge that rolls back cleanly on conflict
Git.Workflow.safe_rebase(config: config)
Git.Workflow.try_merge("feature-branch", config: config)
# Squash merge a branch
Git.Workflow.squash_merge("feature-branch", message: "feat: all the things", config: config)
# Cut a release, publish the current branch, sync a fork, backport commits
Git.Workflow.release("v1.2.0", config: config)
Git.Workflow.publish(config: config)
Git.Workflow.sync_fork(config: config)
Git.Workflow.backport(["abc1234"], config: config)
# Recreate a branch that was deleted (from the reflog)
Git.Workflow.restore_branch("feat/lost", config: config)Git.History
Query commit history:
{:ok, commits} = Git.History.commits_between("v1.0.0", "v2.0.0", config: config)
{:ok, changelog} = Git.History.changelog("v1.0.0", "v2.0.0", config: config)
# changelog.features, changelog.fixes, changelog.other
{:ok, contributors} = Git.History.contributors(path: "lib/", config: config)
{:ok, true} = Git.History.ancestor?("v1.0.0", "main", config: config)Git.Changes
Analyze repository changes:
{:ok, changes} = Git.Changes.between("HEAD~5", "HEAD", config: config)
# [%{status: :modified, path: "lib/foo.ex"}, %{status: :added, path: "lib/bar.ex"}]
{:ok, uncommitted} = Git.Changes.uncommitted(config: config)
# uncommitted.staged, uncommitted.modified, uncommitted.untracked
{:ok, conflicts} = Git.Changes.conflicts(config: config)Git.Info
Repository introspection:
{:ok, summary} = Git.Info.summary(config: config)
# summary.branch, summary.commit, summary.dirty, summary.ahead, summary.behind, ...
{:ok, true} = Git.Info.dirty?(config: config)
{:ok, head} = Git.Info.head(config: config)
{:ok, root} = Git.Info.root(config: config)Git.Search
Search content and history:
{:ok, results} = Git.Search.grep("TODO", config: config)
# [%Git.GrepResult{file: "lib/foo.ex", line_number: 42, content: "# TODO: refactor"}]
{:ok, commits} = Git.Search.commits("fix:", config: config)
{:ok, commits} = Git.Search.pickaxe("my_function", config: config)
{:ok, files} = Git.Search.files("*.ex", config: config)Git.Branches
Branch management:
{:ok, "main"} = Git.Branches.current(config: config)
{:ok, true} = Git.Branches.exists?("feat/login", config: config)
{:ok, merged} = Git.Branches.merged(config: config)
{:ok, deleted} = Git.Branches.cleanup_merged(exclude: ["main", "develop"], config: config)
{:ok, %{ahead: 3, behind: 0}} = Git.Branches.divergence("feat/x", "main", config: config)
{:ok, recent} = Git.Branches.recent(count: 5, config: config)
# Delete a branch; prune locals whose upstream is gone; find squash-merged branches
{:ok, :done} = Git.Branches.delete_branch("feat/done", config: config)
{:ok, pruned} = Git.Branches.prune_gone(config: config)
{:ok, would_delete} = Git.Branches.delete_squashed(target: "main", config: config) # dry-run by default
{:ok, deleted} = Git.Branches.delete_squashed(target: "main", dry_run: false, config: config)Git.Tags
Tag management:
{:ok, :done} = Git.Tags.create("v1.0.0", message: "Release 1.0.0", config: config)
{:ok, tags} = Git.Tags.list(config: config)
{:ok, "v1.0.0"} = Git.Tags.latest(config: config)
{:ok, sorted} = Git.Tags.sorted(config: config) # version-sorted
{:ok, true} = Git.Tags.exists?("v1.0.0", config: config)
{:ok, :done} = Git.Tags.delete("v0.1.0", config: config)Git.Remotes
Remote management:
{:ok, remotes} = Git.Remotes.list_detailed(config: config)
{:ok, :done} = Git.Remotes.add("upstream", "https://github.com/upstream/repo.git", config: config)
{:ok, :done} = Git.Remotes.set_url("origin", "git@github.com:me/repo.git", config: config)
{:ok, :done} = Git.Remotes.prune("origin", config: config)
{:ok, :done} = Git.Remotes.remove("upstream", config: config)Git.Stashes
Stash management:
{:ok, _} = Git.Stashes.save("work in progress", config: config)
{:ok, stashes} = Git.Stashes.list(config: config)
{:ok, _} = Git.Stashes.apply(config: config)
{:ok, _} = Git.Stashes.pop(config: config)
{:ok, :done} = Git.Stashes.drop(config: config)
{:ok, :done} = Git.Stashes.clear(config: config)Git.Patch
Patch creation and application:
{:ok, files} = Git.Patch.create("HEAD~3", output_directory: "/tmp/patches", config: config)
{:ok, :done} = Git.Patch.check("/tmp/patches/0001-fix.patch", config: config)
{:ok, :done} = Git.Patch.apply("/tmp/patches/0001-fix.patch", config: config)
{:ok, :done} = Git.Patch.apply_mailbox(["0001-fix.patch", "0002-feat.patch"], config: config)Git.Conflicts
Merge conflict helpers:
{:ok, false} = Git.Conflicts.detect(config: config)
{:ok, files} = Git.Conflicts.files(config: config)
{:ok, true} = Git.Conflicts.resolved?(config: config)
# Inspect the three sides of a conflicted path
{:ok, base} = Git.Conflicts.base("shared.txt", config: config)
{:ok, ours} = Git.Conflicts.ours("shared.txt", config: config)
{:ok, theirs} = Git.Conflicts.theirs("shared.txt", config: config)
# Resolve by taking one side (single path or a list), then it is staged for you
{:ok, :done} = Git.Conflicts.take_ours("shared.txt", config: config)
{:ok, :done} = Git.Conflicts.take_theirs(["a.txt", "b.txt"], config: config)
{:ok, :done} = Git.Conflicts.resolve("shared.txt", using: :ours, config: config)
# Abort or continue whatever operation is in progress (merge/rebase/cherry-pick/revert)
{:ok, :done} = Git.Conflicts.abort(config: config)
{:ok, _} = Git.Conflicts.continue(config: config)Git.Hooks
Manage git hooks:
{:ok, hooks} = Git.Hooks.list(config: config)
{:ok, path} = Git.Hooks.write("pre-commit", "#!/bin/sh\nmix format --check-formatted", config: config)
{:ok, true} = Git.Hooks.enabled?("pre-commit", config: config)
{:ok, _} = Git.Hooks.disable("pre-commit", config: config)
:ok = Git.Hooks.remove("pre-commit", config: config)Git.Signing
Configure commit and tag signing (sets the relevant git config keys):
# SSH signing (sets gpg.format=ssh, user.signingkey, commit.gpgsign=true)
{:ok, :done} = Git.Signing.use_ssh("/home/me/.ssh/id_ed25519.pub", config: config)
# GPG signing by key id
{:ok, :done} = Git.Signing.use_gpg("ABCD1234", config: config)
# Also sign annotated tags by default
{:ok, :done} = Git.Signing.sign_tags(true, config: config)The actual signing happens when a commit, tag, or merge is created. Once the
config above is set, Git.commit/2, Git.tag/1, and Git.merge/2 sign
automatically, or you can request it per call with :sign / :gpg_sign /
:local_user:
{:ok, _} = Git.commit("feat: signed change", sign: true, config: config)
{:ok, :done} = Git.tag(create: "v1.0.0", message: "release", sign: true, config: config)License
MIT