diff --git a/lib/git.ex b/lib/git.ex index 1e5cf6d..f751076 100644 --- a/lib/git.ex +++ b/lib/git.ex @@ -1834,4 +1834,73 @@ defmodule Git do command = struct!(Git.Commands.Am, rest) Git.Command.run(Git.Commands.Am, command, config) end + + @doc """ + Runs `git interpret-trailers` to add or parse trailers in commit messages. + + Trailers are key-value metadata lines at the end of commit messages, such + as "Signed-off-by:" or "Co-authored-by:". + + ## Options + + * `:config` - a `Git.Config` struct (default: `Git.Config.new()`) + * `:file` - path to a file containing the commit message + * `:parse` - only output the trailers (`--only-trailers`) + * `:trailers` - list of trailers to add, each as `"Key: Value"` (`--trailer`) + * `:in_place` - edit the file in place (`--in-place`) + * `:trim_empty` - trim empty trailers (`--trim-empty`) + * `:where` - where to place new trailers: `"after"`, `"before"`, `"end"`, `"start"` (`--where`) + * `:if_exists` - action if trailer exists: `"addIfDifferentNeighbor"`, `"addIfDifferent"`, `"add"`, `"replace"`, `"doNothing"` (`--if-exists`) + * `:if_missing` - action if trailer missing: `"add"`, `"doNothing"` (`--if-missing`) + * `:unfold` - unfold multi-line trailers (`--unfold`) + * `:no_divider` - do not treat `---` as divider (`--no-divider`) + + ## Examples + + Git.interpret_trailers(file: "msg.txt", trailers: ["Signed-off-by: Name "]) + Git.interpret_trailers(file: "msg.txt", parse: true) + Git.interpret_trailers(file: "msg.txt", trailers: ["Acked-by: Name"], in_place: true) + + """ + @spec interpret_trailers(keyword()) :: {:ok, String.t()} | {:error, term()} + def interpret_trailers(opts \\ []) do + {config, rest} = Keyword.pop(opts, :config, Config.new()) + command = struct!(Git.Commands.InterpretTrailers, rest) + Git.Command.run(Git.Commands.InterpretTrailers, command, config) + end + + @doc """ + Runs `git maintenance` to manage repository maintenance tasks. + + Supports running, starting, stopping, registering, and unregistering + maintenance tasks such as garbage collection and commit-graph updates. + + ## Options + + * `:config` - a `Git.Config` struct (default: `Git.Config.new()`) + * `:run` - run maintenance tasks (`run` subcommand) + * `:start` - start background maintenance (`start` subcommand) + * `:stop` - stop background maintenance (`stop` subcommand) + * `:register_` - register repo for maintenance (`register` subcommand) + * `:unregister` - unregister repo from maintenance (`unregister` subcommand) + * `:task` - specific task to run (`--task`) + * `:auto` - only run if needed (`--auto`) + * `:quiet` - suppress output (`--quiet`) + * `:schedule` - maintenance schedule: `"hourly"`, `"daily"`, `"weekly"` (`--schedule`) + + ## Examples + + Git.maintenance(run: true) + Git.maintenance(run: true, task: "gc") + Git.maintenance(run: true, auto: true) + Git.maintenance(start: true) + Git.maintenance(stop: true) + + """ + @spec maintenance(keyword()) :: {:ok, :done} | {:error, term()} + def maintenance(opts \\ []) do + {config, rest} = Keyword.pop(opts, :config, Config.new()) + command = struct!(Git.Commands.Maintenance, rest) + Git.Command.run(Git.Commands.Maintenance, command, config) + end end diff --git a/lib/git/commands/interpret_trailers.ex b/lib/git/commands/interpret_trailers.ex new file mode 100644 index 0000000..f4d87ff --- /dev/null +++ b/lib/git/commands/interpret_trailers.ex @@ -0,0 +1,107 @@ +defmodule Git.Commands.InterpretTrailers do + @moduledoc """ + Implements the `Git.Command` behaviour for `git interpret-trailers`. + + Adds or parses trailers in commit messages. Trailers are key-value pairs + that appear at the end of a commit message, such as "Signed-off-by:" or + "Co-authored-by:". + """ + + @behaviour Git.Command + + @type t :: %__MODULE__{ + file: String.t() | nil, + parse: boolean(), + trailers: [String.t()], + in_place: boolean(), + trim_empty: boolean(), + where: String.t() | nil, + if_exists: String.t() | nil, + if_missing: String.t() | nil, + unfold: boolean(), + no_divider: boolean() + } + + defstruct file: nil, + parse: false, + trailers: [], + in_place: false, + trim_empty: false, + where: nil, + if_exists: nil, + if_missing: nil, + unfold: false, + no_divider: false + + @doc """ + Returns the argument list for `git interpret-trailers`. + + ## Examples + + iex> Git.Commands.InterpretTrailers.args(%Git.Commands.InterpretTrailers{}) + ["interpret-trailers"] + + iex> Git.Commands.InterpretTrailers.args(%Git.Commands.InterpretTrailers{parse: true}) + ["interpret-trailers", "--only-trailers"] + + iex> Git.Commands.InterpretTrailers.args(%Git.Commands.InterpretTrailers{trailers: ["Signed-off-by: A"]}) + ["interpret-trailers", "--trailer", "Signed-off-by: A"] + + iex> Git.Commands.InterpretTrailers.args(%Git.Commands.InterpretTrailers{in_place: true, file: "msg.txt"}) + ["interpret-trailers", "--in-place", "msg.txt"] + + iex> Git.Commands.InterpretTrailers.args(%Git.Commands.InterpretTrailers{where: "end"}) + ["interpret-trailers", "--where", "end"] + + iex> Git.Commands.InterpretTrailers.args(%Git.Commands.InterpretTrailers{if_exists: "replace"}) + ["interpret-trailers", "--if-exists", "replace"] + + iex> Git.Commands.InterpretTrailers.args(%Git.Commands.InterpretTrailers{if_missing: "doNothing"}) + ["interpret-trailers", "--if-missing", "doNothing"] + + """ + @spec args(t()) :: [String.t()] + @impl true + def args(%__MODULE__{} = command) do + ["interpret-trailers"] + |> maybe_add_flag(command.parse, "--only-trailers") + |> maybe_add_flag(command.in_place, "--in-place") + |> maybe_add_flag(command.trim_empty, "--trim-empty") + |> maybe_add_flag(command.unfold, "--unfold") + |> maybe_add_flag(command.no_divider, "--no-divider") + |> maybe_add_option(command.where, "--where") + |> maybe_add_option(command.if_exists, "--if-exists") + |> maybe_add_option(command.if_missing, "--if-missing") + |> maybe_add_trailers(command.trailers) + |> maybe_add_file(command.file) + end + + @doc """ + Parses the output of `git interpret-trailers`. + + On success (exit code 0), returns `{:ok, output}` with the processed + message or trailer text. On failure, returns `{:error, {stdout, exit_code}}`. + """ + @spec parse_output(String.t(), non_neg_integer()) :: + {:ok, String.t()} | {:error, {String.t(), non_neg_integer()}} + @impl true + def parse_output(stdout, 0), do: {:ok, stdout} + def parse_output(stdout, exit_code), do: {:error, {stdout, exit_code}} + + defp maybe_add_flag(args, false, _flag), do: args + defp maybe_add_flag(args, true, flag), do: args ++ [flag] + + defp maybe_add_option(args, nil, _flag), do: args + defp maybe_add_option(args, value, flag), do: args ++ [flag, value] + + defp maybe_add_trailers(args, []), do: args + + defp maybe_add_trailers(args, trailers) do + Enum.reduce(trailers, args, fn trailer, acc -> + acc ++ ["--trailer", trailer] + end) + end + + defp maybe_add_file(args, nil), do: args + defp maybe_add_file(args, file), do: args ++ [file] +end diff --git a/lib/git/commands/maintenance.ex b/lib/git/commands/maintenance.ex new file mode 100644 index 0000000..966d3a8 --- /dev/null +++ b/lib/git/commands/maintenance.ex @@ -0,0 +1,101 @@ +defmodule Git.Commands.Maintenance do + @moduledoc """ + Implements the `Git.Command` behaviour for `git maintenance`. + + Runs, starts, stops, registers, or unregisters repository maintenance tasks + such as garbage collection, commit-graph updates, and prefetching. + """ + + @behaviour Git.Command + + @type t :: %__MODULE__{ + run: boolean(), + start: boolean(), + stop: boolean(), + register_: boolean(), + unregister: boolean(), + task: String.t() | nil, + auto: boolean(), + quiet: boolean(), + schedule: String.t() | nil + } + + defstruct run: false, + start: false, + stop: false, + register_: false, + unregister: false, + task: nil, + auto: false, + quiet: false, + schedule: nil + + @doc """ + Returns the argument list for `git maintenance`. + + ## Examples + + iex> Git.Commands.Maintenance.args(%Git.Commands.Maintenance{run: true}) + ["maintenance", "run"] + + iex> Git.Commands.Maintenance.args(%Git.Commands.Maintenance{start: true}) + ["maintenance", "start"] + + iex> Git.Commands.Maintenance.args(%Git.Commands.Maintenance{stop: true}) + ["maintenance", "stop"] + + iex> Git.Commands.Maintenance.args(%Git.Commands.Maintenance{register_: true}) + ["maintenance", "register"] + + iex> Git.Commands.Maintenance.args(%Git.Commands.Maintenance{unregister: true}) + ["maintenance", "unregister"] + + iex> Git.Commands.Maintenance.args(%Git.Commands.Maintenance{run: true, task: "gc"}) + ["maintenance", "run", "--task", "gc"] + + iex> Git.Commands.Maintenance.args(%Git.Commands.Maintenance{run: true, auto: true}) + ["maintenance", "run", "--auto"] + + iex> Git.Commands.Maintenance.args(%Git.Commands.Maintenance{run: true, quiet: true}) + ["maintenance", "run", "--quiet"] + + iex> Git.Commands.Maintenance.args(%Git.Commands.Maintenance{run: true, schedule: "daily"}) + ["maintenance", "run", "--schedule", "daily"] + + """ + @spec args(t()) :: [String.t()] + @impl true + def args(%__MODULE__{} = command) do + ["maintenance"] + |> add_subcommand(command) + |> maybe_add_option(command.task, "--task") + |> maybe_add_flag(command.auto, "--auto") + |> maybe_add_flag(command.quiet, "--quiet") + |> maybe_add_option(command.schedule, "--schedule") + end + + @doc """ + Parses the output of `git maintenance`. + + 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 add_subcommand(args, %{run: true}), do: args ++ ["run"] + defp add_subcommand(args, %{start: true}), do: args ++ ["start"] + defp add_subcommand(args, %{stop: true}), do: args ++ ["stop"] + defp add_subcommand(args, %{register_: true}), do: args ++ ["register"] + defp add_subcommand(args, %{unregister: true}), do: args ++ ["unregister"] + defp add_subcommand(args, _command), do: args + + defp maybe_add_flag(args, false, _flag), do: args + defp maybe_add_flag(args, true, flag), do: args ++ [flag] + + defp maybe_add_option(args, nil, _flag), do: args + defp maybe_add_option(args, value, flag), do: args ++ [flag, value] +end diff --git a/lib/git/repo.ex b/lib/git/repo.ex index e4b52b9..55d7f99 100644 --- a/lib/git/repo.ex +++ b/lib/git/repo.ex @@ -790,4 +790,24 @@ defmodule Git.Repo do def am(%__MODULE__{} = repo, opts \\ []) do Git.am(Keyword.put(opts, :config, repo.config)) end + + @doc """ + Runs `git interpret-trailers` on the repository. + + See `Git.interpret_trailers/1` for available options. + """ + @spec interpret_trailers(t(), keyword()) :: {:ok, String.t()} | {:error, term()} + def interpret_trailers(%__MODULE__{} = repo, opts \\ []) do + Git.interpret_trailers(Keyword.put(opts, :config, repo.config)) + end + + @doc """ + Runs `git maintenance` on the repository. + + See `Git.maintenance/1` for available options. + """ + @spec maintenance(t(), keyword()) :: {:ok, :done} | {:error, term()} + def maintenance(%__MODULE__{} = repo, opts \\ []) do + Git.maintenance(Keyword.put(opts, :config, repo.config)) + end end diff --git a/test/git/commands/interpret_trailers_test.exs b/test/git/commands/interpret_trailers_test.exs new file mode 100644 index 0000000..e8cd11a --- /dev/null +++ b/test/git/commands/interpret_trailers_test.exs @@ -0,0 +1,187 @@ +defmodule Git.InterpretTrailersTest do + use ExUnit.Case, async: true + + alias Git.Commands.InterpretTrailers + alias Git.Config + + @env [ + {"GIT_AUTHOR_NAME", "Test User"}, + {"GIT_AUTHOR_EMAIL", "test@test.com"}, + {"GIT_COMMITTER_NAME", "Test User"}, + {"GIT_COMMITTER_EMAIL", "test@test.com"} + ] + + defp setup_repo do + tmp_dir = + Path.join( + System.tmp_dir!(), + "git_interpret_trailers_test_#{:erlang.unique_integer([:positive])}" + ) + + File.mkdir_p!(tmp_dir) + cfg = Config.new(working_dir: tmp_dir, env: @env) + {:ok, :done} = Git.init(config: cfg) + + {:ok, :done} = + Git.git_config(set_key: "user.name", set_value: "Test User", config: cfg) + + {:ok, :done} = + Git.git_config(set_key: "user.email", set_value: "test@test.com", config: cfg) + + on_exit(fn -> File.rm_rf!(tmp_dir) end) + {tmp_dir, cfg} + end + + describe "args/1" do + test "builds args with no options" do + assert InterpretTrailers.args(%InterpretTrailers{}) == ["interpret-trailers"] + end + + test "builds args with parse flag" do + assert InterpretTrailers.args(%InterpretTrailers{parse: true}) == + ["interpret-trailers", "--only-trailers"] + end + + test "builds args with single trailer" do + assert InterpretTrailers.args(%InterpretTrailers{ + trailers: ["Signed-off-by: Test User "] + }) == + [ + "interpret-trailers", + "--trailer", + "Signed-off-by: Test User " + ] + end + + test "builds args with multiple trailers" do + assert InterpretTrailers.args(%InterpretTrailers{ + trailers: ["Signed-off-by: A", "Reviewed-by: B"] + }) == + [ + "interpret-trailers", + "--trailer", + "Signed-off-by: A", + "--trailer", + "Reviewed-by: B" + ] + end + + test "builds args with in_place and file" do + assert InterpretTrailers.args(%InterpretTrailers{in_place: true, file: "msg.txt"}) == + ["interpret-trailers", "--in-place", "msg.txt"] + end + + test "builds args with trim_empty" do + assert InterpretTrailers.args(%InterpretTrailers{trim_empty: true}) == + ["interpret-trailers", "--trim-empty"] + end + + test "builds args with where option" do + assert InterpretTrailers.args(%InterpretTrailers{where: "end"}) == + ["interpret-trailers", "--where", "end"] + end + + test "builds args with if_exists option" do + assert InterpretTrailers.args(%InterpretTrailers{if_exists: "replace"}) == + ["interpret-trailers", "--if-exists", "replace"] + end + + test "builds args with if_missing option" do + assert InterpretTrailers.args(%InterpretTrailers{if_missing: "doNothing"}) == + ["interpret-trailers", "--if-missing", "doNothing"] + end + + test "builds args with unfold flag" do + assert InterpretTrailers.args(%InterpretTrailers{unfold: true}) == + ["interpret-trailers", "--unfold"] + end + + test "builds args with no_divider flag" do + assert InterpretTrailers.args(%InterpretTrailers{no_divider: true}) == + ["interpret-trailers", "--no-divider"] + end + + test "builds args with file" do + assert InterpretTrailers.args(%InterpretTrailers{file: "commit_msg.txt"}) == + ["interpret-trailers", "commit_msg.txt"] + end + end + + describe "git interpret-trailers" do + test "adds a trailer to a message file" do + {tmp_dir, cfg} = setup_repo() + + msg_file = Path.join(tmp_dir, "commit_msg.txt") + File.write!(msg_file, "Initial commit\n") + + {:ok, output} = + Git.interpret_trailers( + trailers: ["Signed-off-by: Test User "], + file: msg_file, + config: cfg + ) + + assert output =~ "Initial commit" + assert output =~ "Signed-off-by: Test User " + end + + test "parses trailers from a message file" do + {tmp_dir, cfg} = setup_repo() + + msg_file = Path.join(tmp_dir, "commit_msg.txt") + + File.write!( + msg_file, + "Subject line\n\nBody text\n\nSigned-off-by: Test User \n" + ) + + {:ok, output} = + Git.interpret_trailers( + parse: true, + file: msg_file, + config: cfg + ) + + assert output =~ "Signed-off-by: Test User " + refute output =~ "Subject line" + end + + test "adds multiple trailers" do + {tmp_dir, cfg} = setup_repo() + + msg_file = Path.join(tmp_dir, "commit_msg.txt") + File.write!(msg_file, "feat: new feature\n") + + {:ok, output} = + Git.interpret_trailers( + trailers: [ + "Signed-off-by: Alice ", + "Reviewed-by: Bob " + ], + file: msg_file, + config: cfg + ) + + assert output =~ "Signed-off-by: Alice " + assert output =~ "Reviewed-by: Bob " + end + + test "edits file in place" do + {tmp_dir, cfg} = setup_repo() + + msg_file = Path.join(tmp_dir, "commit_msg.txt") + File.write!(msg_file, "fix: bug fix\n") + + {:ok, _output} = + Git.interpret_trailers( + trailers: ["Signed-off-by: Test User "], + in_place: true, + file: msg_file, + config: cfg + ) + + updated = File.read!(msg_file) + assert updated =~ "Signed-off-by: Test User " + end + end +end diff --git a/test/git/commands/maintenance_test.exs b/test/git/commands/maintenance_test.exs new file mode 100644 index 0000000..a963fa4 --- /dev/null +++ b/test/git/commands/maintenance_test.exs @@ -0,0 +1,102 @@ +defmodule Git.MaintenanceTest do + use ExUnit.Case, async: true + + alias Git.Commands.Maintenance + alias Git.Config + + @env [ + {"GIT_AUTHOR_NAME", "Test User"}, + {"GIT_AUTHOR_EMAIL", "test@test.com"}, + {"GIT_COMMITTER_NAME", "Test User"}, + {"GIT_COMMITTER_EMAIL", "test@test.com"} + ] + + defp setup_repo do + tmp_dir = + Path.join( + System.tmp_dir!(), + "git_maintenance_test_#{:erlang.unique_integer([:positive])}" + ) + + File.mkdir_p!(tmp_dir) + cfg = Config.new(working_dir: tmp_dir, env: @env) + {:ok, :done} = Git.init(config: cfg) + + {:ok, :done} = + Git.git_config(set_key: "user.name", set_value: "Test User", config: cfg) + + {:ok, :done} = + Git.git_config(set_key: "user.email", set_value: "test@test.com", config: cfg) + + {:ok, _} = Git.commit("initial", allow_empty: true, config: cfg) + on_exit(fn -> File.rm_rf!(tmp_dir) end) + {tmp_dir, cfg} + end + + describe "args/1" do + test "builds args with run subcommand" do + assert Maintenance.args(%Maintenance{run: true}) == ["maintenance", "run"] + end + + test "builds args with start subcommand" do + assert Maintenance.args(%Maintenance{start: true}) == ["maintenance", "start"] + end + + test "builds args with stop subcommand" do + assert Maintenance.args(%Maintenance{stop: true}) == ["maintenance", "stop"] + end + + test "builds args with register subcommand" do + assert Maintenance.args(%Maintenance{register_: true}) == ["maintenance", "register"] + end + + test "builds args with unregister subcommand" do + assert Maintenance.args(%Maintenance{unregister: true}) == ["maintenance", "unregister"] + end + + test "builds args with task option" do + assert Maintenance.args(%Maintenance{run: true, task: "gc"}) == + ["maintenance", "run", "--task", "gc"] + end + + test "builds args with auto flag" do + assert Maintenance.args(%Maintenance{run: true, auto: true}) == + ["maintenance", "run", "--auto"] + end + + test "builds args with quiet flag" do + assert Maintenance.args(%Maintenance{run: true, quiet: true}) == + ["maintenance", "run", "--quiet"] + end + + test "builds args with schedule option" do + assert Maintenance.args(%Maintenance{run: true, schedule: "daily"}) == + ["maintenance", "run", "--schedule", "daily"] + end + + test "builds args with multiple options" do + assert Maintenance.args(%Maintenance{run: true, task: "gc", quiet: true}) == + ["maintenance", "run", "--task", "gc", "--quiet"] + end + end + + describe "git maintenance" do + test "runs maintenance on a repository" do + {_tmp_dir, cfg} = setup_repo() + + assert {:ok, :done} = Git.maintenance(run: true, config: cfg) + end + + test "runs specific maintenance task" do + {_tmp_dir, cfg} = setup_repo() + + assert {:ok, :done} = Git.maintenance(run: true, task: "gc", config: cfg) + end + + test "runs maintenance with auto mode" do + {_tmp_dir, cfg} = setup_repo() + + assert {:ok, :done} = Git.maintenance(run: true, auto: true, config: cfg) + end + end +end