From 8b122fc95b460189d2600866765389fd1c3740c5 Mon Sep 17 00:00:00 2001 From: Josh Rotenberg Date: Wed, 8 Apr 2026 13:05:04 -0700 Subject: [PATCH] feat: add apply and am commands (patch workflow) --- lib/git.ex | 62 +++++++++++ lib/git/commands/am.ex | 95 +++++++++++++++++ lib/git/commands/apply.ex | 103 ++++++++++++++++++ lib/git/repo.ex | 20 ++++ test/git/commands/am_test.exs | 135 +++++++++++++++++++++++ test/git/commands/apply_test.exs | 178 +++++++++++++++++++++++++++++++ 6 files changed, 593 insertions(+) create mode 100644 lib/git/commands/am.ex create mode 100644 lib/git/commands/apply.ex create mode 100644 test/git/commands/am_test.exs create mode 100644 test/git/commands/apply_test.exs diff --git a/lib/git.ex b/lib/git.ex index a28543b..1e5cf6d 100644 --- a/lib/git.ex +++ b/lib/git.ex @@ -1772,4 +1772,66 @@ defmodule Git do command = struct!(Git.Commands.Restore, rest) Git.Command.run(Git.Commands.Restore, command, config) end + + @doc """ + Runs `git apply` to apply a patch to files and/or the index. + + The function is named `apply_patch` to avoid conflicting with `Kernel.apply/2`. + + ## Options + + * `:config` - a `Git.Config` struct (default: `Git.Config.new()`) + * `:patch` - path to the patch file (required) + * `:check` - check if patch applies cleanly without applying (`--check`) + * `:stat` - show diffstat (`--stat`) + * `:summary` - show summary (`--summary`) + * `:cached` - apply to index only (`--cached`) + * `:index` - apply to index and working tree (`--index`) + * `:reverse` - apply in reverse (`--reverse`) + * `:three_way` - attempt 3-way merge (`--3way`) + * `:verbose` - verbose output (`--verbose`) + + ## Examples + + Git.apply_patch(patch: "fix.patch") + Git.apply_patch(patch: "fix.patch", check: true) + Git.apply_patch(patch: "fix.patch", stat: true) + + """ + @spec apply_patch(keyword()) :: {:ok, :done} | {:ok, String.t()} | {:error, term()} + def apply_patch(opts \\ []) do + {config, rest} = Keyword.pop(opts, :config, Config.new()) + command = struct!(Git.Commands.Apply, rest) + Git.Command.run(Git.Commands.Apply, command, config) + end + + @doc """ + Runs `git am` to apply patches from mailbox-formatted files. + + ## Options + + * `:config` - a `Git.Config` struct (default: `Git.Config.new()`) + * `:patches` - list of paths to mailbox patch files + * `:directory` - path to directory of patches + * `:three_way` - 3-way merge on conflict (`--3way`) + * `:keep` - keep subject prefix (`--keep`) + * `:signoff` - add Signed-off-by line (`--signoff`) + * `:abort` - abort current am session (`--abort`) + * `:continue_` - continue after resolving conflict (`--continue`) + * `:skip` - skip current patch (`--skip`) + * `:quiet` - quiet output (`--quiet`) + + ## Examples + + Git.am(patches: ["0001-fix.patch"]) + Git.am(patches: ["0001-fix.patch"], three_way: true) + Git.am(abort: true) + + """ + @spec am(keyword()) :: {:ok, :done} | {:error, term()} + def am(opts \\ []) do + {config, rest} = Keyword.pop(opts, :config, Config.new()) + command = struct!(Git.Commands.Am, rest) + Git.Command.run(Git.Commands.Am, command, config) + end end diff --git a/lib/git/commands/am.ex b/lib/git/commands/am.ex new file mode 100644 index 0000000..1381636 --- /dev/null +++ b/lib/git/commands/am.ex @@ -0,0 +1,95 @@ +defmodule Git.Commands.Am do + @moduledoc """ + Implements the `Git.Command` behaviour for `git am`. + + Applies a series of patches from mailbox-formatted files. Supports three-way + merges, keeping subject prefixes, adding sign-off lines, and controlling + in-progress am sessions (abort, continue, skip). + """ + + @behaviour Git.Command + + @type t :: %__MODULE__{ + patches: [String.t()], + directory: String.t() | nil, + three_way: boolean(), + keep: boolean(), + signoff: boolean(), + abort: boolean(), + continue_: boolean(), + skip: boolean(), + quiet: boolean() + } + + defstruct patches: [], + directory: nil, + three_way: false, + keep: false, + signoff: false, + abort: false, + continue_: false, + skip: false, + quiet: false + + @doc """ + Returns the argument list for `git am`. + + When `:abort`, `:continue_`, or `:skip` is `true`, builds the corresponding + control command. Otherwise builds the full am command with all applicable + flags and patch file paths. + + ## Examples + + iex> Git.Commands.Am.args(%Git.Commands.Am{abort: true}) + ["am", "--abort"] + + iex> Git.Commands.Am.args(%Git.Commands.Am{continue_: true}) + ["am", "--continue"] + + iex> Git.Commands.Am.args(%Git.Commands.Am{skip: true}) + ["am", "--skip"] + + iex> Git.Commands.Am.args(%Git.Commands.Am{patches: ["0001-fix.patch"]}) + ["am", "0001-fix.patch"] + + iex> Git.Commands.Am.args(%Git.Commands.Am{patches: ["0001-fix.patch"], three_way: true, signoff: true}) + ["am", "--3way", "--signoff", "0001-fix.patch"] + + iex> Git.Commands.Am.args(%Git.Commands.Am{directory: "/tmp/patches"}) + ["am", "/tmp/patches"] + + """ + @spec args(t()) :: [String.t()] + @impl true + def args(%__MODULE__{abort: true}), do: ["am", "--abort"] + def args(%__MODULE__{continue_: true}), do: ["am", "--continue"] + def args(%__MODULE__{skip: true}), do: ["am", "--skip"] + + def args(%__MODULE__{} = command) do + ["am"] + |> maybe_add(command.three_way, "--3way") + |> maybe_add(command.keep, "--keep") + |> maybe_add(command.signoff, "--signoff") + |> maybe_add(command.quiet, "--quiet") + |> maybe_add_patches(command.patches, command.directory) + end + + @doc """ + Parses the output of `git am`. + + On success (exit code 0), returns `{:ok, :done}`. + On failure, returns `{:error, {stdout, exit_code}}`. + """ + @spec parse_output(String.t(), non_neg_integer()) :: + {:ok, :done} | {:error, {String.t(), non_neg_integer()}} + @impl true + def parse_output(_stdout, 0), do: {:ok, :done} + def parse_output(stdout, exit_code), do: {:error, {stdout, exit_code}} + + defp maybe_add(args, true, flag), do: args ++ [flag] + defp maybe_add(args, false, _flag), do: args + + defp maybe_add_patches(args, [], nil), do: args + defp maybe_add_patches(args, [], dir) when is_binary(dir), do: args ++ [dir] + defp maybe_add_patches(args, patches, _dir), do: args ++ patches +end diff --git a/lib/git/commands/apply.ex b/lib/git/commands/apply.ex new file mode 100644 index 0000000..e5ed31e --- /dev/null +++ b/lib/git/commands/apply.ex @@ -0,0 +1,103 @@ +defmodule Git.Commands.Apply do + @moduledoc """ + Implements the `Git.Command` behaviour for `git apply`. + + Applies a patch to files and/or to the index. Supports checking whether a + patch applies cleanly, showing diffstat/summary, applying to the index or + working tree, reverse application, and three-way merges. + """ + + @behaviour Git.Command + + @mode_key :__git_apply_mode__ + + @type t :: %__MODULE__{ + patch: String.t() | nil, + check: boolean(), + stat: boolean(), + summary: boolean(), + cached: boolean(), + index: boolean(), + reverse: boolean(), + three_way: boolean(), + verbose: boolean() + } + + defstruct patch: nil, + check: false, + stat: false, + summary: false, + cached: false, + index: false, + reverse: false, + three_way: false, + verbose: false + + @doc """ + Returns the argument list for `git apply`. + + ## Examples + + iex> Git.Commands.Apply.args(%Git.Commands.Apply{patch: "fix.patch"}) + ["apply", "fix.patch"] + + iex> Git.Commands.Apply.args(%Git.Commands.Apply{patch: "fix.patch", check: true}) + ["apply", "--check", "fix.patch"] + + iex> Git.Commands.Apply.args(%Git.Commands.Apply{patch: "fix.patch", stat: true, summary: true}) + ["apply", "--stat", "--summary", "fix.patch"] + + iex> Git.Commands.Apply.args(%Git.Commands.Apply{patch: "fix.patch", cached: true}) + ["apply", "--cached", "fix.patch"] + + iex> Git.Commands.Apply.args(%Git.Commands.Apply{patch: "fix.patch", reverse: true}) + ["apply", "--reverse", "fix.patch"] + + iex> Git.Commands.Apply.args(%Git.Commands.Apply{patch: "fix.patch", three_way: true}) + ["apply", "--3way", "fix.patch"] + + """ + @spec args(t()) :: [String.t()] + @impl true + def args(%__MODULE__{} = command) do + info_mode = command.stat or command.summary or command.check + Process.put(@mode_key, info_mode) + + ["apply"] + |> maybe_add(command.check, "--check") + |> maybe_add(command.stat, "--stat") + |> maybe_add(command.summary, "--summary") + |> maybe_add(command.cached, "--cached") + |> maybe_add(command.index, "--index") + |> maybe_add(command.reverse, "--reverse") + |> maybe_add(command.three_way, "--3way") + |> maybe_add(command.verbose, "--verbose") + |> maybe_add_patch(command.patch) + end + + @doc """ + Parses the output of `git apply`. + + For stat, summary, or check modes, returns `{:ok, output}` with the + informational text. For normal apply operations, returns `{:ok, :done}`. + On failure, returns `{:error, {stdout, exit_code}}`. + """ + @spec parse_output(String.t(), non_neg_integer()) :: + {:ok, :done} | {:ok, String.t()} | {:error, {String.t(), non_neg_integer()}} + @impl true + def parse_output(stdout, 0) do + if Process.get(@mode_key, false) do + {:ok, stdout} + else + {:ok, :done} + end + end + + def parse_output(stdout, exit_code), do: {:error, {stdout, exit_code}} + + defp maybe_add(args, true, flag), do: args ++ [flag] + defp maybe_add(args, false, _flag), do: args + + defp maybe_add_patch(args, nil), do: args + defp maybe_add_patch(args, patch), do: args ++ [patch] +end diff --git a/lib/git/repo.ex b/lib/git/repo.ex index 4b5bf25..e4b52b9 100644 --- a/lib/git/repo.ex +++ b/lib/git/repo.ex @@ -770,4 +770,24 @@ defmodule Git.Repo do def restore(%__MODULE__{} = repo, opts \\ []) do Git.restore(Keyword.put(opts, :config, repo.config)) end + + @doc """ + Runs `git apply` on the repository. + + See `Git.apply_patch/1` for available options. + """ + @spec apply_patch(t(), keyword()) :: {:ok, :done} | {:ok, String.t()} | {:error, term()} + def apply_patch(%__MODULE__{} = repo, opts \\ []) do + Git.apply_patch(Keyword.put(opts, :config, repo.config)) + end + + @doc """ + Runs `git am` on the repository. + + See `Git.am/1` for available options. + """ + @spec am(t(), keyword()) :: {:ok, :done} | {:error, term()} + def am(%__MODULE__{} = repo, opts \\ []) do + Git.am(Keyword.put(opts, :config, repo.config)) + end end diff --git a/test/git/commands/am_test.exs b/test/git/commands/am_test.exs new file mode 100644 index 0000000..e3ba8aa --- /dev/null +++ b/test/git/commands/am_test.exs @@ -0,0 +1,135 @@ +defmodule Git.AmTest do + use ExUnit.Case, async: true + + alias Git.Commands.Am + alias Git.Config + + setup do + tmp_dir = + Path.join( + System.tmp_dir!(), + "git_wrapper_am_test_#{:erlang.unique_integer([:positive])}" + ) + + File.mkdir_p!(tmp_dir) + System.cmd("git", ["init", "--initial-branch=main"], cd: tmp_dir) + System.cmd("git", ["config", "user.name", "Test User"], cd: tmp_dir) + System.cmd("git", ["config", "user.email", "test@test.com"], cd: tmp_dir) + + # Create initial file and commit + File.write!(Path.join(tmp_dir, "hello.txt"), "hello\n") + System.cmd("git", ["add", "hello.txt"], cd: tmp_dir) + System.cmd("git", ["commit", "-m", "initial commit"], cd: tmp_dir) + + on_exit(fn -> File.rm_rf!(tmp_dir) end) + + config = Config.new(working_dir: tmp_dir) + %{tmp_dir: tmp_dir, config: config} + end + + describe "args/1" do + test "builds args for abort" do + assert Am.args(%Am{abort: true}) == ["am", "--abort"] + end + + test "builds args for continue" do + assert Am.args(%Am{continue_: true}) == ["am", "--continue"] + end + + test "builds args for skip" do + assert Am.args(%Am{skip: true}) == ["am", "--skip"] + end + + test "builds args with a single patch" do + assert Am.args(%Am{patches: ["0001-fix.patch"]}) == ["am", "0001-fix.patch"] + end + + test "builds args with multiple patches" do + assert Am.args(%Am{patches: ["0001-fix.patch", "0002-feat.patch"]}) == + ["am", "0001-fix.patch", "0002-feat.patch"] + end + + test "builds args with --3way" do + assert Am.args(%Am{patches: ["0001-fix.patch"], three_way: true}) == + ["am", "--3way", "0001-fix.patch"] + end + + test "builds args with --keep" do + assert Am.args(%Am{patches: ["0001-fix.patch"], keep: true}) == + ["am", "--keep", "0001-fix.patch"] + end + + test "builds args with --signoff" do + assert Am.args(%Am{patches: ["0001-fix.patch"], signoff: true}) == + ["am", "--signoff", "0001-fix.patch"] + end + + test "builds args with --quiet" do + assert Am.args(%Am{patches: ["0001-fix.patch"], quiet: true}) == + ["am", "--quiet", "0001-fix.patch"] + end + + test "builds args with directory" do + assert Am.args(%Am{directory: "/tmp/patches"}) == ["am", "/tmp/patches"] + end + + test "builds args with multiple flags" do + assert Am.args(%Am{patches: ["0001-fix.patch"], three_way: true, signoff: true}) == + ["am", "--3way", "--signoff", "0001-fix.patch"] + end + end + + describe "am a patch" do + test "applies a format-patch mailbox file", %{tmp_dir: tmp_dir, config: config} do + # Create a commit on a branch, then format-patch it + System.cmd("git", ["checkout", "-b", "feature"], cd: tmp_dir) + File.write!(Path.join(tmp_dir, "hello.txt"), "hello world\n") + System.cmd("git", ["add", "hello.txt"], cd: tmp_dir) + System.cmd("git", ["commit", "-m", "update hello"], cd: tmp_dir) + + # Generate a mailbox patch + {patch_output, 0} = System.cmd("git", ["format-patch", "-1", "--stdout"], cd: tmp_dir) + patch_path = Path.join(tmp_dir, "0001-update-hello.patch") + File.write!(patch_path, patch_output) + + # Go back to main and apply with am + System.cmd("git", ["checkout", "main"], cd: tmp_dir) + + assert {:ok, :done} = Git.am(patches: [patch_path], config: config) + + assert File.read!(Path.join(tmp_dir, "hello.txt")) == "hello world\n" + end + + test "applies a patch with --signoff", %{tmp_dir: tmp_dir, config: config} do + System.cmd("git", ["checkout", "-b", "feature"], cd: tmp_dir) + File.write!(Path.join(tmp_dir, "hello.txt"), "hello signoff\n") + System.cmd("git", ["add", "hello.txt"], cd: tmp_dir) + System.cmd("git", ["commit", "-m", "signoff change"], cd: tmp_dir) + + {patch_output, 0} = System.cmd("git", ["format-patch", "-1", "--stdout"], cd: tmp_dir) + patch_path = Path.join(tmp_dir, "0001-signoff.patch") + File.write!(patch_path, patch_output) + + System.cmd("git", ["checkout", "main"], cd: tmp_dir) + + assert {:ok, :done} = Git.am(patches: [patch_path], signoff: true, config: config) + + # Check that Signed-off-by was added + {log, 0} = System.cmd("git", ["log", "-1", "--format=%B"], cd: tmp_dir) + assert log =~ "Signed-off-by:" + end + end + + describe "am failure" do + test "returns error for invalid patch", %{tmp_dir: tmp_dir, config: config} do + bad_patch_path = Path.join(tmp_dir, "bad.patch") + File.write!(bad_patch_path, "this is not a valid mbox patch\n") + + assert {:error, {output, exit_code}} = + Git.am(patches: [bad_patch_path], config: config) + + assert exit_code != 0 + assert is_binary(output) + end + end +end diff --git a/test/git/commands/apply_test.exs b/test/git/commands/apply_test.exs new file mode 100644 index 0000000..991e810 --- /dev/null +++ b/test/git/commands/apply_test.exs @@ -0,0 +1,178 @@ +defmodule Git.ApplyTest do + use ExUnit.Case, async: true + + alias Git.Commands.Apply + alias Git.Config + + setup do + tmp_dir = + Path.join( + System.tmp_dir!(), + "git_wrapper_apply_test_#{:erlang.unique_integer([:positive])}" + ) + + File.mkdir_p!(tmp_dir) + System.cmd("git", ["init", "--initial-branch=main"], cd: tmp_dir) + System.cmd("git", ["config", "user.name", "Test User"], cd: tmp_dir) + System.cmd("git", ["config", "user.email", "test@test.com"], cd: tmp_dir) + + # Create initial file and commit + File.write!(Path.join(tmp_dir, "hello.txt"), "hello\n") + System.cmd("git", ["add", "hello.txt"], cd: tmp_dir) + System.cmd("git", ["commit", "-m", "initial commit"], cd: tmp_dir) + + on_exit(fn -> File.rm_rf!(tmp_dir) end) + + config = Config.new(working_dir: tmp_dir) + %{tmp_dir: tmp_dir, config: config} + end + + describe "args/1" do + test "builds args with patch only" do + assert Apply.args(%Apply{patch: "fix.patch"}) == ["apply", "fix.patch"] + end + + test "builds args with --check" do + assert Apply.args(%Apply{patch: "fix.patch", check: true}) == + ["apply", "--check", "fix.patch"] + end + + test "builds args with --stat" do + assert Apply.args(%Apply{patch: "fix.patch", stat: true}) == + ["apply", "--stat", "fix.patch"] + end + + test "builds args with --summary" do + assert Apply.args(%Apply{patch: "fix.patch", summary: true}) == + ["apply", "--summary", "fix.patch"] + end + + test "builds args with --cached" do + assert Apply.args(%Apply{patch: "fix.patch", cached: true}) == + ["apply", "--cached", "fix.patch"] + end + + test "builds args with --index" do + assert Apply.args(%Apply{patch: "fix.patch", index: true}) == + ["apply", "--index", "fix.patch"] + end + + test "builds args with --reverse" do + assert Apply.args(%Apply{patch: "fix.patch", reverse: true}) == + ["apply", "--reverse", "fix.patch"] + end + + test "builds args with --3way" do + assert Apply.args(%Apply{patch: "fix.patch", three_way: true}) == + ["apply", "--3way", "fix.patch"] + end + + test "builds args with --verbose" do + assert Apply.args(%Apply{patch: "fix.patch", verbose: true}) == + ["apply", "--verbose", "fix.patch"] + end + + test "builds args with multiple flags" do + assert Apply.args(%Apply{patch: "fix.patch", stat: true, summary: true}) == + ["apply", "--stat", "--summary", "fix.patch"] + end + end + + describe "apply a patch" do + test "applies a patch file to the working tree", %{tmp_dir: tmp_dir, config: config} do + # Generate a patch by making a change + File.write!(Path.join(tmp_dir, "hello.txt"), "hello world\n") + + {patch_content, 0} = System.cmd("git", ["diff"], cd: tmp_dir) + patch_path = Path.join(tmp_dir, "change.patch") + File.write!(patch_path, patch_content) + + # Reset the change so we can apply the patch + System.cmd("git", ["checkout", "--", "hello.txt"], cd: tmp_dir) + + assert {:ok, :done} = Git.apply_patch(patch: patch_path, config: config) + + assert File.read!(Path.join(tmp_dir, "hello.txt")) == "hello world\n" + end + + test "checks if a patch applies cleanly", %{tmp_dir: tmp_dir, config: config} do + File.write!(Path.join(tmp_dir, "hello.txt"), "hello world\n") + + {patch_content, 0} = System.cmd("git", ["diff"], cd: tmp_dir) + patch_path = Path.join(tmp_dir, "change.patch") + File.write!(patch_path, patch_content) + + System.cmd("git", ["checkout", "--", "hello.txt"], cd: tmp_dir) + + assert {:ok, _output} = Git.apply_patch(patch: patch_path, check: true, config: config) + end + + test "returns stat output", %{tmp_dir: tmp_dir, config: config} do + File.write!(Path.join(tmp_dir, "hello.txt"), "hello world\n") + + {patch_content, 0} = System.cmd("git", ["diff"], cd: tmp_dir) + patch_path = Path.join(tmp_dir, "change.patch") + File.write!(patch_path, patch_content) + + assert {:ok, output} = Git.apply_patch(patch: patch_path, stat: true, config: config) + assert is_binary(output) + assert output =~ "hello.txt" + end + + test "applies a patch in reverse", %{tmp_dir: tmp_dir, config: config} do + # Make a change and generate a forward patch + File.write!(Path.join(tmp_dir, "hello.txt"), "hello world\n") + + {patch_content, 0} = System.cmd("git", ["diff"], cd: tmp_dir) + patch_path = Path.join(tmp_dir, "change.patch") + File.write!(patch_path, patch_content) + + # Stage the change so we have the modified file + System.cmd("git", ["add", "hello.txt"], cd: tmp_dir) + System.cmd("git", ["commit", "-m", "modify hello"], cd: tmp_dir) + + # Now apply the patch in reverse to undo the change + assert {:ok, :done} = Git.apply_patch(patch: patch_path, reverse: true, config: config) + + assert File.read!(Path.join(tmp_dir, "hello.txt")) == "hello\n" + end + + test "applies a patch to the index with --cached", %{tmp_dir: tmp_dir, config: config} do + File.write!(Path.join(tmp_dir, "hello.txt"), "hello world\n") + + {patch_content, 0} = System.cmd("git", ["diff"], cd: tmp_dir) + patch_path = Path.join(tmp_dir, "change.patch") + File.write!(patch_path, patch_content) + + System.cmd("git", ["checkout", "--", "hello.txt"], cd: tmp_dir) + + assert {:ok, :done} = Git.apply_patch(patch: patch_path, cached: true, config: config) + + # The index should have the change but working tree should not + {staged, 0} = System.cmd("git", ["diff", "--cached", "--name-only"], cd: tmp_dir) + assert staged =~ "hello.txt" + end + end + + describe "apply failure" do + test "returns error for a patch that does not apply", %{tmp_dir: tmp_dir, config: config} do + # Create a patch that won't apply + bad_patch = """ + --- a/nonexistent.txt + +++ b/nonexistent.txt + @@ -1 +1 @@ + -old content + +new content + """ + + patch_path = Path.join(tmp_dir, "bad.patch") + File.write!(patch_path, bad_patch) + + assert {:error, {output, exit_code}} = + Git.apply_patch(patch: patch_path, config: config) + + assert exit_code != 0 + assert is_binary(output) + end + end +end