Skip to main content

src/version_bump@git.erl

-module(version_bump@git).
-compile([no_auto_import, nowarn_unused_vars, nowarn_unused_function, nowarn_nomatch, inline]).
-define(FILEPATH, "src/version_bump/git.gleam").
-export([parse_log/1, log_since/2, get_tags/1, current_branch/1, head_sha/1, list_branches/1, create_tag/3, push/3, stage/2, commit/4, get_remote_url/2]).

-if(?OTP_RELEASE >= 27).
-define(MODULEDOC(Str), -moduledoc(Str)).
-define(DOC(Str), -doc(Str)).
-else.
-define(MODULEDOC(Str), -compile([])).
-define(DOC(Str), -compile([])).
-endif.

?MODULEDOC(
    " Git access by shelling out to the `git` executable via `shellout`.\n"
    "\n"
    " `parse_log` is the only PURE function here — it decodes a custom\n"
    " `--pretty` format that delimits fields with the ASCII unit separator\n"
    " (`\\u{1f}`) and records with the ASCII record separator (`\\u{1e}`). Those\n"
    " control characters never appear in normal commit text, so the parse is\n"
    " unambiguous without any quoting/escaping.\n"
).

-file("src/version_bump/git.gleam", 45).
?DOC(
    " Parse a single commit record. Returns `Error(Nil)` for blank/short records\n"
    " so `filter_map` can drop them cleanly.\n"
).
-spec parse_record(binary()) -> {ok, version_bump@commit_parser:commit()} |
    {error, nil}.
parse_record(Record) ->
    case gleam@string:trim(Record) of
        <<""/utf8>> ->
            {error, nil};

        _ ->
            case gleam@string:split(Record, <<"\x{1f}"/utf8>>) of
                [Hash,
                    Short_hash,
                    Subject,
                    Body,
                    Author_name,
                    Author_email,
                    Date] ->
                    {ok,
                        {commit,
                            gleam@string:trim(Hash),
                            gleam@string:trim(Short_hash),
                            Subject,
                            gleam@string:trim(Body),
                            Author_name,
                            Author_email,
                            gleam@string:trim(Date)}};

                _ ->
                    {error, nil}
            end
    end.

-file("src/version_bump/git.gleam", 37).
?DOC(
    " Parse the raw output of `git log --pretty=<pretty_format>` into commits.\n"
    "\n"
    " PURE: no IO. Splits on the record separator, then each record on the unit\n"
    " separator. Records with the wrong number of fields (including the empty\n"
    " trailing record produced by the final record separator) are skipped.\n"
).
-spec parse_log(binary()) -> list(version_bump@commit_parser:commit()).
parse_log(Raw) ->
    _pipe = Raw,
    _pipe@1 = gleam@string:split(_pipe, <<"\x{1e}"/utf8>>),
    gleam@list:filter_map(_pipe@1, fun parse_record/1).

-file("src/version_bump/git.gleam", 214).
?DOC(" Run a `git` subcommand in `cwd`, mapping the failure tuple to a `GitError`.\n").
-spec run(binary(), list(binary())) -> {ok, binary()} |
    {error, version_bump@error:release_error()}.
run(Cwd, Args) ->
    _pipe = shellout:command(<<"git"/utf8>>, Args, Cwd, []),
    gleam@result:map_error(
        _pipe,
        fun(Failure) ->
            {Code, Message} = Failure,
            {git_error,
                <<<<<<<<<<"`git "/utf8,
                                    (gleam@string:join(Args, <<" "/utf8>>))/binary>>/binary,
                                "` failed (exit "/utf8>>/binary,
                            (erlang:integer_to_binary(Code))/binary>>/binary,
                        "): "/utf8>>/binary,
                    (gleam@string:trim(Message))/binary>>}
        end
    ).

-file("src/version_bump/git.gleam", 70).
?DOC(
    " Run `git log [from..HEAD]` with our pretty format and parse the result.\n"
    "\n"
    " When `from` is `None` the entire history reachable from `HEAD` is returned;\n"
    " otherwise only commits in the `from..HEAD` range (i.e. reachable from HEAD\n"
    " but not from `from`).\n"
).
-spec log_since(binary(), gleam@option:option(binary())) -> {ok,
        list(version_bump@commit_parser:commit())} |
    {error, version_bump@error:release_error()}.
log_since(Cwd, From) ->
    Range = case From of
        {some, Ref} ->
            <<Ref/binary, "..HEAD"/utf8>>;

        none ->
            <<"HEAD"/utf8>>
    end,
    gleam@result:map(
        run(
            Cwd,
            [<<"log"/utf8>>,
                <<"--pretty="/utf8,
                    "%H\x{1f}%h\x{1f}%s\x{1f}%b\x{1f}%an\x{1f}%ae\x{1f}%cI\x{1e}"/utf8>>,
                Range]
        ),
        fun(Raw) -> parse_log(Raw) end
    ).

-file("src/version_bump/git.gleam", 230).
?DOC(" Split text into lines, dropping any that are empty after trimming.\n").
-spec nonempty_lines(binary()) -> list(binary()).
nonempty_lines(Raw) ->
    _pipe = Raw,
    _pipe@1 = gleam@string:split(_pipe, <<"\n"/utf8>>),
    _pipe@2 = gleam@list:map(_pipe@1, fun gleam@string:trim/1),
    gleam@list:filter(_pipe@2, fun(Line) -> Line /= <<""/utf8>> end).

-file("src/version_bump/git.gleam", 83).
?DOC(" List all tags in the repository.\n").
-spec get_tags(binary()) -> {ok, list(binary())} |
    {error, version_bump@error:release_error()}.
get_tags(Cwd) ->
    gleam@result:map(
        run(Cwd, [<<"tag"/utf8>>]),
        fun(Raw) -> nonempty_lines(Raw) end
    ).

-file("src/version_bump/git.gleam", 89).
?DOC(" The name of the currently checked-out branch.\n").
-spec current_branch(binary()) -> {ok, binary()} |
    {error, version_bump@error:release_error()}.
current_branch(Cwd) ->
    gleam@result:map(
        run(
            Cwd,
            [<<"rev-parse"/utf8>>, <<"--abbrev-ref"/utf8>>, <<"HEAD"/utf8>>]
        ),
        fun(Raw) -> gleam@string:trim(Raw) end
    ).

-file("src/version_bump/git.gleam", 95).
?DOC(" The full SHA of `HEAD`.\n").
-spec head_sha(binary()) -> {ok, binary()} |
    {error, version_bump@error:release_error()}.
head_sha(Cwd) ->
    gleam@result:map(
        run(Cwd, [<<"rev-parse"/utf8>>, <<"HEAD"/utf8>>]),
        fun(Raw) -> gleam@string:trim(Raw) end
    ).

-file("src/version_bump/git.gleam", 102).
?DOC(
    " List local and remote-tracking branch names, with the leading `origin/`\n"
    " (or any other remote prefix is preserved as-is) and decorations stripped.\n"
).
-spec list_branches(binary()) -> {ok, list(binary())} |
    {error, version_bump@error:release_error()}.
list_branches(Cwd) ->
    gleam@result:map(
        run(
            Cwd,
            [<<"branch"/utf8>>,
                <<"--all"/utf8>>,
                <<"--format=%(refname:short)"/utf8>>]
        ),
        fun(Raw) -> _pipe = Raw,
            _pipe@1 = nonempty_lines(_pipe),
            gleam@list:filter(
                _pipe@1,
                fun(Name) ->
                    not gleam_stdlib:contains_string(Name, <<" -> "/utf8>>)
                end
            ) end
    ).

-file("src/version_bump/git.gleam", 120).
?DOC(
    " Create an annotated tag at `HEAD`. The committer identity is set per-command\n"
    " (an annotated tag records a tagger, which git refuses to invent) so this works\n"
    " on a bare CI runner with no `user.name`/`user.email` configured — matching how\n"
    " the `git` plugin's commit sets its identity.\n"
).
-spec create_tag(binary(), binary(), binary()) -> {ok, nil} |
    {error, version_bump@error:release_error()}.
create_tag(Cwd, Tag, Message) ->
    gleam@result:map(
        run(
            Cwd,
            [<<"-c"/utf8>>,
                <<"user.name=version_bump"/utf8>>,
                <<"-c"/utf8>>,
                <<"user.email=version_bump@users.noreply.github.com"/utf8>>,
                <<"tag"/utf8>>,
                <<"-a"/utf8>>,
                Tag,
                <<"-m"/utf8>>,
                Message]
        ),
        fun(_) -> nil end
    ).

-file("src/version_bump/git.gleam", 143).
?DOC(
    " Push a single ref to a remote. `ref` may be a tag name, a branch name, or a\n"
    " `<src>:<dst>` refspec such as `HEAD:main`.\n"
).
-spec push(binary(), binary(), binary()) -> {ok, nil} |
    {error, version_bump@error:release_error()}.
push(Cwd, Remote, Ref) ->
    gleam@result:map(
        run(Cwd, [<<"push"/utf8>>, Remote, Ref]),
        fun(_) -> nil end
    ).

-file("src/version_bump/git.gleam", 153).
?DOC(" Stage the given paths (`git add -- <paths>`).\n").
-spec stage(binary(), list(binary())) -> {ok, nil} |
    {error, version_bump@error:release_error()}.
stage(Cwd, Paths) ->
    gleam@result:map(
        run(Cwd, lists:append([<<"add"/utf8>>, <<"--"/utf8>>], Paths)),
        fun(_) -> nil end
    ).

-file("src/version_bump/git.gleam", 190).
?DOC(
    " True when there are staged changes. `git diff --staged --quiet` exits 0 when\n"
    " the index matches HEAD (nothing staged) and non-zero when it differs.\n"
).
-spec has_staged_changes(binary()) -> boolean().
has_staged_changes(Cwd) ->
    case shellout:command(
        <<"git"/utf8>>,
        [<<"diff"/utf8>>, <<"--staged"/utf8>>, <<"--quiet"/utf8>>],
        Cwd,
        []
    ) of
        {ok, _} ->
            false;

        {error, _} ->
            true
    end.

-file("src/version_bump/git.gleam", 163).
?DOC(
    " Commit the staged changes with `message`, attributing the commit to the given\n"
    " identity (set per-command so a release works even when git's `user.name`/\n"
    " `user.email` aren't configured, e.g. on a fresh CI runner). When nothing is\n"
    " staged this is a successful no-op, so a release with no file changes to commit\n"
    " doesn't fail.\n"
).
-spec commit(binary(), binary(), binary(), binary()) -> {ok, nil} |
    {error, version_bump@error:release_error()}.
commit(Cwd, Message, Committer_name, Committer_email) ->
    case has_staged_changes(Cwd) of
        false ->
            {ok, nil};

        true ->
            gleam@result:map(
                run(
                    Cwd,
                    [<<"-c"/utf8>>,
                        <<"user.name="/utf8, Committer_name/binary>>,
                        <<"-c"/utf8>>,
                        <<"user.email="/utf8, Committer_email/binary>>,
                        <<"commit"/utf8>>,
                        <<"-m"/utf8>>,
                        Message]
                ),
                fun(_) -> nil end
            )
    end.

-file("src/version_bump/git.gleam", 205).
?DOC(" Resolve the fetch URL configured for a remote.\n").
-spec get_remote_url(binary(), binary()) -> {ok, binary()} |
    {error, version_bump@error:release_error()}.
get_remote_url(Cwd, Remote) ->
    gleam@result:map(
        run(Cwd, [<<"remote"/utf8>>, <<"get-url"/utf8>>, Remote]),
        fun(Raw) -> gleam@string:trim(Raw) end
    ).