diff --git a/lib/git/changes.ex b/lib/git/changes.ex index 915bafc..6869196 100644 --- a/lib/git/changes.ex +++ b/lib/git/changes.ex @@ -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 # --------------------------------------------------------------------------- diff --git a/lib/git/conflicts.ex b/lib/git/conflicts.ex new file mode 100644 index 0000000..36fb6ce --- /dev/null +++ b/lib/git/conflicts.ex @@ -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 diff --git a/lib/git/patch.ex b/lib/git/patch.ex new file mode 100644 index 0000000..cee8c21 --- /dev/null +++ b/lib/git/patch.ex @@ -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 diff --git a/lib/git/remotes.ex b/lib/git/remotes.ex new file mode 100644 index 0000000..b8391ff --- /dev/null +++ b/lib/git/remotes.ex @@ -0,0 +1,99 @@ +defmodule Git.Remotes do + @moduledoc """ + Higher-level remote management helpers that compose lower-level `Git` functions. + + Provides convenience functions for listing, adding, removing, and updating + git remotes. + + 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 """ + Lists remotes with their URLs. + + Delegates to `Git.remote/1` which returns verbose output by default. + + Returns `{:ok, [Git.Remote.t()]}`. + """ + @spec list_detailed(keyword()) :: {:ok, [Git.Remote.t()]} | {:error, term()} + def list_detailed(opts \\ []) do + {config, _rest} = extract_config(opts) + Git.remote(config: config) + end + + @doc """ + Adds a remote. + + Uses `Git.remote(add_name: name, add_url: url)`. + + Returns `{:ok, :done}` on success. + """ + @spec add(String.t(), String.t(), keyword()) :: {:ok, :done} | {:error, term()} + def add(name, url, opts \\ []) do + {config, _rest} = extract_config(opts) + Git.remote(add_name: name, add_url: url, config: config) + end + + @doc """ + Removes a remote. + + Uses `Git.remote(remove: name)`. + + Returns `{:ok, :done}` on success. + """ + @spec remove(String.t(), keyword()) :: {:ok, :done} | {:error, term()} + def remove(name, opts \\ []) do + {config, _rest} = extract_config(opts) + Git.remote(remove: name, config: config) + end + + @doc """ + Updates the URL of an existing remote. + + Uses `git remote set-url` via raw `System.cmd` since the command module + does not expose a set-url option. + + Returns `{:ok, :done}` on success. + """ + @spec set_url(String.t(), String.t(), keyword()) :: {:ok, :done} | {:error, term()} + def set_url(name, url, opts \\ []) do + {config, _rest} = extract_config(opts) + + args = ["remote", "set-url", name, url] + cmd_opts = Config.cmd_opts(config) + + case System.cmd(config.binary, args, cmd_opts) do + {_stdout, 0} -> {:ok, :done} + {stdout, exit_code} -> {:error, {stdout, exit_code}} + end + end + + @doc """ + Prunes stale remote-tracking branches for a remote. + + Uses `Git.fetch(remote: name, prune: true)`. + + Returns `{:ok, :done}` on success. + """ + @spec prune(String.t(), keyword()) :: {:ok, :done} | {:error, term()} + def prune(name, opts \\ []) do + {config, _rest} = extract_config(opts) + Git.fetch(remote: name, prune: true, config: config) + end + + # --------------------------------------------------------------------------- + # Private helpers + # --------------------------------------------------------------------------- + + defp extract_config(opts) do + Keyword.pop(opts, :config, Config.new()) + end +end diff --git a/lib/git/stashes.ex b/lib/git/stashes.ex new file mode 100644 index 0000000..5fe1beb --- /dev/null +++ b/lib/git/stashes.ex @@ -0,0 +1,171 @@ +defmodule Git.Stashes do + @moduledoc """ + Higher-level stash management operations that compose the lower-level + `Git.stash/1` command. + + 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 """ + Saves current changes to the stash with a message. + + Delegates to `Git.stash(save: true, message: message)`. + + ## Options + + * `:config` - a `Git.Config` struct + + ## Examples + + {:ok, :done} = Git.Stashes.save("work in progress") + + """ + @spec save(String.t(), keyword()) :: {:ok, :done} | {:error, term()} + def save(message, opts \\ []) do + {config, rest} = extract_config(opts) + Git.stash([{:save, true}, {:message, message}, {:config, config} | rest]) + end + + @doc """ + Pops the latest stash entry, applying it and removing it from the stash list. + + Delegates to `Git.stash(pop: true)`. + + ## Options + + * `:config` - a `Git.Config` struct + + ## Examples + + {:ok, :done} = Git.Stashes.pop() + + """ + @spec pop(keyword()) :: {:ok, :done} | {:error, term()} + def pop(opts \\ []) do + {config, rest} = extract_config(opts) + Git.stash([{:pop, true}, {:config, config} | rest]) + end + + @doc """ + Applies the latest stash (or a specific stash by index) without removing it. + + Uses raw `git stash apply` since the underlying command module does not + support the apply subcommand directly. + + ## Options + + * `:config` - a `Git.Config` struct + * `:index` - stash index to apply (e.g., `1` for `stash@{1}`) + + ## Examples + + {:ok, :done} = Git.Stashes.apply() + {:ok, :done} = Git.Stashes.apply(index: 2) + + """ + @spec apply(keyword()) :: {:ok, :done} | {:error, term()} + def apply(opts \\ []) do + {config, rest} = extract_config(opts) + {index, _rest} = Keyword.pop(rest, :index) + + args = ["stash", "apply"] ++ stash_ref(index) + cmd_opts = Config.cmd_opts(config) + + case System.cmd(config.binary, args, cmd_opts) do + {_stdout, 0} -> {:ok, :done} + {stdout, exit_code} -> {:error, {stdout, exit_code}} + end + end + + @doc """ + Lists all stash entries. + + Delegates to `Git.stash(list: true)`. + + ## Options + + * `:config` - a `Git.Config` struct + + ## Examples + + {:ok, entries} = Git.Stashes.list() + + """ + @spec list(keyword()) :: {:ok, [Git.StashEntry.t()]} | {:error, term()} + def list(opts \\ []) do + {config, _rest} = extract_config(opts) + Git.stash(list: true, config: config) + end + + @doc """ + Drops a specific stash entry. + + Delegates to `Git.stash(drop: true)`. + + ## Options + + * `:config` - a `Git.Config` struct + * `:index` - stash index to drop (e.g., `1` for `stash@{1}`) + + ## Examples + + {:ok, :done} = Git.Stashes.drop() + {:ok, :done} = Git.Stashes.drop(index: 1) + + """ + @spec drop(keyword()) :: {:ok, :done} | {:error, term()} + def drop(opts \\ []) do + {config, rest} = extract_config(opts) + {index, _rest} = Keyword.pop(rest, :index) + Git.stash([{:drop, true}, {:config, config}] ++ stash_index(index)) + end + + @doc """ + Clears all stash entries. + + Uses raw `git stash clear` since the underlying command module does not + support the clear subcommand directly. + + ## Options + + * `:config` - a `Git.Config` struct + + ## Examples + + {:ok, :done} = Git.Stashes.clear() + + """ + @spec clear(keyword()) :: {:ok, :done} | {:error, term()} + def clear(opts \\ []) do + {config, _rest} = extract_config(opts) + + cmd_opts = Config.cmd_opts(config) + + case System.cmd(config.binary, ["stash", "clear"], cmd_opts) do + {_stdout, 0} -> {:ok, :done} + {stdout, exit_code} -> {:error, {stdout, exit_code}} + end + end + + # --------------------------------------------------------------------------- + # Private helpers + # --------------------------------------------------------------------------- + + defp extract_config(opts) do + Keyword.pop(opts, :config, Config.new()) + end + + defp stash_ref(nil), do: [] + defp stash_ref(index) when is_integer(index), do: ["stash@{#{index}}"] + + defp stash_index(nil), do: [] + defp stash_index(index) when is_integer(index), do: [{:index, index}] +end diff --git a/lib/git/tags.ex b/lib/git/tags.ex new file mode 100644 index 0000000..9e85fc8 --- /dev/null +++ b/lib/git/tags.ex @@ -0,0 +1,155 @@ +defmodule Git.Tags do + @moduledoc """ + Higher-level tag management helpers that compose lower-level `Git` functions. + + Provides convenience functions for creating, listing, sorting, and querying + tags. + + 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 a tag. + + Delegates to `Git.tag/1` with the `:create` option. Supports `:message` + for annotated tags and `:ref` for tagging a specific commit. + + ## Options + + * `:message` - annotation message (creates an annotated tag) + * `:ref` - commit to tag (default: HEAD) + * `:config` - a `Git.Config` struct + + Returns `{:ok, :done}` on success. + """ + @spec create(String.t(), keyword()) :: {:ok, :done} | {:error, term()} + def create(name, opts \\ []) do + {config, rest} = extract_config(opts) + tag_opts = [{:create, name}, {:config, config} | rest] + Git.tag(tag_opts) + end + + @doc """ + Lists all tags with detailed information. + + Delegates to `Git.tag/1` with no create/delete options. + + Returns `{:ok, [Git.Tag.t()]}`. + """ + @spec list(keyword()) :: {:ok, [Git.Tag.t()]} | {:error, term()} + def list(opts \\ []) do + {config, _rest} = extract_config(opts) + Git.tag(config: config) + end + + @doc """ + Returns the most recent tag reachable from HEAD. + + Uses `Git.describe(tags: true, abbrev: 0)` to find the latest tag. + + Returns `{:ok, String.t()}` with the tag name, or `{:error, term()}` if + no tags exist. + """ + @spec latest(keyword()) :: {:ok, String.t()} | {:error, term()} + def latest(opts \\ []) do + {config, _rest} = extract_config(opts) + Git.describe(tags: true, abbrev: 0, config: config) + end + + @doc """ + Returns tags sorted by semantic version. + + Lists all tags, then sorts them using `Version.parse/1` where possible. + Tags that are not valid semver are sorted lexicographically and placed + after the versioned tags. + + Returns `{:ok, [Git.Tag.t()]}`. + """ + @spec sorted(keyword()) :: {:ok, [Git.Tag.t()]} | {:error, term()} + def sorted(opts \\ []) do + {config, _rest} = extract_config(opts) + + case Git.tag(config: config) do + {:ok, tags} -> + {:ok, sort_by_version(tags)} + + error -> + error + end + end + + @doc """ + Deletes a tag. + + Delegates to `Git.tag(delete: name)`. + + Returns `{:ok, :done}` on success. + """ + @spec delete(String.t(), keyword()) :: {:ok, :done} | {:error, term()} + def delete(name, opts \\ []) do + {config, _rest} = extract_config(opts) + Git.tag(delete: name, config: config) + end + + @doc """ + Checks whether a tag exists. + + Lists all tags and checks if the given name is present. + + Returns `{:ok, true}` or `{:ok, false}`. + """ + @spec exists?(String.t(), keyword()) :: {:ok, boolean()} | {:error, term()} + def exists?(name, opts \\ []) do + {config, _rest} = extract_config(opts) + + case Git.tag(config: config) do + {:ok, tags} -> + {:ok, Enum.any?(tags, fn tag -> tag.name == name end)} + + error -> + error + end + end + + # --------------------------------------------------------------------------- + # Private helpers + # --------------------------------------------------------------------------- + + defp extract_config(opts) do + Keyword.pop(opts, :config, Config.new()) + end + + defp sort_by_version(tags) do + {versioned, non_versioned} = + Enum.split_with(tags, fn tag -> + tag.name + |> strip_v_prefix() + |> Version.parse() + |> case do + {:ok, _} -> true + :error -> false + end + end) + + sorted_versioned = + Enum.sort_by(versioned, fn tag -> + {:ok, version} = tag.name |> strip_v_prefix() |> Version.parse() + version + end) + + sorted_non_versioned = Enum.sort_by(non_versioned, & &1.name) + + sorted_versioned ++ sorted_non_versioned + end + + defp strip_v_prefix("v" <> rest), do: rest + defp strip_v_prefix(name), do: name +end diff --git a/test/git/changes_test.exs b/test/git/changes_test.exs index 6c64242..7d85a2c 100644 --- a/test/git/changes_test.exs +++ b/test/git/changes_test.exs @@ -197,4 +197,85 @@ defmodule Git.ChangesTest do assert length(summary.files) == 2 end end + + # --------------------------------------------------------------------------- + # staged/1 + # --------------------------------------------------------------------------- + + describe "staged/1" do + test "returns diff of staged changes" do + {dir, cfg} = setup_repo("changes_staged") + + write_and_commit(dir, cfg, "file.txt", "line1\n", "add file") + + # Modify and stage the file + File.write!(Path.join(dir, "file.txt"), "line1\nline2\n") + {:ok, :done} = Git.add(files: ["file.txt"], config: cfg) + + assert {:ok, %Git.Diff{} = diff} = Git.Changes.staged(config: cfg) + assert diff.total_insertions > 0 or diff.files != [] + end + + test "returns empty diff when nothing is staged" do + {_dir, cfg} = setup_repo("changes_staged_empty") + + assert {:ok, %Git.Diff{} = diff} = Git.Changes.staged(config: cfg) + assert diff.files == [] + end + end + + # --------------------------------------------------------------------------- + # unstaged/1 + # --------------------------------------------------------------------------- + + describe "unstaged/1" do + test "returns diff of unstaged changes" do + {dir, cfg} = setup_repo("changes_unstaged") + + write_and_commit(dir, cfg, "file.txt", "line1\n", "add file") + + # Modify without staging + File.write!(Path.join(dir, "file.txt"), "line1\nline2\n") + + assert {:ok, %Git.Diff{} = diff} = Git.Changes.unstaged(config: cfg) + assert diff.total_insertions > 0 or diff.files != [] + end + + test "returns empty diff when working tree is clean" do + {_dir, cfg} = setup_repo("changes_unstaged_empty") + + assert {:ok, %Git.Diff{} = diff} = Git.Changes.unstaged(config: cfg) + assert diff.files == [] + end + end + + # --------------------------------------------------------------------------- + # stats/1 + # --------------------------------------------------------------------------- + + describe "stats/1" do + test "returns summary statistics of unstaged changes" do + {dir, cfg} = setup_repo("changes_stats") + + write_and_commit(dir, cfg, "file.txt", "line1\n", "add file") + + # Modify without staging + File.write!(Path.join(dir, "file.txt"), "line1\nline2\nline3\n") + + assert {:ok, stats} = Git.Changes.stats(config: cfg) + assert is_integer(stats.files_changed) + assert is_integer(stats.insertions) + assert is_integer(stats.deletions) + assert stats.files_changed >= 1 + end + + test "returns zeroes on clean working tree" do + {_dir, cfg} = setup_repo("changes_stats_clean") + + assert {:ok, stats} = Git.Changes.stats(config: cfg) + assert stats.files_changed == 0 + assert stats.insertions == 0 + assert stats.deletions == 0 + end + end end diff --git a/test/git/conflicts_test.exs b/test/git/conflicts_test.exs new file mode 100644 index 0000000..838c374 --- /dev/null +++ b/test/git/conflicts_test.exs @@ -0,0 +1,144 @@ +defmodule Git.ConflictsTest do + use ExUnit.Case, async: true + + @git_env [ + {"GIT_AUTHOR_NAME", "Test User"}, + {"GIT_AUTHOR_EMAIL", "test@example.com"}, + {"GIT_COMMITTER_NAME", "Test User"}, + {"GIT_COMMITTER_EMAIL", "test@example.com"} + ] + + setup do + tmp_dir = + Path.join( + System.tmp_dir!(), + "git_conflicts_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.email", "test@example.com"], cd: tmp_dir) + System.cmd("git", ["config", "user.name", "Test User"], cd: tmp_dir) + + # Initial commit with a file + File.write!(Path.join(tmp_dir, "shared.txt"), "initial content") + System.cmd("git", ["add", "."], cd: tmp_dir) + System.cmd("git", ["commit", "-m", "initial commit"], cd: tmp_dir, env: @git_env) + + on_exit(fn -> File.rm_rf!(tmp_dir) end) + + config = Git.Config.new(working_dir: tmp_dir, env: @git_env) + + %{tmp_dir: tmp_dir, config: config} + end + + # Creates a merge conflict by modifying the same file on two branches + defp create_conflict(tmp_dir) do + # Create a branch and modify the file + System.cmd("git", ["checkout", "-b", "conflict-branch"], cd: tmp_dir) + File.write!(Path.join(tmp_dir, "shared.txt"), "branch version") + System.cmd("git", ["add", "shared.txt"], cd: tmp_dir) + System.cmd("git", ["commit", "-m", "branch change"], cd: tmp_dir, env: @git_env) + + # Switch back to main and modify the same file differently + System.cmd("git", ["checkout", "main"], cd: tmp_dir) + File.write!(Path.join(tmp_dir, "shared.txt"), "main version") + System.cmd("git", ["add", "shared.txt"], cd: tmp_dir) + System.cmd("git", ["commit", "-m", "main change"], cd: tmp_dir, env: @git_env) + + # Attempt merge (will fail with conflict) + System.cmd("git", ["merge", "conflict-branch"], cd: tmp_dir, env: @git_env) + end + + describe "detect/1" do + test "returns false when no conflicts exist", %{config: config} do + assert {:ok, false} = Git.Conflicts.detect(config: config) + end + + test "returns true when merge conflicts exist", %{tmp_dir: tmp_dir, config: config} do + create_conflict(tmp_dir) + + assert {:ok, true} = Git.Conflicts.detect(config: config) + end + end + + describe "files/1" do + test "returns empty list when no conflicts exist", %{config: config} do + assert {:ok, []} = Git.Conflicts.files(config: config) + end + + test "lists conflicted files", %{tmp_dir: tmp_dir, config: config} do + create_conflict(tmp_dir) + + assert {:ok, files} = Git.Conflicts.files(config: config) + assert "shared.txt" in files + end + + test "lists multiple conflicted files", %{tmp_dir: tmp_dir, config: config} do + # Add a second file to the initial commit + File.write!(Path.join(tmp_dir, "other.txt"), "initial other") + System.cmd("git", ["add", "other.txt"], cd: tmp_dir) + System.cmd("git", ["commit", "-m", "add other file"], cd: tmp_dir, env: @git_env) + + # Create branch, modify both files + System.cmd("git", ["checkout", "-b", "multi-conflict"], cd: tmp_dir) + File.write!(Path.join(tmp_dir, "shared.txt"), "branch shared") + File.write!(Path.join(tmp_dir, "other.txt"), "branch other") + System.cmd("git", ["add", "."], cd: tmp_dir) + System.cmd("git", ["commit", "-m", "branch changes"], cd: tmp_dir, env: @git_env) + + # Back to main, modify same files differently + System.cmd("git", ["checkout", "main"], cd: tmp_dir) + File.write!(Path.join(tmp_dir, "shared.txt"), "main shared") + File.write!(Path.join(tmp_dir, "other.txt"), "main other") + System.cmd("git", ["add", "."], cd: tmp_dir) + System.cmd("git", ["commit", "-m", "main changes"], cd: tmp_dir, env: @git_env) + + # Merge + System.cmd("git", ["merge", "multi-conflict"], cd: tmp_dir, env: @git_env) + + assert {:ok, files} = Git.Conflicts.files(config: config) + assert length(files) == 2 + assert "shared.txt" in files + assert "other.txt" in files + end + end + + describe "resolved?/1" do + test "returns true when no conflicts exist", %{config: config} do + assert {:ok, true} = Git.Conflicts.resolved?(config: config) + end + + test "returns false when conflicts exist", %{tmp_dir: tmp_dir, config: config} do + create_conflict(tmp_dir) + + assert {:ok, false} = Git.Conflicts.resolved?(config: config) + end + + test "returns true after conflicts are resolved", %{tmp_dir: tmp_dir, config: config} do + create_conflict(tmp_dir) + + # Resolve the conflict by writing a resolved version and adding it + File.write!(Path.join(tmp_dir, "shared.txt"), "resolved content") + System.cmd("git", ["add", "shared.txt"], cd: tmp_dir) + + assert {:ok, true} = Git.Conflicts.resolved?(config: config) + end + end + + describe "abort_merge/1" do + test "aborts a conflicted merge", %{tmp_dir: tmp_dir, config: config} do + create_conflict(tmp_dir) + + assert {:ok, true} = Git.Conflicts.detect(config: config) + + assert {:ok, :done} = Git.Conflicts.abort_merge(config: config) + + # After abort, the file should be back to the main version + assert File.read!(Path.join(tmp_dir, "shared.txt")) == "main version" + + # No more conflicts + assert {:ok, false} = Git.Conflicts.detect(config: config) + end + end +end diff --git a/test/git/patch_test.exs b/test/git/patch_test.exs new file mode 100644 index 0000000..58491a5 --- /dev/null +++ b/test/git/patch_test.exs @@ -0,0 +1,143 @@ +defmodule Git.PatchTest do + use ExUnit.Case, async: true + + @git_env [ + {"GIT_AUTHOR_NAME", "Test User"}, + {"GIT_AUTHOR_EMAIL", "test@example.com"}, + {"GIT_COMMITTER_NAME", "Test User"}, + {"GIT_COMMITTER_EMAIL", "test@example.com"} + ] + + setup do + tmp_dir = + Path.join( + System.tmp_dir!(), + "git_patch_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.email", "test@example.com"], cd: tmp_dir) + System.cmd("git", ["config", "user.name", "Test User"], cd: tmp_dir) + + # Initial commit + File.write!(Path.join(tmp_dir, "README.md"), "initial") + System.cmd("git", ["add", "."], cd: tmp_dir) + System.cmd("git", ["commit", "-m", "initial commit"], cd: tmp_dir, env: @git_env) + + on_exit(fn -> File.rm_rf!(tmp_dir) end) + + config = Git.Config.new(working_dir: tmp_dir, env: @git_env) + + %{tmp_dir: tmp_dir, config: config} + end + + defp write_and_commit(tmp_dir, filename, content, message) do + File.write!(Path.join(tmp_dir, filename), content) + System.cmd("git", ["add", filename], cd: tmp_dir) + System.cmd("git", ["commit", "-m", message], cd: tmp_dir, env: @git_env) + end + + describe "create/2" do + test "creates patch files from commits", %{tmp_dir: tmp_dir, config: config} do + write_and_commit(tmp_dir, "feature.ex", "defmodule Feature, do: end", "feat: add feature") + + patch_dir = Path.join(tmp_dir, "patches") + File.mkdir_p!(patch_dir) + + assert {:ok, files} = + Git.Patch.create("HEAD~1", output_directory: patch_dir, config: config) + + assert length(files) == 1 + [patch_file] = files + assert File.exists?(patch_file) + end + + test "creates multiple patches for multiple commits", %{tmp_dir: tmp_dir, config: config} do + write_and_commit(tmp_dir, "a.ex", "a", "feat: add a") + write_and_commit(tmp_dir, "b.ex", "b", "feat: add b") + + patch_dir = Path.join(tmp_dir, "patches") + File.mkdir_p!(patch_dir) + + assert {:ok, files} = + Git.Patch.create("HEAD~2", output_directory: patch_dir, config: config) + + assert length(files) == 2 + end + end + + describe "apply/2" do + test "applies a patch file", %{tmp_dir: tmp_dir, config: config} do + write_and_commit(tmp_dir, "patched.ex", "original", "feat: add file") + + patch_dir = Path.join(tmp_dir, "patches") + File.mkdir_p!(patch_dir) + + {:ok, [patch_file]} = + Git.Patch.create("HEAD~1", output_directory: patch_dir, config: config) + + # Reset to before the commit to apply the patch + System.cmd("git", ["reset", "--hard", "HEAD~1"], cd: tmp_dir) + + refute File.exists?(Path.join(tmp_dir, "patched.ex")) + + assert {:ok, :done} = Git.Patch.apply(patch_file, config: config) + + assert File.exists?(Path.join(tmp_dir, "patched.ex")) + end + end + + describe "apply_mailbox/2" do + test "applies mailbox-formatted patches", %{tmp_dir: tmp_dir, config: config} do + write_and_commit(tmp_dir, "am_file.ex", "content", "feat: add am file") + + patch_dir = Path.join(tmp_dir, "patches") + File.mkdir_p!(patch_dir) + + {:ok, [patch_file]} = + Git.Patch.create("HEAD~1", output_directory: patch_dir, config: config) + + # Reset to before the commit + System.cmd("git", ["reset", "--hard", "HEAD~1"], cd: tmp_dir) + + assert {:ok, :done} = Git.Patch.apply_mailbox([patch_file], config: config) + + assert File.exists?(Path.join(tmp_dir, "am_file.ex")) + end + end + + describe "check/2" do + test "returns ok when patch applies cleanly", %{tmp_dir: tmp_dir, config: config} do + write_and_commit(tmp_dir, "check_file.ex", "check content", "feat: add check file") + + patch_dir = Path.join(tmp_dir, "patches") + File.mkdir_p!(patch_dir) + + {:ok, [patch_file]} = + Git.Patch.create("HEAD~1", output_directory: patch_dir, config: config) + + # Reset to before the commit + System.cmd("git", ["reset", "--hard", "HEAD~1"], cd: tmp_dir) + + assert {:ok, _output} = Git.Patch.check(patch_file, config: config) + end + + test "returns error when patch does not apply cleanly", %{tmp_dir: tmp_dir, config: config} do + write_and_commit(tmp_dir, "conflict.ex", "original", "feat: add file") + + patch_dir = Path.join(tmp_dir, "patches") + File.mkdir_p!(patch_dir) + + {:ok, [patch_file]} = + Git.Patch.create("HEAD~1", output_directory: patch_dir, config: config) + + # Modify the file so the patch won't apply cleanly + File.write!(Path.join(tmp_dir, "conflict.ex"), "completely different content") + System.cmd("git", ["add", "conflict.ex"], cd: tmp_dir) + System.cmd("git", ["commit", "-m", "change file"], cd: tmp_dir, env: @git_env) + + assert {:error, _reason} = Git.Patch.check(patch_file, config: config) + end + end +end diff --git a/test/git/remotes_test.exs b/test/git/remotes_test.exs new file mode 100644 index 0000000..49e8bbc --- /dev/null +++ b/test/git/remotes_test.exs @@ -0,0 +1,146 @@ +defmodule Git.RemotesTest do + use ExUnit.Case, async: true + + @git_env [ + {"GIT_AUTHOR_NAME", "Test User"}, + {"GIT_AUTHOR_EMAIL", "test@example.com"}, + {"GIT_COMMITTER_NAME", "Test User"}, + {"GIT_COMMITTER_EMAIL", "test@example.com"} + ] + + defp setup_repo(name) do + tmp_dir = + Path.join( + System.tmp_dir!(), + "git_#{name}_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) + + System.cmd("git", ["commit", "--allow-empty", "-m", "initial"], + cd: tmp_dir, + env: @git_env + ) + + cfg = Git.Config.new(working_dir: tmp_dir, env: @git_env) + + on_exit(fn -> File.rm_rf!(tmp_dir) end) + {tmp_dir, cfg} + end + + defp setup_bare_remote(name) do + bare_dir = + Path.join( + System.tmp_dir!(), + "git_#{name}_bare_#{:erlang.unique_integer([:positive])}" + ) + + File.mkdir_p!(bare_dir) + System.cmd("git", ["init", "--bare"], cd: bare_dir) + + on_exit(fn -> File.rm_rf!(bare_dir) end) + bare_dir + end + + # --------------------------------------------------------------------------- + # list_detailed/1 + # --------------------------------------------------------------------------- + + describe "list_detailed/1" do + test "returns empty list when no remotes" do + {_dir, cfg} = setup_repo("remotes_list_empty") + + assert {:ok, []} = Git.Remotes.list_detailed(config: cfg) + end + + test "returns remotes with URLs" do + {dir, cfg} = setup_repo("remotes_list") + bare = setup_bare_remote("remotes_list_bare") + + System.cmd("git", ["remote", "add", "origin", bare], cd: dir) + + assert {:ok, remotes} = Git.Remotes.list_detailed(config: cfg) + assert length(remotes) == 1 + remote = hd(remotes) + assert remote.name == "origin" + assert remote.fetch_url == bare + assert remote.push_url == bare + end + end + + # --------------------------------------------------------------------------- + # add/3 + # --------------------------------------------------------------------------- + + describe "add/3" do + test "adds a remote" do + {dir, cfg} = setup_repo("remotes_add") + bare = setup_bare_remote("remotes_add_bare") + + assert {:ok, :done} = Git.Remotes.add("origin", bare, config: cfg) + + {output, 0} = System.cmd("git", ["remote", "-v"], cd: dir) + assert String.contains?(output, "origin") + assert String.contains?(output, bare) + end + end + + # --------------------------------------------------------------------------- + # remove/2 + # --------------------------------------------------------------------------- + + describe "remove/2" do + test "removes a remote" do + {dir, cfg} = setup_repo("remotes_remove") + bare = setup_bare_remote("remotes_remove_bare") + + System.cmd("git", ["remote", "add", "origin", bare], cd: dir) + + assert {:ok, :done} = Git.Remotes.remove("origin", config: cfg) + + {output, 0} = System.cmd("git", ["remote"], cd: dir) + refute String.contains?(output, "origin") + end + end + + # --------------------------------------------------------------------------- + # set_url/3 + # --------------------------------------------------------------------------- + + describe "set_url/3" do + test "updates the URL of an existing remote" do + {dir, cfg} = setup_repo("remotes_set_url") + bare1 = setup_bare_remote("remotes_set_url_bare1") + bare2 = setup_bare_remote("remotes_set_url_bare2") + + System.cmd("git", ["remote", "add", "origin", bare1], cd: dir) + + assert {:ok, :done} = Git.Remotes.set_url("origin", bare2, config: cfg) + + {output, 0} = System.cmd("git", ["remote", "-v"], cd: dir) + assert String.contains?(output, bare2) + refute String.contains?(output, bare1) + end + end + + # --------------------------------------------------------------------------- + # prune/2 + # --------------------------------------------------------------------------- + + describe "prune/2" do + test "prunes stale remote-tracking branches" do + {dir, cfg} = setup_repo("remotes_prune") + bare = setup_bare_remote("remotes_prune_bare") + + # Push to bare so fetch has something to work with + System.cmd("git", ["remote", "add", "origin", bare], cd: dir) + System.cmd("git", ["push", "-u", "origin", "main"], cd: dir, env: @git_env) + + # Prune should succeed even when nothing is stale + assert {:ok, :done} = Git.Remotes.prune("origin", config: cfg) + end + end +end diff --git a/test/git/stashes_test.exs b/test/git/stashes_test.exs new file mode 100644 index 0000000..8f8a34f --- /dev/null +++ b/test/git/stashes_test.exs @@ -0,0 +1,174 @@ +defmodule Git.StashesTest do + use ExUnit.Case, async: true + + @git_env [ + {"GIT_AUTHOR_NAME", "Test User"}, + {"GIT_AUTHOR_EMAIL", "test@example.com"}, + {"GIT_COMMITTER_NAME", "Test User"}, + {"GIT_COMMITTER_EMAIL", "test@example.com"} + ] + + setup do + tmp_dir = + Path.join( + System.tmp_dir!(), + "git_stashes_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.email", "test@example.com"], cd: tmp_dir) + System.cmd("git", ["config", "user.name", "Test User"], cd: tmp_dir) + + # Need at least one commit for stash to work + File.write!(Path.join(tmp_dir, "README.md"), "initial") + System.cmd("git", ["add", "."], cd: tmp_dir) + System.cmd("git", ["commit", "-m", "initial commit"], cd: tmp_dir, env: @git_env) + + on_exit(fn -> File.rm_rf!(tmp_dir) end) + + config = Git.Config.new(working_dir: tmp_dir, env: @git_env) + + %{tmp_dir: tmp_dir, config: config} + end + + defp make_dirty(tmp_dir) do + File.write!(Path.join(tmp_dir, "dirty.txt"), "uncommitted changes") + System.cmd("git", ["add", "dirty.txt"], cd: tmp_dir) + end + + describe "save/2" do + test "saves current changes with a message", %{tmp_dir: tmp_dir, config: config} do + make_dirty(tmp_dir) + + assert {:ok, :done} = Git.Stashes.save("work in progress", config: config) + + # Verify stash was created + assert {:ok, entries} = Git.Stashes.list(config: config) + assert length(entries) == 1 + end + end + + describe "list/1" do + test "returns empty list when no stashes exist", %{config: config} do + assert {:ok, []} = Git.Stashes.list(config: config) + end + + test "lists stash entries after save", %{tmp_dir: tmp_dir, config: config} do + make_dirty(tmp_dir) + Git.Stashes.save("first stash", config: config) + + # Create another dirty file and stash it + File.write!(Path.join(tmp_dir, "another.txt"), "more changes") + System.cmd("git", ["add", "another.txt"], cd: tmp_dir) + Git.Stashes.save("second stash", config: config) + + assert {:ok, entries} = Git.Stashes.list(config: config) + assert length(entries) == 2 + end + end + + describe "pop/1" do + test "pops the latest stash entry", %{tmp_dir: tmp_dir, config: config} do + make_dirty(tmp_dir) + Git.Stashes.save("to pop", config: config) + + # File should be gone after stash + refute File.exists?(Path.join(tmp_dir, "dirty.txt")) + + assert {:ok, :done} = Git.Stashes.pop(config: config) + + # File should be restored + assert File.exists?(Path.join(tmp_dir, "dirty.txt")) + + # Stash should be empty + assert {:ok, []} = Git.Stashes.list(config: config) + end + end + + describe "apply/1" do + test "applies the latest stash without removing it", %{tmp_dir: tmp_dir, config: config} do + make_dirty(tmp_dir) + Git.Stashes.save("to apply", config: config) + + refute File.exists?(Path.join(tmp_dir, "dirty.txt")) + + assert {:ok, :done} = Git.Stashes.apply(config: config) + + # File should be restored + assert File.exists?(Path.join(tmp_dir, "dirty.txt")) + + # Stash should still exist (apply does not remove it) + assert {:ok, entries} = Git.Stashes.list(config: config) + assert length(entries) == 1 + end + + test "applies a specific stash by index", %{tmp_dir: tmp_dir, config: config} do + # Create first stash + File.write!(Path.join(tmp_dir, "first.txt"), "first") + System.cmd("git", ["add", "first.txt"], cd: tmp_dir) + Git.Stashes.save("first", config: config) + + # Create second stash + File.write!(Path.join(tmp_dir, "second.txt"), "second") + System.cmd("git", ["add", "second.txt"], cd: tmp_dir) + Git.Stashes.save("second", config: config) + + # Apply stash@{1} (the first/older stash) + assert {:ok, :done} = Git.Stashes.apply(index: 1, config: config) + + assert File.exists?(Path.join(tmp_dir, "first.txt")) + end + end + + describe "drop/1" do + test "drops the latest stash entry", %{tmp_dir: tmp_dir, config: config} do + make_dirty(tmp_dir) + Git.Stashes.save("to drop", config: config) + + assert {:ok, entries} = Git.Stashes.list(config: config) + assert length(entries) == 1 + + assert {:ok, :done} = Git.Stashes.drop(config: config) + + assert {:ok, []} = Git.Stashes.list(config: config) + end + + test "drops a specific stash by index", %{tmp_dir: tmp_dir, config: config} do + # Create two stashes + File.write!(Path.join(tmp_dir, "a.txt"), "a") + System.cmd("git", ["add", "a.txt"], cd: tmp_dir) + Git.Stashes.save("stash a", config: config) + + File.write!(Path.join(tmp_dir, "b.txt"), "b") + System.cmd("git", ["add", "b.txt"], cd: tmp_dir) + Git.Stashes.save("stash b", config: config) + + # Drop stash@{1} (the older one) + assert {:ok, :done} = Git.Stashes.drop(index: 1, config: config) + + assert {:ok, entries} = Git.Stashes.list(config: config) + assert length(entries) == 1 + end + end + + describe "clear/1" do + test "clears all stash entries", %{tmp_dir: tmp_dir, config: config} do + # Create multiple stashes + File.write!(Path.join(tmp_dir, "x.txt"), "x") + System.cmd("git", ["add", "x.txt"], cd: tmp_dir) + Git.Stashes.save("stash x", config: config) + + File.write!(Path.join(tmp_dir, "y.txt"), "y") + System.cmd("git", ["add", "y.txt"], cd: tmp_dir) + Git.Stashes.save("stash y", config: config) + + assert {:ok, entries} = Git.Stashes.list(config: config) + assert length(entries) == 2 + + assert {:ok, :done} = Git.Stashes.clear(config: config) + + assert {:ok, []} = Git.Stashes.list(config: config) + end + end +end diff --git a/test/git/tags_test.exs b/test/git/tags_test.exs new file mode 100644 index 0000000..5fe2acb --- /dev/null +++ b/test/git/tags_test.exs @@ -0,0 +1,198 @@ +defmodule Git.TagsTest do + use ExUnit.Case, async: true + + @git_env [ + {"GIT_AUTHOR_NAME", "Test User"}, + {"GIT_AUTHOR_EMAIL", "test@example.com"}, + {"GIT_COMMITTER_NAME", "Test User"}, + {"GIT_COMMITTER_EMAIL", "test@example.com"} + ] + + defp setup_repo(name) do + tmp_dir = + Path.join( + System.tmp_dir!(), + "git_#{name}_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) + + System.cmd("git", ["commit", "--allow-empty", "-m", "initial"], + cd: tmp_dir, + env: @git_env + ) + + cfg = Git.Config.new(working_dir: tmp_dir, env: @git_env) + + on_exit(fn -> File.rm_rf!(tmp_dir) end) + {tmp_dir, cfg} + end + + defp write_and_commit(dir, filename, content, msg) do + File.write!(Path.join(dir, filename), content) + System.cmd("git", ["add", filename], cd: dir) + System.cmd("git", ["commit", "-m", msg], cd: dir, env: @git_env) + end + + # --------------------------------------------------------------------------- + # create/2 + # --------------------------------------------------------------------------- + + describe "create/2" do + test "creates a lightweight tag" do + {dir, cfg} = setup_repo("tags_create_light") + write_and_commit(dir, "file.txt", "content\n", "add file") + + assert {:ok, :done} = Git.Tags.create("v1.0.0", config: cfg) + + {output, 0} = System.cmd("git", ["tag", "-l"], cd: dir) + assert String.contains?(output, "v1.0.0") + end + + test "creates an annotated tag with message" do + {dir, cfg} = setup_repo("tags_create_annotated") + write_and_commit(dir, "file.txt", "content\n", "add file") + + assert {:ok, :done} = Git.Tags.create("v1.0.0", message: "release 1.0", config: cfg) + + {output, 0} = System.cmd("git", ["tag", "-l", "-n1"], cd: dir) + assert String.contains?(output, "v1.0.0") + assert String.contains?(output, "release 1.0") + end + + test "creates a tag on a specific ref" do + {dir, cfg} = setup_repo("tags_create_ref") + write_and_commit(dir, "file.txt", "first\n", "first") + {sha1, 0} = System.cmd("git", ["rev-parse", "HEAD"], cd: dir) + sha1 = String.trim(sha1) + + write_and_commit(dir, "file.txt", "second\n", "second") + + assert {:ok, :done} = Git.Tags.create("v0.1.0", ref: sha1, config: cfg) + + {tagged_sha, 0} = System.cmd("git", ["rev-parse", "v0.1.0"], cd: dir) + assert String.trim(tagged_sha) == sha1 + end + end + + # --------------------------------------------------------------------------- + # list/1 + # --------------------------------------------------------------------------- + + describe "list/1" do + test "returns empty list when no tags" do + {_dir, cfg} = setup_repo("tags_list_empty") + + assert {:ok, []} = Git.Tags.list(config: cfg) + end + + test "returns all tags" do + {dir, cfg} = setup_repo("tags_list") + write_and_commit(dir, "file.txt", "content\n", "add file") + + System.cmd("git", ["tag", "v1.0.0"], cd: dir) + System.cmd("git", ["tag", "v2.0.0"], cd: dir) + + assert {:ok, tags} = Git.Tags.list(config: cfg) + names = Enum.map(tags, & &1.name) + assert "v1.0.0" in names + assert "v2.0.0" in names + end + end + + # --------------------------------------------------------------------------- + # latest/1 + # --------------------------------------------------------------------------- + + describe "latest/1" do + test "returns the most recent tag" do + {dir, cfg} = setup_repo("tags_latest") + write_and_commit(dir, "file.txt", "v1\n", "v1") + System.cmd("git", ["tag", "v1.0.0"], cd: dir) + + write_and_commit(dir, "file.txt", "v2\n", "v2") + System.cmd("git", ["tag", "v2.0.0"], cd: dir) + + assert {:ok, "v2.0.0"} = Git.Tags.latest(config: cfg) + end + + test "returns error when no tags exist" do + {_dir, cfg} = setup_repo("tags_latest_none") + + assert {:error, _} = Git.Tags.latest(config: cfg) + end + end + + # --------------------------------------------------------------------------- + # sorted/1 + # --------------------------------------------------------------------------- + + describe "sorted/1" do + test "sorts tags by semantic version" do + {dir, cfg} = setup_repo("tags_sorted") + write_and_commit(dir, "file.txt", "content\n", "add file") + + System.cmd("git", ["tag", "v2.0.0"], cd: dir) + System.cmd("git", ["tag", "v1.0.0"], cd: dir) + System.cmd("git", ["tag", "v1.1.0"], cd: dir) + System.cmd("git", ["tag", "v0.1.0"], cd: dir) + + assert {:ok, tags} = Git.Tags.sorted(config: cfg) + names = Enum.map(tags, & &1.name) + assert names == ["v0.1.0", "v1.0.0", "v1.1.0", "v2.0.0"] + end + + test "non-semver tags are placed after versioned tags" do + {dir, cfg} = setup_repo("tags_sorted_mixed") + write_and_commit(dir, "file.txt", "content\n", "add file") + + System.cmd("git", ["tag", "v1.0.0"], cd: dir) + System.cmd("git", ["tag", "beta"], cd: dir) + System.cmd("git", ["tag", "v0.1.0"], cd: dir) + + assert {:ok, tags} = Git.Tags.sorted(config: cfg) + names = Enum.map(tags, & &1.name) + assert names == ["v0.1.0", "v1.0.0", "beta"] + end + end + + # --------------------------------------------------------------------------- + # delete/2 + # --------------------------------------------------------------------------- + + describe "delete/2" do + test "deletes an existing tag" do + {dir, cfg} = setup_repo("tags_delete") + write_and_commit(dir, "file.txt", "content\n", "add file") + System.cmd("git", ["tag", "v1.0.0"], cd: dir) + + assert {:ok, :done} = Git.Tags.delete("v1.0.0", config: cfg) + + {output, 0} = System.cmd("git", ["tag", "-l"], cd: dir) + refute String.contains?(output, "v1.0.0") + end + end + + # --------------------------------------------------------------------------- + # exists?/2 + # --------------------------------------------------------------------------- + + describe "exists?/2" do + test "returns true when tag exists" do + {dir, cfg} = setup_repo("tags_exists_true") + write_and_commit(dir, "file.txt", "content\n", "add file") + System.cmd("git", ["tag", "v1.0.0"], cd: dir) + + assert {:ok, true} = Git.Tags.exists?("v1.0.0", config: cfg) + end + + test "returns false when tag does not exist" do + {_dir, cfg} = setup_repo("tags_exists_false") + + assert {:ok, false} = Git.Tags.exists?("v1.0.0", config: cfg) + end + end +end