Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 61 additions & 0 deletions lib/git/changes.ex
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,67 @@ defmodule Git.Changes do
end
end

@doc """
Returns only staged changes as a diff.

Uses `Git.diff(staged: true, stat: true)` to show what is in the index
but not yet committed.

Returns `{:ok, Git.Diff.t()}`.
"""
@spec staged(keyword()) :: {:ok, Git.Diff.t()} | {:error, term()}
def staged(opts \\ []) do
{config, _rest} = extract_config(opts)
Git.diff(staged: true, stat: true, config: config)
end

@doc """
Returns only unstaged changes as a diff.

Uses `Git.diff(stat: true)` to show working tree changes that have not
been staged.

Returns `{:ok, Git.Diff.t()}`.
"""
@spec unstaged(keyword()) :: {:ok, Git.Diff.t()} | {:error, term()}
def unstaged(opts \\ []) do
{config, _rest} = extract_config(opts)
Git.diff(stat: true, config: config)
end

@doc """
Returns summary statistics of unstaged working tree changes.

Uses `Git.diff(stat: true)` and extracts file count, insertions, and
deletions into a simple map.

Returns `{:ok, %{files_changed: n, insertions: n, deletions: n}}`.
"""
@spec stats(keyword()) ::
{:ok,
%{
files_changed: non_neg_integer(),
insertions: non_neg_integer(),
deletions: non_neg_integer()
}}
| {:error, term()}
def stats(opts \\ []) do
{config, _rest} = extract_config(opts)

case Git.diff(stat: true, config: config) do
{:ok, diff} ->
{:ok,
%{
files_changed: length(diff.files),
insertions: diff.total_insertions,
deletions: diff.total_deletions
}}

error ->
error
end
end

# ---------------------------------------------------------------------------
# Private helpers
# ---------------------------------------------------------------------------
Expand Down
147 changes: 147 additions & 0 deletions lib/git/conflicts.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
defmodule Git.Conflicts do
@moduledoc """
Merge conflict detection and resolution helpers that compose `Git.status/1`
and `Git.merge/2`.

All functions accept an optional keyword list. Use `:config` to specify the
repository via a `Git.Config` struct; when omitted a default config is built
from the environment.
"""

alias Git.Config

# Unmerged status code combinations per git-status porcelain v1:
# DD (both deleted), AU (added by us), UD (deleted by them),
# UA (added by them), DU (deleted by us), AA (both added), UU (both modified)
@unmerged_pairs MapSet.new([
{"D", "D"},
{"A", "U"},
{"U", "D"},
{"U", "A"},
{"D", "U"},
{"A", "A"},
{"U", "U"}
])

# ---------------------------------------------------------------------------
# Public API
# ---------------------------------------------------------------------------

@doc """
Checks whether the repository is in a conflicted state.

Uses `Git.status/1` and inspects entries for unmerged status codes.

Returns `{:ok, true}` when conflicts exist, `{:ok, false}` otherwise.

## Options

* `:config` - a `Git.Config` struct

## Examples

{:ok, false} = Git.Conflicts.detect()

"""
@spec detect(keyword()) :: {:ok, boolean()} | {:error, term()}
def detect(opts \\ []) do
{config, _rest} = extract_config(opts)

case Git.status(config: config) do
{:ok, status} ->
{:ok, Enum.any?(status.entries, &unmerged?/1)}

error ->
error
end
end

@doc """
Lists file paths that have merge conflicts.

Uses `Git.status/1` and filters for entries with unmerged status codes.

Returns `{:ok, [String.t()]}`.

## Options

* `:config` - a `Git.Config` struct

## Examples

{:ok, files} = Git.Conflicts.files()

"""
@spec files(keyword()) :: {:ok, [String.t()]} | {:error, term()}
def files(opts \\ []) do
{config, _rest} = extract_config(opts)

case Git.status(config: config) do
{:ok, status} ->
conflicted =
status.entries
|> Enum.filter(&unmerged?/1)
|> Enum.map(& &1.path)

{:ok, conflicted}

error ->
error
end
end

@doc """
Checks whether all conflicts have been resolved.

This is the inverse of `detect/1` -- returns `{:ok, true}` when no unmerged
files exist.

## Options

* `:config` - a `Git.Config` struct

## Examples

{:ok, true} = Git.Conflicts.resolved?()

"""
@spec resolved?(keyword()) :: {:ok, boolean()} | {:error, term()}
def resolved?(opts \\ []) do
case detect(opts) do
{:ok, conflicted} -> {:ok, not conflicted}
error -> error
end
end

@doc """
Aborts an in-progress conflicted merge.

Delegates to `Git.merge(:abort)`.

## Options

* `:config` - a `Git.Config` struct

## Examples

{:ok, :done} = Git.Conflicts.abort_merge()

"""
@spec abort_merge(keyword()) :: {:ok, :done} | {:error, term()}
def abort_merge(opts \\ []) do
{config, _rest} = extract_config(opts)
Git.merge(:abort, config: config)
end

# ---------------------------------------------------------------------------
# Private helpers
# ---------------------------------------------------------------------------

defp extract_config(opts) do
Keyword.pop(opts, :config, Config.new())
end

defp unmerged?(%{index: index, working_tree: working_tree}) do
MapSet.member?(@unmerged_pairs, {index, working_tree})
end
end
107 changes: 107 additions & 0 deletions lib/git/patch.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
defmodule Git.Patch do
@moduledoc """
Higher-level patch workflow operations that compose `Git.format_patch/1`,
`Git.apply_patch/1`, and `Git.am/1`.

All functions accept an optional keyword list. Use `:config` to specify the
repository via a `Git.Config` struct; when omitted a default config is built
from the environment.
"""

alias Git.Config

# ---------------------------------------------------------------------------
# Public API
# ---------------------------------------------------------------------------

@doc """
Creates patch files from commits starting at a ref.

Delegates to `Git.format_patch/1`.

## Options

* `:config` - a `Git.Config` struct
* `:output_directory` - directory to write patch files to

## Examples

{:ok, files} = Git.Patch.create("HEAD~3")
{:ok, files} = Git.Patch.create("HEAD~1", output_directory: "/tmp/patches")

"""
@spec create(String.t(), keyword()) ::
{:ok, [String.t()]} | {:ok, String.t()} | {:error, term()}
def create(ref, opts \\ []) do
{config, rest} = extract_config(opts)
Git.format_patch([{:ref, ref}, {:config, config} | rest])
end

@doc """
Applies a patch file to the working tree.

Delegates to `Git.apply_patch/1`.

## Options

* `:config` - a `Git.Config` struct

## Examples

{:ok, :done} = Git.Patch.apply("0001-fix.patch")

"""
@spec apply(String.t(), keyword()) :: {:ok, :done} | {:ok, String.t()} | {:error, term()}
def apply(patch_file, opts \\ []) do
{config, rest} = extract_config(opts)
Git.apply_patch([{:patch, patch_file}, {:config, config} | rest])
end

@doc """
Applies mailbox-formatted patches (git am).

Delegates to `Git.am/1`.

## Options

* `:config` - a `Git.Config` struct

## Examples

{:ok, :done} = Git.Patch.apply_mailbox(["0001-fix.patch"])

"""
@spec apply_mailbox([String.t()], keyword()) :: {:ok, :done} | {:error, term()}
def apply_mailbox(patches, opts \\ []) do
{config, rest} = extract_config(opts)
Git.am([{:patches, patches}, {:config, config} | rest])
end

@doc """
Checks whether a patch applies cleanly without actually applying it.

Uses `Git.apply_patch/1` with the `:check` option.

## Options

* `:config` - a `Git.Config` struct

## Examples

{:ok, _output} = Git.Patch.check("0001-fix.patch")

"""
@spec check(String.t(), keyword()) :: {:ok, String.t()} | {:error, term()}
def check(patch_file, opts \\ []) do
{config, rest} = extract_config(opts)
Git.apply_patch([{:patch, patch_file}, {:check, true}, {:config, config} | rest])
end

# ---------------------------------------------------------------------------
# Private helpers
# ---------------------------------------------------------------------------

defp extract_config(opts) do
Keyword.pop(opts, :config, Config.new())
end
end
Loading