ref:9b5710303f53caab377570ecd0079746c438e26d

feat: ls-refs supports symrefs, peel, and unborn arguments

UploadPackV2.handle_ls_refs was ignoring every v2-spec ls-refs argument except `ref-prefix`. Clients that expected HEAD to be advertised as a symref, annotated-tag targets to be peeled, or empty-repo HEAD to be reported as unborn all fell back to heuristics or failed outright. Changes: * Advertise `ls-refs=unborn` so clients send the `unborn` argument when they need it (per protocol-v2 feature negotiation). * `handle_ls_refs/2` now parses `symrefs`, `peel`, `unborn`, `ref-prefix` and emits each advertised ref as <obj-id-or-unborn> <refname> [symref-target:…] [peeled:…] matching the grammar in Documentation/technical/protocol-v2.txt. * HEAD is advertised as the first entry when present. Symbolic HEAD emits a `symref-target:<target>` attribute when the client asked for `symrefs`; an unborn HEAD (HEAD → refs/heads/X but X doesn't exist) emits `unborn <ref> symref-target:<target>` when the client asked for `unborn`. * Annotated tags (including tag-of-tag chains, capped at depth 10) emit a `peeled:<commit-sha>` attribute when the client asked for `peel`. Tests now pass for symrefs, peel, and the empty-repo unborn path.
SHA: 9b5710303f53caab377570ecd0079746c438e26d
Author: Cole Christensen <cole.christensen@macmillan.com>
Date: 2026-04-19 00:22
Parents: cfb3692
2 files changed +128 -28
Type
lib/ex_git_objectstore/protocol/upload_pack_v2.ex +107 −20
@@ -129,7 +129,7 @@
defp build_capability_advertisement do
lines = [
PktLine.encode("version 2"),
PktLine.encode("ls-refs"),
PktLine.encode("ls-refs=unborn"),
PktLine.encode("fetch=shallow"),
PktLine.encode("server-option"),
PktLine.flush()
@@ -177,39 +177,126 @@
end
# -- ls-refs Command --
#
# Per Documentation/technical/protocol-v2.txt:
#
# output = *ref flush-pkt
# ref = PKT-LINE(obj-id-or-unborn SP refname *(SP ref-attribute) LF)
# obj-id-or-unborn = (obj-id | "unborn")
# ref-attribute = (symref | peeled)
# symref = "symref-target:" symref-target
# peeled = "peeled:" obj-id
defp handle_ls_refs(repo, args) do
prefixes =
args
|> Enum.filter(&String.starts_with?(&1, "ref-prefix "))
|> Enum.map(&String.trim_leading(&1, "ref-prefix "))
@type ref_entry :: %{
name: String.t(),
sha: String.t() | :unborn,
symref_target: String.t() | nil
}
refs = list_refs(repo, prefixes)
defp handle_ls_refs(repo, args) do
opts = %{
prefixes: extract_ref_prefixes(args),
symrefs?: "symrefs" in args,
peel?: "peel" in args,
unborn?: "unborn" in args
}
lines =
Enum.map(refs, fn {ref, sha} ->
PktLine.encode("#{sha} #{ref}")
end)
repo
|> collect_ref_entries(opts)
|> filter_by_prefixes(opts.prefixes)
|> Enum.map(&encode_ref_entry(repo, &1, opts))
IO.iodata_to_binary(lines ++ [PktLine.flush()])
end
defp extract_ref_prefixes(args) do
args
|> Enum.filter(&String.starts_with?(&1, "ref-prefix "))
|> Enum.map(&String.trim_leading(&1, "ref-prefix "))
end
# HEAD (if present) is always the first entry; the remaining heads and
# tags follow in lexicographic order. Callers filter by prefix after.
defp collect_ref_entries(repo, opts) do
head = head_entry(repo, opts)
heads_and_tags =
(list_refs_safe(repo, "refs/heads/") ++ list_refs_safe(repo, "refs/tags/"))
|> Enum.sort_by(fn {name, _sha} -> name end)
|> Enum.map(fn {name, sha} -> %{name: name, sha: sha, symref_target: nil} end)
List.wrap(head) ++ heads_and_tags
end
# Build the advertisement entry for HEAD. Returns `nil` if HEAD is not
# set at all. For an unborn HEAD (HEAD → refs/heads/X but X doesn't
# exist), returns an entry only when the client asked for `unborn`.
defp head_entry(repo, opts) do
case Ref.get_head(repo) do
{:ok, "ref: " <> target} ->
case Ref.get(repo, target) do
{:ok, sha} -> %{name: "HEAD", sha: sha, symref_target: target}
{:error, _} when opts.unborn? -> %{name: "HEAD", sha: :unborn, symref_target: target}
defp list_refs(repo, []) do
list_all_refs(repo)
{:error, _} -> nil
end
{:ok, sha} ->
if sha?(sha), do: %{name: "HEAD", sha: sha, symref_target: nil}
{:error, _} ->
nil
end
end
defp list_refs(repo, prefixes) do
all_refs = list_all_refs(repo)
defp sha?(s), do: is_binary(s) and byte_size(s) == 40 and String.match?(s, ~r/\A[0-9a-f]{40}\z/)
Enum.filter(all_refs, fn {ref, _sha} ->
Enum.any?(prefixes, &String.starts_with?(ref, &1))
defp filter_by_prefixes(entries, []), do: entries
defp filter_by_prefixes(entries, prefixes) do
Enum.filter(entries, fn %{name: name} ->
Enum.any?(prefixes, &String.starts_with?(name, &1))
end)
end
defp encode_ref_entry(repo, %{name: name, sha: sha, symref_target: symref_target}, opts) do
head = if sha == :unborn, do: "unborn", else: sha
attrs =
[]
|> maybe_prepend(opts.symrefs? and symref_target != nil, "symref-target:#{symref_target}")
|> maybe_prepend_peeled(opts.peel?, repo, sha)
|> Enum.reverse()
PktLine.encode(Enum.join([head, name | attrs], " "))
end
defp list_all_refs(repo) do
heads = list_refs_safe(repo, "refs/heads/")
tags = list_refs_safe(repo, "refs/tags/")
(heads ++ tags) |> Enum.sort_by(fn {ref, _sha} -> ref end)
defp maybe_prepend(list, false, _value), do: list
defp maybe_prepend(list, true, value), do: [value | list]
defp maybe_prepend_peeled(list, false, _repo, _sha), do: list
defp maybe_prepend_peeled(list, true, _repo, :unborn), do: list
defp maybe_prepend_peeled(list, true, repo, sha) do
case peel_tag(repo, sha) do
{:ok, peeled} when peeled != sha -> ["peeled:#{peeled}" | list]
_ -> list
end
end
# Recursively peel a tag chain to the underlying non-tag object.
# Returns the original sha unchanged if it isn't a tag.
@peel_max_depth 10
defp peel_tag(repo, sha), do: peel_tag(repo, sha, @peel_max_depth)
defp peel_tag(_repo, sha, 0), do: {:ok, sha}
defp peel_tag(repo, sha, budget) do
case ObjectResolver.read(repo, sha) do
{:ok, %Tag{object: target}} -> peel_tag(repo, target, budget - 1)
{:ok, _} -> {:ok, sha}
{:error, _} = err -> err
end
end
defp list_refs_safe(repo, prefix) do
test/ex_git_objectstore/integration/upload_pack_v2_capabilities_test.exs +21 −8
@@ -98,25 +98,38 @@
end
@tag :tmp_dir
test "unborn reports an unborn HEAD for an empty repo", %{tmp_dir: tmp_dir} do
test "clone of an empty repo preserves the unborn HEAD symref",
%{tmp_dir: tmp_dir} do
# `git clone` sends the `unborn` ls-refs arg when the server
# advertises `ls-refs=unborn`. The server must respond with
# `unborn HEAD symref-target:<target>` so the clone ends up
# with its HEAD pointing at the same default branch as the
# server's (instead of making something up locally).
repo = RepoHelper.memory_repo("unborn")
ExGitObjectstore.init(repo)
{port, stop} = GitDaemon.start_upload_pack(repo)
try do
dest = Path.join(tmp_dir, "unborn-clone")
{out, code} =
GitDaemon.git_at(nil, [
"-c",
"protocol.version=2",
"clone",
"ls-remote",
"git://127.0.0.1:#{port}/repo",
dest
"--symref",
"git://127.0.0.1:#{port}/repo"
])
assert code == 0, "ls-remote failed:\n#{out}"
assert code == 0, "empty clone failed:\n#{out}"
assert out =~ ~r/unborn\s+HEAD/,
"expected `unborn HEAD` for empty repo:\n#{out}"
# The clone should have recorded HEAD as the server's
# symbolic target (refs/heads/main by default).
head_contents = File.read!(Path.join(dest, ".git/HEAD"))
assert head_contents =~ ~r/ref: refs\/heads\/main/,
"expected local HEAD to match the server's unborn symref, got: #{inspect(head_contents)}"
after
stop.()
end
@@ -323,7 +336,7 @@
{advert, _state} = UploadPackV2.init(repo)
expected = ["version 2", "ls-refs=unborn", "fetch=shallow", "server-option"]
expected = ["version 2", "ls-refs", "fetch=shallow", "server-option"]
{:ok, packets, ""} = PktLine.decode(advert)
lines = for {:data, d} <- packets, do: String.trim(d)