Runs git, without a dependency that has not been touched in seven years (#250).
git_cli is 136 lines wrapping System.cmd, and its one notable decision --
stderr_to_stdout: true -- is why GitModule.git_log_split/2 existed: git's
warning: lines are on stderr, where they belong, and the wrapper mixed them
into the output we then parsed around. Keeping the streams apart removes the
problem rather than filtering it.
There was no library to move to. Measured with our own analyzer, every Elixir
git binding is abandoned: xgit 6.5 years with one functional contributor,
gitex 4.9, geef 9.9 with one. Swapping would have traded a dead
dependency for a deader one.
Failure is never silence
System.cmd/3 returns {output, status}, so the tempting shape is:
{out, _} = System.cmd("git", args)which turns a failed command into empty output, and empty output into an
analysis that examined nothing and reported low. That is the defect this
codebase produces most often, so run!/2 raises and is the default; run/2
exists for the callers that genuinely handle a non-zero status, and makes
them say so.
The environment
Git is asked not to be interactive. Without GIT_TERMINAL_PROMPT=0 a clone
of a private or renamed repository waits for credentials rather than failing,
which presents as a hang rather than an error and is a plausible cause of
analyses that time out. Neither we nor git_cli set it before.
Summary
Functions
Clones into path.
The environment every git command runs under.
A handle on an existing repository directory. Does no work.
Runs git, returning {:ok, stdout} or {:error, status, stdout}.
Runs git, returning stdout, and raises Lei.Git.Error on a non-zero exit.
Functions
@spec clone(String.t(), String.t()) :: {:ok, Lei.Git.Repository.t()} | {:error, non_neg_integer()}
Clones into path.
--quiet because clone writes its progress, including the destination, to
stderr, and this process does not need it on the console. -- because a URL
is data: RemoteUrl.validate/1 already requires https so one cannot begin
with a dash, and the separator costs nothing to keep it that way.
This is the one command whose stderr is captured. Clone is also the only one
that names the remote, and a failure prints fatal: repository '<url>' does not exist. Left on the parent's stream that line reaches production logs,
where a repository URL sits beside a ledger timestamp and puts a paying
wallet next to what it analysed (#149). Capturing costs nothing here because
a successful quiet clone writes nothing to stdout either way, and the text is
discarded rather than returned.
The environment every git command runs under.
@spec new(String.t()) :: Lei.Git.Repository.t()
A handle on an existing repository directory. Does no work.
@spec run(Lei.Git.Repository.t() | nil, [String.t()], keyword()) :: {:ok, String.t()} | {:error, non_neg_integer(), String.t()}
Runs git, returning {:ok, stdout} or {:error, status, stdout}.
stderr is left on the parent's stream rather than captured, so nothing git
says about itself can be parsed as output. In normal operation it says
nothing: git log --name-only writes zero bytes there, and clone is passed
--quiet.
@spec run!(Lei.Git.Repository.t() | nil, [String.t()], keyword()) :: String.t()
Runs git, returning stdout, and raises Lei.Git.Error on a non-zero exit.
The default. A caller that wants to proceed without the answer has to say so
by using run/2, rather than getting an empty string and not noticing.