Documentation
¶
Overview ¶
Package lsremote implements the discovery-time half of Git's wire protocols — the surface a caller exercises when they run `git ls-remote` — as a self-contained Go library with no shell-out to a `git` binary.
The package speaks all three protocol versions (v0, v1, v2) over the transports Git itself supports: HTTP(S), SSH, the anonymous `git://` daemon, and local `file://` repositories. Version negotiation matches canonical Git: a caller may pin a preferred version via the Option helpers, but absent an explicit preference the library asks for v2 and gracefully falls back to v0 when the server only speaks the original protocol.
The library exposes two layers. One-shot helpers — see Refs, ListRefs, and ObjectInfos — open a connection, run a single discovery command, and close it; they fit the common case where the caller only wants a ref list or a few object sizes. The session layer — see Dial and Session — keeps a connection open across multiple v2 commands so callers can issue Session.Refs followed by Session.ObjectInfo without re-handshaking.
Primary types ¶
Ref models a single entry in a discovery response: the ref name, its object id, an optional peeled object id for annotated tags, and an optional symref target when the entry is a symbolic reference.
Capabilities records what the remote advertised during the handshake: the negotiated ProtocolVersion, the server agent string, the ObjectFormat in use (`sha1` or `sha256`), and the per-command argument lists a v2 server claims to accept.
ObjectInfo is the response shape for the v2 `object-info` command, pairing an object id with metadata such as its `Size`.
Symref names a single `HEAD → refs/heads/...` style mapping as advertised by v0/v1 servers in their capability list. v2 servers surface the same information inline on each Ref via [Ref.Symref] instead, so v2 Capabilities leaves [Capabilities.Symrefs] empty.
The ProtocolVersion identifier is a Go type alias of transport.ProtocolVersion; the two are interchangeable without conversion. The constants ProtocolV0, ProtocolV1, and ProtocolV2 resolve to the same package-level constants the `transport` package defines.
Example (DefaultBranch) ¶
Example_defaultBranch resolves the canonical name of a remote repository's default branch — the target of HEAD — without cloning or hitting the filesystem. The returned name is the full ref path (`refs/heads/main`, `refs/heads/master`, ...), matching the form a caller would write into a `git fetch` invocation.
package main
import (
"context"
"fmt"
"log"
lsremote "github.com/hiddeco/go-ls-remote"
)
func main() {
branch, err := lsremote.DefaultBranch(context.Background(),
"https://github.com/octocat/Hello-World.git")
if err != nil {
log.Fatal(err)
}
fmt.Println(branch)
}
Output:
Example (HandleErrors) ¶
Example_handleErrors illustrates the package's sentinel error model. Every transport-level failure flows through lsremote.ProtocolError and bridges to one of the public sentinels via errors.Is, so callers never have to branch on transport-specific error types. Use errors.As to recover diagnostic fields such as the HTTP status code or a server-supplied excerpt.
The example dials a non-existent local path so the output is deterministic; the same error-handling shape applies to every transport.
package main
import (
"context"
"errors"
"fmt"
lsremote "github.com/hiddeco/go-ls-remote"
"github.com/hiddeco/go-ls-remote/transport"
filet "github.com/hiddeco/go-ls-remote/transport/file"
)
func main() {
// The package default registers HTTPS/HTTP only; opt into the file
// transport explicitly. See [lsremote.WithTransports] for the full
// composition pattern.
reg := transport.NewRegistry(filet.New())
_, err := lsremote.Dial(context.Background(),
"file:///does/not/exist",
lsremote.WithTransports(reg))
var perr *lsremote.ProtocolError
switch {
case errors.Is(err, lsremote.ErrNotFound):
fmt.Println("not found")
case errors.Is(err, lsremote.ErrAuthRequired):
fmt.Println("auth required")
case errors.Is(err, lsremote.ErrAuthFailed):
fmt.Println("auth failed")
case errors.As(err, &perr):
fmt.Printf("protocol error at %s: %v\n", perr.Op, perr.Err)
case err != nil:
fmt.Printf("other error: %v\n", err)
}
}
Output: not found
Example (PollLatestCommit) ¶
Example_pollLatestCommit retrieves the current commit hash of a specific branch — the building block for "is there anything new to deploy?" polling, image-automation triggers, and similar workflows. The v2 wire applies the prefix filter server-side, so the request is cheap even on remotes with millions of refs.
package main
import (
"context"
"fmt"
"log"
lsremote "github.com/hiddeco/go-ls-remote"
)
func main() {
ctx := context.Background()
refs, err := lsremote.ListRefs(ctx,
"https://github.com/octocat/Hello-World.git",
lsremote.RefsRequest{Prefixes: []string{"refs/heads/master"}})
if err != nil {
log.Fatal(err)
}
for _, ref := range refs {
fmt.Printf("%s\t%s\n", ref.Hash, ref.Name)
}
}
Output:
Index ¶
- Constants
- Variables
- func DefaultBranch(ctx context.Context, rawURL string, opts ...Option) (string, error)
- func Exists(ctx context.Context, rawURL string, opts ...Option) (bool, error)
- func Heads(ctx context.Context, rawURL string, opts ...Option) (iter.Seq2[Ref, error], error)
- func Refs(ctx context.Context, rawURL string, args RefsRequest, opts ...Option) (iter.Seq2[Ref, error], error)
- func Tags(ctx context.Context, rawURL string, opts ...Option) (iter.Seq2[Ref, error], error)
- type Capabilities
- type ObjectFormat
- type ObjectInfo
- type ObjectInfoRequest
- type Option
- type ProtocolError
- type ProtocolVersion
- type Ref
- type RefsRequest
- type Session
- func (s *Session) Capabilities() Capabilities
- func (s *Session) Close() error
- func (s *Session) ListRefs(ctx context.Context, args RefsRequest) ([]Ref, error)
- func (s *Session) ObjectInfo(ctx context.Context, oids []string, args ObjectInfoRequest) ([]ObjectInfo, error)
- func (s *Session) Refs(ctx context.Context, args RefsRequest) (iter.Seq2[Ref, error], error)
- type Symref
Examples ¶
Constants ¶
const ( ProtocolV0 = transport.ProtocolV0 ProtocolV1 = transport.ProtocolV1 ProtocolV2 = transport.ProtocolV2 )
Re-exported ProtocolVersion constants. Because the type is an alias, `lsremote.ProtocolV2 == transport.ProtocolV2` and the two names refer to the same constant value.
Variables ¶
var ( // ErrNotFound signals the remote does not host the requested // repository. HTTP transports surface a `404`, SSH transports the // `fatal: '<path>' does not appear to be a git repository` error // line, and `file://` transports a missing-directory check. ErrNotFound = errors.New("lsremote: repository not found") // ErrAuthRequired signals the server demanded authentication and // none was offered (or the caller's credential resolver yielded // nothing). Callers are expected to plug in credentials and retry. ErrAuthRequired = errors.New("lsremote: authentication required") // ErrAuthFailed signals the server rejected the supplied // credentials. The library does not retry on its own; the caller // should refresh credentials before another attempt. ErrAuthFailed = errors.New("lsremote: authentication failed") // ErrUnsupportedProtocol signals the remote cannot honour the // requested protocol or operation — for example a dumb-HTTP // server when the caller asked for a v2 command, or an // `object-info` request against a v2 server that did not // advertise the command. ErrUnsupportedProtocol = errors.New("lsremote: protocol/operation not supported by server") // ErrServerRefused signals the server returned an explicit // `ERR <message>` pkt-line or otherwise refused the request after // the connection was established. The originating message is // preserved on [ProtocolError.Server] when available. ErrServerRefused = errors.New("lsremote: server refused") // ErrNoDefaultBranch signals that the remote repository is reachable // but HEAD has no symbolic target — it is either detached or the // server omitted the symref mapping from its advertisement. Returned // by [DefaultBranch] when no HEAD symref can be resolved. The // surrounding [ProtocolError] carries `Op == "ls-refs"` on a v2 // server (the mapping is sought via the `ls-refs` command) and // `Op == "advertisement"` on a v0/v1 server (the mapping is sought // in the capability advertisement). Use [errors.Is] to distinguish // this from [ErrNotFound], which means the repository itself is // absent. ErrNoDefaultBranch = errors.New("lsremote: remote has no default branch") // ErrSessionDead signals that the [Session] is no longer usable // because a previous command encountered a mid-stream wire error // that left the underlying byte stream in an undefined position. // The Session marks itself dead, closes its [transport.Conn] // eagerly, and rejects subsequent commands with this sentinel // wrapped in a [*ProtocolError]. The original cause is preserved // on the `errors.Is` chain so callers can match against both. // // Returned only by [Session.Refs], [Session.ListRefs], and // [Session.ObjectInfo] — [Session.Capabilities] continues to work // on a dead session because it reads cached state. // [Session.Close] remains idempotent. ErrSessionDead = errors.New("lsremote: session is no longer usable") )
Sentinel errors returned by the discovery surface. Each carries the `lsremote:` prefix so a stray log line is grep-friendly, and each is matched with errors.Is — typically against a ProtocolError whose [ProtocolError.Err] field wraps the sentinel directly or through one or more transport-layer wrappers.
Transport-specific sentinels (e.g. `transport/http.ErrNotFound`) are deliberately not re-exported: the errors.Is chain walks through ProtocolError.Unwrap, so a caller writes `errors.Is(err, lsremote.ErrNotFound)` without having to know which transport produced the failure.
Functions ¶
func DefaultBranch ¶
DefaultBranch returns the canonical name of the branch HEAD points at on the remote — `refs/heads/main`, `refs/heads/master`, etc.
On v2 the helper issues `ls-refs` with `ref-prefix HEAD`, `symrefs`, and `unborn`, then returns the [Ref.Symref] target attached to the `HEAD` entry. The `unborn` argument mirrors canonical Git's discovery client (connect.c:591-592). The wire layer silently ignores it when the server has not advertised `ls-refs=unborn`, so passing it here is always safe — older servers simply fall through to the v0/v1-style capability-list scan below. With `unborn`, a branch HEAD with no commits yet still surfaces as an unborn `HEAD` entry whose `symref-target:` carries the branch name; without it the server (ls-refs.c:135-136) would suppress HEAD entirely and the helper would fall through to ErrNoDefaultBranch. When the v2 server does not honour `symrefs` (or HEAD is detached and so carries no symref target) the helper falls back to the v0/v1-style capability scan over [Capabilities.Symrefs], because some servers surface the mapping there even on a v2 handshake.
On v0/v1 the wire has no `ls-refs` equivalent and the symref information rides on the capability list; the helper scans [Capabilities.Symrefs] for an entry named `HEAD` and returns its target.
When no symref target can be resolved DefaultBranch returns a *ProtocolError whose `Err` chains to ErrNoDefaultBranch. The surrounding [ProtocolError.Op] is `"ls-refs"` on a v2 server (the mapping is sought via the `ls-refs` command exchange) and `"advertisement"` on a v0/v1 server (the mapping is sought in the capability advertisement). Use `errors.Is(err, ErrNoDefaultBranch)` to detect the "repository present but HEAD has no symbolic target" condition (a detached HEAD on v2, or a v0/v1 server whose advertisement omits a `symref=HEAD:...` capability). A dial failure whose chain matches ErrNotFound means the repository itself is absent — the two sentinels are mutually exclusive.
func Exists ¶
Exists reports whether a repository is reachable at rawURL.
A successful Dial yields `(true, nil)` and the helper closes the resulting Session before returning. A Dial failure whose error chain matches ErrNotFound collapses to `(false, nil)` so a caller can distinguish "missing repository" from "transport blew up" without inspecting the error. Any other error propagates verbatim as `(false, err)`.
Example ¶
ExampleExists reports whether a remote repository is reachable. A missing-repository condition collapses to (false, nil), so callers can distinguish "not there" from "transport failure" without inspecting the error. Authentication failures, DNS errors, and other transport-level problems still propagate as the error return.
package main
import (
"context"
"fmt"
"log"
lsremote "github.com/hiddeco/go-ls-remote"
)
func main() {
ok, err := lsremote.Exists(context.Background(),
"https://github.com/octocat/Hello-World.git")
if err != nil {
log.Fatal(err)
}
if ok {
fmt.Println("found")
} else {
fmt.Println("missing")
}
}
Output:
func Refs ¶
func Refs(ctx context.Context, rawURL string, args RefsRequest, opts ...Option, ) (iter.Seq2[Ref, error], error)
Refs is the one-shot form of Session.Refs. It dials the remote at rawURL, requests an `ls-refs` stream (or filters the v0/v1 advertisement-time cache, per Session.Refs's version split), and arranges for the underlying Session to close when the returned iterator is exhausted or abandoned.
The Session lifetime is tied to the iterator: the wrapper's deferred Session.Close runs when the inner range-over-func returns, whether via exhaustion of the source stream or via the caller's `break` (the latter surfaces as `yield` returning false). Callers should not hold the iterator across goroutines or store it for later draining.
On a dial-time or `ls-refs`-time failure Refs returns `(nil, err)` and leaves no Session open.
func Tags ¶
Tags is a shorthand for Refs restricted to `refs/tags/` with `Peel` set so annotated tags carry their peeled commit id on [Ref.Peeled]. Tags do not carry symref targets, so [Ref.Symref] is always empty on every ref yielded by this iterator.
Example ¶
ExampleTags streams a remote repository's tags through an iterator. lsremote.Ref.Peeled carries the commit OID an annotated tag points to; lightweight tags leave lsremote.Ref.Peeled empty and lsremote.Ref.Hash already points directly at the commit.
package main
import (
"context"
"fmt"
"log"
lsremote "github.com/hiddeco/go-ls-remote"
)
func main() {
seq, err := lsremote.Tags(context.Background(),
"https://github.com/octocat/Hello-World.git")
if err != nil {
log.Fatal(err)
}
for ref, err := range seq {
if err != nil {
log.Fatal(err)
}
commit := ref.Hash
if ref.Peeled != "" {
commit = ref.Peeled
}
fmt.Printf("%s\t%s\n", commit, ref.Name)
}
}
Output:
Types ¶
type Capabilities ¶
type Capabilities struct {
// Version is the negotiated protocol version.
Version ProtocolVersion
// Agent is the server's `agent=` advertisement, or the empty
// string when none was sent.
Agent string
// ObjectFormat is the negotiated repository object format.
// v0/v1 handshakes that omit `object-format` normalise to
// [ObjectFormatSHA1]; v2 handshakes that omit it produce
// the empty string (a server-side protocol violation).
ObjectFormat ObjectFormat
// Commands is the curated intersection of v2 commands this
// library can issue and the server advertised. Empty for v0/v1
// handshakes. [Capabilities.Raw] carries the verbatim wire-level
// capability set for callers that need it.
Commands []string
// LSRefsArgs lists the per-command arguments the server
// accepts on `ls-refs`.
LSRefsArgs []string
// ObjectInfoArgs lists the per-command arguments the server
// accepts on `object-info`.
ObjectInfoArgs []string
// Symrefs lists v0/v1 capability-level symref advertisements.
// Empty for v2 handshakes.
Symrefs []Symref
// Raw is every advertised capability, verbatim, keyed by
// capability name. The value slice preserves both repeated
// advertisements and original order.
Raw map[string][]string
}
Capabilities records what a remote advertised during the discovery handshake: the negotiated ProtocolVersion, the server agent string, the repository's ObjectFormat, and (for v2) the commands and per-command argument lists the server claims to support.
Field semantics ¶
- [Capabilities.Version] is the protocol version actually negotiated, not the one the caller requested.
- [Capabilities.Agent] is the `agent=` capability value the server advertised, or the empty string when the server did not send one.
- [Capabilities.ObjectFormat] is the negotiated repository object format. When a v0 or v1 server omits `object-format`, the field is normalised to ObjectFormatSHA1 — omitting the capability on those protocol versions always implies SHA-1. When a v2 server omits `object-format`, the field is the empty string, signalling a protocol violation rather than a default. Unknown values advertised explicitly are preserved verbatim so callers can detect future formats.
- [Capabilities.Commands] is the curated set of v2 commands that both the server advertised and this library knows how to issue through Session methods. It is empty for v0/v1 handshakes, where command-style negotiation does not exist. Callers who want the verbatim wire-level capability set — including future v2 commands this library does not yet implement — can read [Capabilities.Raw] directly.
- [Capabilities.LSRefsArgs] and [Capabilities.ObjectInfoArgs] list the per-command arguments the server claims to accept for `ls-refs` and `object-info` respectively. The `fetch` command's argument list is not exposed as a typed field — the library never issues `fetch`, and callers that want the advertised arg list can read `Capabilities.Raw["fetch"]` verbatim.
- [Capabilities.Symrefs] lists `HEAD → refs/heads/...` style mappings advertised in the v0/v1 capability list. v2 servers surface symrefs inline on each Ref instead, so this slice is empty for v2 handshakes.
- [Capabilities.Raw] holds every capability the server advertised, keyed by capability name with each value's argument(s) preserved verbatim. It is a `map[string][]string` because the same capability name can appear multiple times in a single advertisement — `symref=HEAD:refs/heads/main` and `symref=refs/remotes/origin/HEAD:refs/heads/main`, for example — and the slice keeps every occurrence in advertised order. Capabilities advertised without a value (a bare `multi_ack` token) appear as a one-element slice containing the empty string.
Caller contract ¶
Callers must treat a returned Capabilities and any slice or map it contains as read-only. The library deep-copies the value before handing it back so mutation by the caller cannot corrupt internal state, but mutating the returned value is undefined and reserved for the library's own use.
Capabilities has no methods; callers read the exported fields directly.
type ObjectFormat ¶
type ObjectFormat string
ObjectFormat identifies the cryptographic hash function a remote repository uses to name objects. The string values are the literal tokens Git puts on the wire (`sha1` for the historical default and `sha256` for the newer object format), so callers can compare against the constants without case folding.
const ( // ObjectFormatSHA1 is the historical Git object format whose // object ids are 40-character hexadecimal SHA-1 digests. ObjectFormatSHA1 ObjectFormat = "sha1" // ObjectFormatSHA256 is the newer Git object format whose // object ids are 64-character hexadecimal SHA-256 digests. ObjectFormatSHA256 ObjectFormat = "sha256" )
type ObjectInfo ¶
type ObjectInfo struct {
// Hash is the queried object id in lower-case hexadecimal.
Hash string
// Size is the object's payload size in bytes, or `-1` when
// the size was not requested or not returned by the server.
Size int64
}
ObjectInfo carries the per-object metadata returned by the v2 `object-info` command.
[ObjectInfo.Hash] is the queried object id in lower-case hexadecimal. [ObjectInfo.Size] is the object's payload size in bytes when the caller requested it and the server returned a value, or `-1` when the size was not requested by the caller or not returned by the server. A real on-disk object can legitimately have a size of zero (an empty blob), so the negative sentinel is the only unambiguous "absent" marker.
ObjectInfo has no methods; callers read the exported fields directly.
func ObjectInfos ¶
func ObjectInfos(ctx context.Context, rawURL string, oids []string, args ObjectInfoRequest, opts ...Option, ) ([]ObjectInfo, error)
ObjectInfos is the one-shot form of Session.ObjectInfo. It dials, issues an `object-info` command, and closes the underlying Session before returning. The plural name avoids the Go-level collision with the ObjectInfo response type while keeping the relationship with Session.ObjectInfo obvious at the call site.
`object-info` is a v2-only command. A server that negotiates v0 or v1 (or a v2 server that did not advertise `object-info`) produces a *ProtocolError whose chain matches ErrUnsupportedProtocol, per Session.ObjectInfo.
type ObjectInfoRequest ¶
type ObjectInfoRequest struct {
// Size asks the server to return the payload size in bytes for
// each queried object id. Maps to the `size` argument of v2
// `object-info` and surfaces on [ObjectInfo.Size].
Size bool
}
ObjectInfoRequest collects the per-command arguments for an `object-info` invocation — the inputs a caller passes to Session.ObjectInfo and the corresponding top-level helper.
ObjectInfoRequest is a plain data carrier with no methods. The zero value is valid and queries the listed object ids without asking for any per-object attributes.
Field semantics ¶
- [ObjectInfoRequest.Size] asks the server to return the payload size in bytes for each queried object id. It maps to the `size` argument of the v2 `object-info` command and surfaces on [ObjectInfo.Size]. `object-info` is a v2-only command; on v0/v1 handshakes the library never issues it and this flag is unused.
See gitprotocol-v2.adoc §"object-info" for the v2 wire contract.
type Option ¶
type Option interface {
// contains filtered or unexported methods
}
Option configures a Dial. Compose Options from the `With*` constructors exported by this package; the interface is intentionally sealed via the unexported `applyDial` method so the option set cannot grow outside this package.
func WithProtocol ¶
func WithProtocol(v ProtocolVersion) Option
WithProtocol pins protocol negotiation to a specific wire version. Omit the option to auto-negotiate (prefer v2, fall back to v0).
There is no `Auto` sentinel: absence of WithProtocol is the auto signal. The constructor captures v by value into a fresh pointer so later mutation of the caller's variable cannot affect the stored preference.
Example ¶
ExampleWithProtocol pins protocol-version negotiation. Auto- negotiation (the default) prefers v2 and falls back to v0; pin v0 only when the caller knows the remote does not understand v2, or to reproduce a historical interaction.
package main
import (
"context"
"fmt"
"log"
lsremote "github.com/hiddeco/go-ls-remote"
)
func main() {
refs, err := lsremote.ListRefs(context.Background(),
"https://example.com/git/legacy.git",
lsremote.RefsRequest{},
lsremote.WithProtocol(lsremote.ProtocolV0))
if err != nil {
log.Fatal(err)
}
fmt.Println(len(refs), "refs")
}
Output:
func WithTracer ¶
WithTracer installs t as the trace.Tracer for protocol observability. A nil tracer disables tracing — the default — and every emission site in the library short-circuits on an explicit nil check.
Example ¶
ExampleWithTracer captures pkt-line, HTTP, and command lifecycle events through a trace.Tracer. trace.NewWriterTracer dumps a human-readable summary to an io.Writer, comparable to canonical Git's `GIT_TRACE_PACKET=` output; implement trace.Tracer directly for structured or machine-readable consumption.
package main
import (
"context"
"log"
"os"
"time"
lsremote "github.com/hiddeco/go-ls-remote"
"github.com/hiddeco/go-ls-remote/trace"
)
func main() {
tracer := trace.NewWriterTracer(os.Stderr)
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
_, err := lsremote.ListRefs(ctx,
"https://github.com/octocat/Hello-World.git",
lsremote.RefsRequest{},
lsremote.WithTracer(tracer))
if err != nil {
log.Fatal(err) //nolint:gocritic // example doc pattern: log.Fatal mirrors typical caller code
}
}
Output:
func WithTransports ¶
WithTransports replaces the default HTTP-only transport registry with r. Use it to opt into SSH, git, or file transports, or to substitute a custom HTTP client.
Passing nil is permitted and means "fall back to the package default" — Dial performs the substitution at call time, so a nil here is indistinguishable from omitting the option.
Example ¶
ExampleWithTransports composes a registry covering schemes beyond the HTTPS-only default. SSH and `git://` are intentionally opt-in so a misconfigured caller cannot accidentally reach a non-HTTPS remote; the same pattern adds them once the per-transport options (credentials, host-key callback) have been wired in. See the `ssht` and `gitt` package examples for the per-transport setup.
package main
import (
"context"
"fmt"
"log"
lsremote "github.com/hiddeco/go-ls-remote"
"github.com/hiddeco/go-ls-remote/transport"
filet "github.com/hiddeco/go-ls-remote/transport/file"
gitt "github.com/hiddeco/go-ls-remote/transport/git"
httpt "github.com/hiddeco/go-ls-remote/transport/http"
)
func main() {
reg := transport.NewRegistry(
httpt.New(),
gitt.New(),
filet.New(),
)
ctx := context.Background()
refs, err := lsremote.ListRefs(ctx, "git://example.com/repo.git",
lsremote.RefsRequest{},
lsremote.WithTransports(reg))
if err != nil {
log.Fatal(err)
}
fmt.Println(len(refs), "refs")
}
Output:
func WithUserAgent ¶
WithUserAgent overrides the User-Agent string sent on HTTP-based transports. The empty string means "use the transport's own default," which matches omitting the option entirely.
type ProtocolError ¶
type ProtocolError struct {
// URL is the request URL with credentials redacted via
// [transport.RedactURL]. The redaction is reapplied by
// [ProtocolError.Error] so the formatted output is always safe
// to log.
URL string
// Op identifies the discovery step that failed. See the type
// doc for the allowed values.
Op string
// Version is the negotiated [ProtocolVersion], or nil when the
// error occurred before the handshake completed.
Version *ProtocolVersion
// Server is the server-supplied error message (a `ERR` pkt-line
// payload, an HTTP body excerpt, or similar). Bounded by the
// transport layer to approximately 1 KiB; when the original body
// exceeded that bound the excerpt ends in `"..."` so callers can
// detect truncation. The public surface preserves the bytes the
// transport emitted verbatim and does not re-truncate.
Server string
// Err is the wrapped cause. It typically matches one of the
// package sentinels via [errors.Is]; nil when no specific cause
// is available.
Err error
// Status is the HTTP status code, or 0 when the failure
// occurred over a non-HTTP transport or before a response was
// received.
Status int
}
ProtocolError carries diagnostic context for a protocol-level failure raised by the discovery surface. It wraps an underlying cause — typically one of the package-level sentinels above — and adds enough context (URL, operation, negotiated version, HTTP status, server-supplied message) for a caller to log or report the failure without having to reach into transport internals.
Matching with errors.Is ¶
ProtocolError.Unwrap returns [ProtocolError.Err], so `errors.Is(perr, ErrNotFound)` succeeds when `perr.Err` is `ErrNotFound` or transitively wraps it via one or more `fmt.Errorf("...: %w", ...)` calls.
The Op values ¶
[ProtocolError.Op] names the discovery step that failed:
- `"dial"` — connection setup before the wire handshake began.
- `"probe"` — the initial discovery request (`GET .../info/refs`, or the equivalent on non-HTTP transports) used to detect protocol version and capabilities.
- `"advertisement"` — parsing the server's capability/ref advertisement.
- `"ls-refs"` — the v2 `ls-refs` command exchange.
- `"object-info"` — the v2 `object-info` command exchange.
Field invariants ¶
[ProtocolError.URL] is always credential-redacted via transport.RedactURL before it surfaces to a caller. The formatter in ProtocolError.Error applies the redaction on every call, so a caller cannot accidentally leak a password by reading the field directly and concatenating it into a log line — though callers are nevertheless encouraged to populate the field with an already-redacted value.
[ProtocolError.Server] is bounded to approximately 1 KiB: the transport layer caps the server-supplied excerpt at 1 KiB and appends a trailing `"..."` marker when the original body was longer, so the surfaced string can be up to 1 KiB plus the three-byte ellipsis. The marker lets callers tell a short body from one that was truncated; the public surface preserves it verbatim and does not re-truncate.
Format ¶
ProtocolError.Error returns a one-line summary of the form
lsremote: <Op>: <Err> (URL <redacted-url>) [status N] [server: <truncated>]
The bracketed bits are elided when their backing field is zero: `status N` is omitted when [ProtocolError.Status] is zero, and the `server:` section is omitted when [ProtocolError.Server] is empty. A nil [ProtocolError.Err] is printed as `<nil cause>` so the formatter never panics.
func (*ProtocolError) Error ¶
func (e *ProtocolError) Error() string
Error formats e as a single line of the form documented on ProtocolError. The URL is credential-redacted via transport.RedactURL on every call, so the returned string is always safe to log.
type ProtocolVersion ¶
type ProtocolVersion = transport.ProtocolVersion
ProtocolVersion is a Go type alias of transport.ProtocolVersion. The alias means the two are interchangeable without conversion: a `transport.ProtocolVersion` value is also a `lsremote.ProtocolVersion`, and the ProtocolV0, ProtocolV1, and ProtocolV2 constants below resolve to the same package-level constants the transport package defines.
type Ref ¶
type Ref struct {
// Name is the ref name, e.g. `HEAD`, `refs/heads/main`,
// `refs/tags/v1.0.0`.
Name string
// Hash is the hexadecimal object id the ref points at. It is
// 40 characters for `sha1` and 64 characters for `sha256`,
// or the empty string for an unborn `HEAD`.
Hash string
// Peeled is the hexadecimal object id of the peeled commit
// for an annotated tag, or the empty string when [Ref.Name]
// is not a peeled annotated tag.
Peeled string
// Symref is the target ref name when [Ref.Name] is a symbolic
// reference and the server disclosed the target, or the empty
// string otherwise.
Symref string
}
Ref names a single Git reference as it appears in the remote's discovery response.
The exported fields mirror the wire representation directly so a caller can read whatever it needs without indirection:
- [Ref.Name] is the canonical ref name — `HEAD`, `refs/heads/main`, `refs/tags/v1.0.0`, and so on.
- [Ref.Hash] is the hexadecimal object id the ref points at — 40 characters for `sha1` repositories and 64 for `sha256`. It is the empty string for the unborn-`HEAD` case where a server advertises `HEAD` with no object yet.
- [Ref.Peeled] is the hexadecimal object id of the underlying commit when [Ref.Name] is an annotated tag and the server supplied a peeled value (either inline via `^{}` on the v0/v1 wire or via the v2 `peel` argument to `ls-refs`). It is empty when the ref is not a peeled annotated tag.
- [Ref.Symref] is the target ref name when this entry is a symbolic reference and the server disclosed the target. It is empty otherwise.
Ref has no methods; callers read the exported fields directly.
func ListRefs ¶
func ListRefs(ctx context.Context, rawURL string, args RefsRequest, opts ...Option, ) ([]Ref, error)
ListRefs is the one-shot form of Session.ListRefs. It dials, collects every ref into a slice, and closes the underlying Session before returning.
On a dial-time or `ls-refs`-time failure ListRefs returns `(nil, err)` and leaves no Session open.
Example ¶
ExampleListRefs collects every advertised ref into a slice. Use this when the caller wants the slice form rather than the iterator returned by lsremote.Refs; the two share the same underlying `ls-refs` exchange on a v2 server.
lsremote.RefsRequest.Symrefs asks the server to disclose the target of symbolic references (`HEAD`, `refs/remotes/origin/HEAD`, ...); lsremote.Ref.Symref is empty for non-symbolic refs.
package main
import (
"context"
"fmt"
"log"
lsremote "github.com/hiddeco/go-ls-remote"
)
func main() {
refs, err := lsremote.ListRefs(context.Background(),
"https://github.com/octocat/Hello-World.git",
lsremote.RefsRequest{Symrefs: true})
if err != nil {
log.Fatal(err)
}
for _, ref := range refs {
if ref.Symref != "" {
fmt.Printf("%s -> %s\n", ref.Name, ref.Symref)
continue
}
fmt.Printf("%s %s\n", ref.Hash, ref.Name)
}
}
Output:
type RefsRequest ¶
type RefsRequest struct {
// Prefixes restricts the returned refs to those whose names
// begin with one of the listed strings. Applied server-side on
// v2 and client-side on v0/v1. An empty or nil slice means no
// filtering.
Prefixes []string
// Peel asks the server to include peeled object ids for
// annotated tags. On v2 this maps to the `peel` argument; on
// v0/v1 peeled values ride inline on the advertisement.
Peel bool
// Symrefs asks the server to disclose symref targets alongside
// each [Ref]. On v2 this maps to the `symrefs` argument and
// surfaces on [Ref.Symref]. On v0/v1 the flag has no wire effect,
// but the library post-fills [Ref.Symref] from
// [Capabilities.Symrefs] when the flag is set.
Symrefs bool
// Unborn asks a v2 server to advertise an unborn `HEAD`. The
// flag is honoured only when the server advertises
// `ls-refs=unborn`. On v0/v1 the flag has no effect.
Unborn bool
}
RefsRequest collects the per-command arguments for an `ls-refs` invocation — the inputs a caller passes to Session.Refs, Session.ListRefs, and the corresponding top-level helpers.
RefsRequest is a plain data carrier with no methods. The zero value is valid and asks the server for every ref it would normally advertise, without peeled object ids, symref targets, or an unborn `HEAD`.
Field semantics ¶
- [RefsRequest.Prefixes] restricts the returned refs to those whose names begin with one of the listed strings. On the v2 wire the filter is applied server-side: the library forwards each entry verbatim as a `ref-prefix <value>` argument to `ls-refs`. On the v0/v1 wire there is no equivalent capability, so the library fetches the full advertisement and applies the filter client side. Either way, an empty or nil [RefsRequest.Prefixes] means "do not filter".
- [RefsRequest.Peel] asks the server to include the peeled object id for annotated tags. On v2 it maps to the `peel` argument to `ls-refs`; on v0/v1 the peeled value rides inline on the advertisement (`<oid> <name>^{}`) regardless of this flag, and the library keeps the flag's behaviour consistent by surfacing the value on [Ref.Peeled] only when the caller asked for it.
- [RefsRequest.Symrefs] asks the server to disclose symref targets alongside each Ref. On v2 it maps to the `symrefs` argument to `ls-refs` and surfaces inline on [Ref.Symref]. On v0/v1 the flag has no wire effect (the symref info already rides on the capability list), but the library post-fills [Ref.Symref] from [Capabilities.Symrefs] when the flag is set, unifying the call-site experience with v2. When the flag is not set on v0/v1, [Ref.Symref] is always empty; the raw mapping remains available on [Capabilities.Symrefs] regardless.
- [RefsRequest.Unborn] asks a v2 server to advertise an unborn `HEAD` — a `HEAD` whose target ref exists but holds no commit yet. The flag maps to the `unborn` argument to `ls-refs` and is honoured only when the server advertises `ls-refs=unborn` in its capability list. On v0/v1 the wire has no concept of an unborn `HEAD`, so the flag is ignored.
See gitprotocol-v2.adoc §"ls-refs" for the v2 wire contract.
type Session ¶
type Session struct {
// contains filtered or unexported fields
}
Session represents an open discovery-time connection to a remote Git repository. A Session is produced by Dial after the handshake completes; methods on a Session issue one or more discovery commands — `ls-refs`, `object-info` — against the same underlying connection.
Opaque type ¶
Session has no exported fields. Callers obtain a `*Session` from Dial and interact with it only through its methods; the internal state — the live transport.Conn, the negotiated Capabilities, the cached advertisement-time refs (for v0/v1 only), the captured dial configuration and the redacted URL — is library-private.
Concurrency ¶
A `*Session` is safe for concurrent use only when the underlying transport multiplexes independent commands onto independent network requests. The HTTP transport satisfies this: each v2 command is its own POST, so two goroutines can issue commands against the same HTTP-backed Session without external synchronisation. The SSH, `git://`, and `file://` transports do NOT satisfy it: they share a single bidirectional byte stream where one in-flight command must drain before the next begins. Callers using a non-HTTP transport must serialise Session method calls externally.
Lifecycle ¶
A Session owns the underlying transport.Conn. Session.Close releases that connection; repeated calls return nil per the transport.Conn idempotent-Close contract.
func Dial ¶
Dial opens a discovery-time connection to the Git repository at rawURL and returns a *Session ready to issue discovery commands.
Dial is eager: by the time it returns, the URL has been parsed, a transport has been selected, the connection has been opened, and the server's capability advertisement has been read and translated into a Capabilities value. Callers who want lazy connection setup should defer the Dial itself, not wrap it.
Options ¶
The resolved configuration is:
- The transport transport.Registry supplied via WithTransports, or — when omitted — an HTTP-only default registry covering the `https` and `http` schemes.
- The trace.Tracer supplied via WithTracer, or no tracing.
- The User-Agent string supplied via WithUserAgent, or the transport's own default.
- The protocol version pinned by WithProtocol, or auto-negotiate when omitted. Auto-negotiation prefers v2 and falls back to v0.
Error model ¶
Failure modes split by the discovery step that produced them.
- URL parse failures from transport.ParseURL are returned verbatim; they never reach the wire, so wrapping them in a ProtocolError would be misleading. Callers match these with errors.Is against the transport.ErrEmptyURL, transport.ErrUnsupportedScheme, and related sentinels.
- An unknown scheme — the URL parsed but no transport is registered for it — surfaces as a *ProtocolError with `Op == "dial"`, `Err` wrapping the public ErrUnsupportedProtocol sentinel and a short message on [ProtocolError.Server] naming the missing scheme.
- Any error returned by the chosen transport's transport.Transport.Open is wrapped in a *ProtocolError with `Op == "dial"` and the underlying error placed on `Err`. errors.Is walks transitively through the wrap, so callers match against the public ErrNotFound, ErrAuthRequired, ErrAuthFailed, and ErrUnsupportedProtocol sentinels without having to know which transport produced the failure.
- An error reading or parsing the server's advertisement surfaces as a *ProtocolError with `Op == "advertisement"`. The wire layer's `ErrUnsupportedProtocol` sentinel is joined with the public ErrUnsupportedProtocol so a caller's `errors.Is` check against the public sentinel succeeds. The underlying transport.Conn is closed on this path so callers do not see a leaked half-open connection.
See Option, WithTransports, WithTracer, WithUserAgent, WithProtocol, and Session for the surrounding types.
Example ¶
ExampleDial opens one session and issues several discovery commands against it. Reusing a single session amortises the handshake — useful when a caller wants both a ref list and per-object metadata from the same remote.
A lsremote.Session is safe for concurrent use only when the underlying transport multiplexes commands onto independent requests. The HTTP transport satisfies that; SSH, `git://`, and `file://` do not — callers using a non-HTTP transport must serialise method calls externally.
package main
import (
"context"
"fmt"
"log"
lsremote "github.com/hiddeco/go-ls-remote"
)
func main() {
ctx := context.Background()
session, err := lsremote.Dial(ctx,
"https://github.com/octocat/Hello-World.git")
if err != nil {
log.Fatal(err)
}
defer session.Close()
caps := session.Capabilities()
fmt.Println("protocol ", caps.Version)
fmt.Println("server agent ", caps.Agent)
fmt.Println("object format", caps.ObjectFormat)
heads, err := session.ListRefs(ctx, lsremote.RefsRequest{
Prefixes: []string{"refs/heads/"},
})
if err != nil {
log.Fatal(err) //nolint:gocritic // example doc pattern: log.Fatal mirrors typical caller code
}
fmt.Println("branches:", len(heads))
}
Output:
func (*Session) Capabilities ¶
func (s *Session) Capabilities() Capabilities
Capabilities returns a deep copy of the capability snapshot captured at Dial time. The copy is independent of the Session's internal state: mutating any slice or map on the returned value cannot affect the result of a later call.
The fields are populated per the rules documented on Capabilities itself.
Allocation ¶
`Capabilities` allocates a fresh deep copy on every call. Callers in hot loops should cache the returned value rather than calling this method repeatedly.
func (*Session) Close ¶
Close releases the underlying transport.Conn. The contract on transport.Conn.Close requires the implementation to be idempotent, so a second or later call after the first returns nil.
func (*Session) ListRefs ¶
ListRefs collects the refs yielded by Session.Refs into a slice. Iteration stops on the first error, which is returned alongside a nil slice; otherwise every yielded ref is appended in order.
func (*Session) ObjectInfo ¶
func (s *Session) ObjectInfo(ctx context.Context, oids []string, args ObjectInfoRequest, ) ([]ObjectInfo, error)
ObjectInfo issues a v2 `object-info` command and returns the per-OID metadata the server replied with.
`object-info` is a v2-only command. A Session whose [Capabilities.Version] is not ProtocolV2, or whose v2 [Capabilities.Commands] set does not include `object-info`, returns a *ProtocolError with `Op == "object-info"` whose error chain matches ErrUnsupportedProtocol. The capability-set guard mirrors canonical Git's pre-issue check: mainstream hosts advertise v2 with only `ls-refs` and `fetch`, so a client-side short-circuit is the only way to surface a public-typed error rather than a raw transport failure.
The `oids` slice is forwarded verbatim as one `oid <hex>` argument per element. When `args.Size` is true the request also carries the `size` argument and each returned [ObjectInfo.Size] is populated with the server's reported size. When `args.Size` is false the Session sets every returned [ObjectInfo.Size] to `-1`, matching the "size not requested" sentinel documented on the ObjectInfo type itself. The library cannot distinguish a real zero-byte blob from a size the server elided on a Size-requested call, so a returned `Size == 0` is left as-is in the Size-requested branch.
`ObjectInfo` returns a slice, not an iterator, because the response is bounded by the caller-supplied `oids` count and can be safely materialised in memory. `Session.Refs`, by contrast, returns an iterator: an `ls-refs` response can be arbitrarily large and should be streamed rather than buffered.
Example ¶
ExampleSession_ObjectInfo asks the server for the payload size of a handful of objects without fetching them — useful for supply-chain sizing, mirror estimates, or release-asset audits. `object-info` is a v2-only command; sessions that negotiated v0 or v1, or v2 servers that did not advertise the command, surface lsremote.ErrUnsupportedProtocol.
lsremote.ObjectInfo.Size is `-1` when the size was not requested or the server elided it; a real on-disk object can legitimately have a size of zero (an empty blob), so the negative sentinel is the only unambiguous "absent" marker.
package main
import (
"context"
"errors"
"fmt"
"log"
lsremote "github.com/hiddeco/go-ls-remote"
)
func main() {
ctx := context.Background()
session, err := lsremote.Dial(ctx,
"https://github.com/octocat/Hello-World.git")
if err != nil {
log.Fatal(err)
}
defer session.Close()
infos, err := session.ObjectInfo(ctx, []string{
"7fd1a60b01f91b314f59955a4e4d4e80d8edf11d",
"553c2077f0edc3d5dc5d17262f6aa498e69d6f8e",
}, lsremote.ObjectInfoRequest{Size: true})
if err != nil {
// A v2 server without object-info advertised is a common case
// on hosts that only ship ls-refs and fetch; degrade gracefully.
if errors.Is(err, lsremote.ErrUnsupportedProtocol) {
fmt.Println("server does not support object-info")
return
}
log.Fatal(err) //nolint:gocritic // example doc pattern: log.Fatal mirrors typical caller code
}
for _, info := range infos {
fmt.Printf("%s %d bytes\n", info.Hash, info.Size)
}
}
Output:
func (*Session) Refs ¶
Refs returns an iterator over the server's references.
On v2 (ProtocolV2) Refs issues an `ls-refs` command, forwarding [RefsRequest.Prefixes] verbatim as `ref-prefix` arguments and toggling `peel`, `symrefs`, and `unborn` per the corresponding RefsRequest flags. The `unborn` argument is dropped silently when the server did not advertise `ls-refs=unborn` in its capability list (connect.c::get_remote_refs lines 564-597). The iterator streams the response and yields one (Ref, error) pair per emission.
On v0/v1 the wire has no `ls-refs` equivalent: the full ref advertisement has already been consumed by Dial and cached on the Session. Refs filters that cached slice by [RefsRequest.Prefixes] client-side and yields the survivors. [RefsRequest.Peel] and [RefsRequest.Unborn] are still no-ops on v0/v1: peeled-tag information rides inline on the v0/v1 advertisement regardless, and v0/v1 has no unborn-`HEAD` wire representation.
[RefsRequest.Symrefs] is honoured client-side on v0/v1 even though it has no wire effect: when the flag is true the library post-fills [Ref.Symref] on each yielded ref from [Capabilities.Symrefs], unifying the call-site experience with v2. When the flag is false, [Ref.Symref] is always empty on yielded refs, regardless of what the advertisement carried. [Capabilities.Symrefs] remains populated in both cases for callers who prefer the capability-level view.
The returned error is non-nil only when the v2 command request itself fails before any response bytes are consumed (for example a transport-level POST failure). A wire-level decode error mid-stream surfaces through the iterator: it yields a single (zero Ref, err) pair wrapping the cause in a `*ProtocolError` with `Op == "ls-refs"` and stops. Iteration over a successful v0/v1 path never yields an error.
Example ¶
ExampleSession_Refs walks the iterator returned by lsremote.Session.Refs and breaks out early. The library wraps the iterator so the connection state stays consistent on early `break`: the response is drained behind the scenes and the session remains usable for subsequent commands — load-bearing on the SSH, `git://`, and `file://` transports where one byte stream carries every command.
package main
import (
"context"
"fmt"
"log"
lsremote "github.com/hiddeco/go-ls-remote"
)
func main() {
ctx := context.Background()
session, err := lsremote.Dial(ctx,
"https://github.com/octocat/Hello-World.git")
if err != nil {
log.Fatal(err)
}
defer session.Close()
seq, err := session.Refs(ctx, lsremote.RefsRequest{
Prefixes: []string{"refs/tags/"},
Peel: true,
})
if err != nil {
log.Fatal(err) //nolint:gocritic // example doc pattern: log.Fatal mirrors typical caller code
}
// Take the first ten tags only.
n := 0
for ref, err := range seq {
if err != nil {
log.Fatal(err)
}
fmt.Println(ref.Name)
n++
if n == 10 {
break
}
}
}
Output:
type Symref ¶
type Symref struct {
// Name is the symbolic ref's own name, e.g. `HEAD`.
Name string
// Target is the ref name the symref points at, e.g.
// `refs/heads/main`.
Target string
}
Symref names a single symbolic-reference mapping that a v0/v1 server advertised in its capability list — for example `HEAD → refs/heads/main`.
v2 servers do not advertise symrefs at the capability level; they expose the same information inline on each Ref via [Ref.Symref] when the caller passes the `symrefs` argument to `ls-refs`. As a result [Capabilities.Symrefs] is populated only for v0/v1 handshakes and is left empty for v2.
Symref has no methods; callers read the exported fields directly.
Source Files
¶
Directories
¶
| Path | Synopsis |
|---|---|
|
internal
|
|
|
dumbhttp
Package dumbhttp synthesises a v0-shaped pkt-line stream from the `info/refs` body of a Git "dumb" HTTP server.
|
Package dumbhttp synthesises a v0-shaped pkt-line stream from the `info/refs` body of a Git "dumb" HTTP server. |
|
inttest
Package inttest is the integration-test support layer that wires fixture repositories to in-process transports.
|
Package inttest is the integration-test support layer that wires fixture repositories to in-process transports. |
|
livetest
Package livetest exercises the library end-to-end against real Git hosting providers.
|
Package livetest exercises the library end-to-end against real Git hosting providers. |
|
objfmt
Package objfmt models Git's on-disk object formats: the hash algorithms in use, the typed object id values for each, and the pack object type enum.
|
Package objfmt models Git's on-disk object formats: the hash algorithms in use, the typed object id values for each, and the pack object type enum. |
|
objstore
Package objstore is the read-only on-disk Git object store.
|
Package objstore is the read-only on-disk Git object store. |
|
reftable
Package reftable reads Git reftable files: a stack-of-tables ref storage format with binary search inside each table and a merged view across the stack.
|
Package reftable reads Git reftable files: a stack-of-tables ref storage format with binary search inside each table and a merged view across the stack. |
|
server
Package server implements an in-process emulator of canonical Git's `upload-pack` discovery-time wire protocols.
|
Package server implements an in-process emulator of canonical Git's `upload-pack` discovery-time wire protocols. |
|
testfixture
Package testfixture materialises on-disk repository fixtures from `testdata/repos/<name>/` into a fresh `t.TempDir()`.
|
Package testfixture materialises on-disk repository fixtures from `testdata/repos/<name>/` into a fresh `t.TempDir()`. |
|
wire
Package wire encodes and decodes the Git smart-protocol wire formats consumed by the root `lsremote` package.
|
Package wire encodes and decodes the Git smart-protocol wire formats consumed by the root `lsremote` package. |
|
Package pktline implements the pkt-line wire-format codec used by the Git smart protocol in versions 0, 1, and 2.
|
Package pktline implements the pkt-line wire-format codec used by the Git smart protocol in versions 0, 1, and 2. |
|
Package trace defines the Tracer interface and event types emitted by the go-ls-remote library at significant points in the wire-protocol and HTTP lifecycle.
|
Package trace defines the Tracer interface and event types emitted by the go-ls-remote library at significant points in the wire-protocol and HTTP lifecycle. |
|
Package transport defines the abstraction every Git wire-protocol transport (HTTP, SSH, git daemon, file) implements.
|
Package transport defines the abstraction every Git wire-protocol transport (HTTP, SSH, git daemon, file) implements. |
|
file
Package filet implements the local-filesystem Git transport.Transport.
|
Package filet implements the local-filesystem Git transport.Transport. |
|
git
Package gitt implements the git-daemon transport for the `git://` URL scheme.
|
Package gitt implements the git-daemon transport for the `git://` URL scheme. |
|
http
Package httpt is the HTTP/HTTPS Git transport.
|
Package httpt is the HTTP/HTTPS Git transport. |
|
ssh
Package ssht is the SSH Git transport.
|
Package ssht is the SSH Git transport. |