Git.Workflow (git v0.7.0)

Copy Markdown View Source

Composable helpers for common multi-step git workflows.

Each function orchestrates several lower-level Git commands into a single logical operation. All functions accept a keyword list where the :config key, when present, must be a Git.Config struct and is forwarded to every underlying git invocation.

Examples

# Stage everything and commit in one call
{:ok, result} = Git.Workflow.commit_all("feat: ship it", config: cfg)

# Work on a feature branch, then return to the original branch
{:ok, result} = Git.Workflow.feature_branch("feat/cool", fn opts ->
  File.write!("cool.txt", "cool")
  {:ok, :done} = Git.add(files: ["cool.txt"], config: opts[:config])
  {:ok, _} = Git.commit("feat: cool", Keyword.take(opts, [:config]))
  {:ok, :worked}
end, merge: true, config: cfg)

Summary

Types

A step, optionally labeled so a failure can name it.

A workflow step: a 1-arity function receiving the config keyword list.

Functions

Amends the last commit.

Cherry-picks commits onto another branch, then returns to where you started.

Runs a list of steps in order, threading :config into each and short-circuiting on the first {:error, _}.

Stages all changes and commits with the given message.

Returns the working tree to a pristine committed state.

Creates a feature branch, runs a function on it, and returns to the original branch.

Pushes the current branch to a remote, setting its upstream.

Creates an annotated release tag and, optionally, pushes it.

Recreates a deleted branch.

Rebases onto an upstream, cleaning up on conflict.

Collapses the last count commits on the current branch into a single new commit with message.

Merges a branch with --squash and commits with the given message.

Fetches from a remote and integrates changes using rebase or merge.

Syncs a fork: fetches the upstream and integrates it into the current branch.

Merges branch into the current branch, cleaning up on conflict.

Moves HEAD back by :count commits, returning the commits that were undone.

Checks out ref, runs fun on it, then restores the original branch.

Stashes uncommitted tracked changes (only if the tree is dirty), runs fun, then pops the stash.

Types

labeled_step()

@type labeled_step() :: step() | {atom(), step()}

A step, optionally labeled so a failure can name it.

step()

@type step() :: (keyword() -> {:ok, term()} | {:error, term()})

A workflow step: a 1-arity function receiving the config keyword list.

Functions

amend(opts \\ [])

@spec amend(keyword()) :: {:ok, Git.CommitResult.t()} | {:error, term()}

Amends the last commit.

When no :message is provided, the existing commit message is reused. When :all is true, all changes are staged before amending.

Options

  • :message - new commit message (default: reuse existing message)
  • :all - stage all changes before amending (default false)
  • :config - a Git.Config struct

Returns {:ok, commit_result} on success.

backport(commits, opts \\ [])

@spec backport(
  String.t() | [String.t()],
  keyword()
) :: {:ok, Git.CherryPickResult.t()} | {:error, term()}

Cherry-picks commits onto another branch, then returns to where you started.

commits is a single sha/ref or a list of them, applied in order. The current branch is remembered, :target is checked out (created from :base when given), and the commits are cherry-picked. On a conflict the cherry-pick is aborted; the original branch is always restored afterward, even if a step raises. With :push, the target is pushed to :remote after a clean pick.

Options

  • :target - branch to apply the commits on (required)
  • :base - create :target from this ref first (default: :target must exist)
  • :push - push :target to :remote after a clean pick (default false)
  • :remote - remote to push to (default "origin")
  • :config - a Git.Config struct

Returns {:ok, %Git.CherryPickResult{}} on success, or the error.

chain(steps, opts \\ [])

@spec chain(
  [labeled_step()],
  keyword()
) :: {:ok, term()} | {:error, term()}

Runs a list of steps in order, threading :config into each and short-circuiting on the first {:error, _}.

Each step is a 1-arity function receiving the config keyword list (the same contract as feature_branch/3's function), or a {name, fun} tuple. With a tuple, a failure is reported as {:error, {name, reason}}; otherwise the raw {:error, reason} is returned. Returns the last step's {:ok, result}; an empty list returns {:ok, nil}.

Steps do not receive each other's results. Prefer a plain with for a fixed sequence or when a later step needs an earlier step's output; reach for chain/2 when the step list is built at runtime.

Examples

[
  {:stage, fn o -> Git.add(Keyword.merge(o, all: true)) end},
  {:commit, fn o -> Git.commit("chore: release", o) end}
]
|> Git.Workflow.chain(config: cfg)

commit_all(message, opts \\ [])

@spec commit_all(
  String.t(),
  keyword()
) :: {:ok, Git.CommitResult.t()} | {:error, term()}

Stages all changes and commits with the given message.

Any additional keyword options (e.g., :allow_empty) are forwarded to Git.commit/2.

Options

Returns {:ok, commit_result} on success.

discard_all(opts \\ [])

@spec discard_all(keyword()) ::
  {:ok, :discarded} | {:ok, {:dry_run, [String.t()]}} | {:error, term()}

Returns the working tree to a pristine committed state.

Hard-resets tracked changes and removes untracked files and directories, so both halves of a dirty tree are cleared (a reset --hard leaves untracked files, a clean leaves tracked modifications; you need both).

Destructive: discarded untracked and ignored files are not recoverable. Preview first with dry_run: true.

Options

  • :ignored - also remove ignored files (clean -x, default false)
  • :dry_run - do not change anything; return the untracked/ignored files that would be removed (default false)
  • :config - a Git.Config struct

Returns {:ok, :discarded} after clearing the tree, or {:ok, {:dry_run, paths}} when :dry_run is set.

feature_branch(name, fun, opts \\ [])

@spec feature_branch(
  String.t(),
  (keyword() -> {:ok, term()} | {:error, term()}),
  keyword()
) ::
  {:ok, term()} | {:error, term()}

Creates a feature branch, runs a function on it, and returns to the original branch.

The function fun receives a keyword list containing the :config key (when one was provided in opts). It must return {:ok, result} or {:error, reason}.

After fun completes (successfully or not), the original branch is checked out to ensure cleanup.

Options

  • :merge - when true, merge the feature branch back into the original branch after fun succeeds (default false)
  • :delete - when true, delete the feature branch after a successful merge (default false; requires :merge to be true)
  • :config - a Git.Config struct

Returns {:ok, result} where result is the return value of fun, or the merge result when :merge is true.

publish(opts \\ [])

@spec publish(keyword()) :: {:ok, :done} | {:error, term()}

Pushes the current branch to a remote, setting its upstream.

Runs git push -u <remote> <current-branch>. With :force, uses --force-with-lease rather than a plain push, so a remote that moved on is not overwritten blindly.

Options

  • :remote - remote to push to (default "origin")
  • :force - force with lease (default false)
  • :config - a Git.Config struct

Returns {:ok, :done} on success.

release(version, opts \\ [])

@spec release(
  String.t(),
  keyword()
) :: {:ok, String.t()} | {:error, term()}

Creates an annotated release tag and, optionally, pushes it.

Refuses to clobber an existing tag: when version already exists the tag is left untouched and {:error, {:tag_exists, version}} is returned. Publishing is opt-in, so nothing is pushed unless :push is true.

Options

  • :message - annotation message (default: version)
  • :sign - create a GPG-signed tag (-s, default false)
  • :ref - commit to tag (default "HEAD")
  • :push - push the tag after creating it (default false)
  • :remote - remote to push the tag to (default "origin")
  • :config - a Git.Config struct

Returns {:ok, version} once the tag exists (and, with :push, is pushed).

restore_branch(name, opts \\ [])

@spec restore_branch(
  String.t(),
  keyword()
) :: {:ok, String.t()} | {:error, term()}

Recreates a deleted branch.

With :sha, simply creates name at that commit. Otherwise scans the reflog for the last commit name pointed at (the value HEAD held just before the branch was last left) and recreates it there, or returns {:error, :not_found} when the reflog has no record of the branch. Pass checkout: true to switch to the restored branch.

Options

  • :sha - recreate the branch at this commit instead of consulting the reflog
  • :checkout - check the branch out after recreating it (default false)
  • :config - a Git.Config struct

Returns {:ok, sha} with the commit the branch was recreated at.

safe_rebase(opts \\ [])

@spec safe_rebase(keyword()) :: {:ok, Git.RebaseResult.t()} | {:error, term()}

Rebases onto an upstream, cleaning up on conflict.

Runs Git.rebase/1 with :upstream (and optional :onto) taken from opts. A conflict (or any rebase failure) leaves the rebase in progress, so this aborts it with Git.rebase(abort: true, ...) before returning the original {:error, reason}. The abort is best-effort.

Options

  • :upstream - the upstream ref to rebase onto
  • :onto - rebase onto this ref instead of the upstream's base
  • :config - a Git.Config struct

Returns {:ok, %Git.RebaseResult{}} on a clean rebase.

squash_last(count, message, opts \\ [])

@spec squash_last(pos_integer(), String.t(), keyword()) ::
  {:ok, Git.CommitResult.t()} | {:error, term()}

Collapses the last count commits on the current branch into a single new commit with message.

Soft-resets to HEAD~count (keeping all their changes staged) and commits them as one. This is the only in-place squash in the library, since interactive rebase is intentionally not wrapped. count must be at least 2.

Rewrites current-branch history; recover the originals from the reflog if needed.

Options

Returns {:ok, commit_result}, or {:error, :cannot_undo_root} when there are not enough commits to squash that many.

squash_merge(branch, opts \\ [])

@spec squash_merge(
  String.t(),
  keyword()
) :: {:ok, Git.CommitResult.t()} | {:error, term()}

Merges a branch with --squash and commits with the given message.

Options

  • :message - commit message (required)
  • :delete - when true, delete the source branch after merge (default false)
  • :config - a Git.Config struct

Returns {:ok, commit_result} on success. When :delete is requested but the source branch cannot be deleted (for example it is still checked out in a worktree), returns {:error, {:branch_not_deleted, reason}} rather than reporting success over a branch that still exists. The squash commit itself has already landed at that point.

sync(opts \\ [])

@spec sync(keyword()) :: {:ok, :synced} | {:error, term()}

Fetches from a remote and integrates changes using rebase or merge.

Options

  • :strategy - :rebase (default) or :merge
  • :autostash - stash uncommitted changes before syncing and pop after (default true). Like git's own rebase.autostash, only tracked changes are stashed; an untracked-only working tree is not stashed.
  • :remote - remote name (default "origin")
  • :branch - branch to sync with (defaults to the upstream tracking branch)
  • :config - a Git.Config struct

Returns {:ok, :synced} on success.

When autostash is active and integration succeeds but popping the stash conflicts with the freshly integrated changes, returns {:error, {:autostash_pop_failed, reason}} (the working tree is left with the popped stash and its conflict markers) rather than reporting success over it.

When integration itself fails (for example a rebase or merge conflict), the autostash is left in place; recover it with git stash pop after resolving the integration, since popping onto a mid-conflict tree would compound it.

sync_fork(opts \\ [])

@spec sync_fork(keyword()) :: {:ok, :synced} | {:error, term()}

Syncs a fork: fetches the upstream and integrates it into the current branch.

Fetches :upstream, then fast-forwards the current branch onto <upstream>/<branch> (a --ff-only merge, the default) or rebases onto it when strategy: :rebase. With :push, the result is pushed to :origin.

Options

  • :upstream - remote to sync from (default "upstream")
  • :origin - remote to push to when :push (default "origin")
  • :branch - branch to track on the upstream (default: the current branch)
  • :strategy - :merge (fast-forward only, default) or :rebase
  • :push - push to :origin after integrating (default false)
  • :config - a Git.Config struct

Returns {:ok, :synced} on success.

try_merge(branch, opts \\ [])

@spec try_merge(
  String.t(),
  keyword()
) :: {:ok, Git.MergeResult.t()} | {:error, term()}

Merges branch into the current branch, cleaning up on conflict.

Runs Git.merge/2. A conflict (or any merge failure) leaves the working tree mid-merge, so this aborts it with Git.merge(:abort, ...) before returning the original {:error, reason}. The abort is best-effort: when the failure left no merge to abort, the abort's own error is ignored and the original merge error is still returned.

Extra options (for example :no_ff or :strategy) are forwarded to the merge.

Options

Returns {:ok, %Git.MergeResult{}} on a clean merge.

undo_last_commit(opts \\ [])

@spec undo_last_commit(keyword()) :: {:ok, [Git.Commit.t()]} | {:error, term()}

Moves HEAD back by :count commits, returning the commits that were undone.

This is the "I did not mean to commit that" helper. It captures the commits being undone first, then resets. :mode controls what happens to their changes:

  • :soft (default) - keep the changes staged
  • :mixed - keep the changes in the working tree, unstaged
  • :hard - discard the changes (irreversible beyond the reflog)

Options

  • :count - number of commits to undo (default 1)
  • :mode - :soft (default), :mixed, or :hard
  • :config - a Git.Config struct

Returns {:ok, undone_commits} where undone_commits is the list of Git.Commit structs that were on top of the new HEAD, or {:error, :cannot_undo_root} when there is not enough history to move back that far (you cannot reset past the root commit).

with_branch(ref, fun, opts \\ [])

@spec with_branch(String.t(), step(), keyword()) :: {:ok, term()} | {:error, term()}

Checks out ref, runs fun on it, then restores the original branch.

The original branch is restored even if fun raises. Pass create: true to create the branch first. Returns fun's result. This is the reusable bracket underneath feature_branch/3.

Options

  • :create - create ref as a new branch before running (default false)
  • :config - a Git.Config struct

with_stash(fun, opts \\ [])

@spec with_stash(
  step(),
  keyword()
) :: {:ok, term()} | {:error, term()}

Stashes uncommitted tracked changes (only if the tree is dirty), runs fun, then pops the stash.

The stash is popped even if fun raises. Returns fun's result. A pop that fails (for example a conflict) is surfaced as the error rather than hidden. This is the :autostash behavior of sync/1, made reusable.

Options