# Merge pull request #102 from entireio/paul/ua-service-identity

`5eb8cb0`→main·

toothbrush·1w ago·7 files·+196 added/-12 removed

Add SetIdentity for embedder User-Agent attribution; derive default version from build info

## Changes

### Added

- `gitsync.SetIdentity(service, version)` — lets an embedding service name itself in every request git-sync makes. The HTTP User-Agent and the git-protocol `agent=` capability become `<service>/<version> git-sync/<git-sync-version> go-git/<go-git-version>` (non-git provider requests carry the same string without the go-git token). Previously an embedder had no way to identify itself: the advertised version lives in an internal package, and the old doc comment claiming "SDK consumers may overwrite it" was unimplementable from outside the module.

- `RefScope.ExcludeRefs` — exact ref-name exclusion, alongside the existing prefix-based `ExcludeRefPrefixes`. An excluded exact name is not pulled, pushed, or pruned, but — unlike a prefix — its children are unaffected, so a caller can reserve a directory-anchor ref like `refs/heads/entire` while still mirroring `refs/heads/entire/foo`. Threaded through `RefScope` → planner `PlanConfig`; `IsRefExcluded` now takes both prefix and exact lists.

### Changed

- The default advertised git-sync version is now resolved from the binary's embedded build info instead of the hardcoded "dev": embedders automatically advertise the git-sync module version they built against, and a `go install ...@version` CLI build advertises that version. "dev" remains only for builds with no usable version (e.g. a plain `go build` of this repo). The goreleaser-stamped CLI version still takes precedence when present.

### Removed

- The built-in Entire DB credential store integration (`hosts.json` active-user lookup, the file/keyring token store, and OAuth refresh-token handling). `auth.Resolve` now resolves only explicit token/bearer credentials; everything else defers to the git credential helper on a 401, exactly as for any other remote. The Entire mirroring pipeline and the `git-remote-entire` helper already supply credentials directly (installation / repo-scoped tokens at the transport layer), so nothing produced the `hosts.json`/token-store layout this code read. This drops the `github.com/zalando/go-keyring` dependency and, with the file token store gone, the package now compiles on Windows without a `flock` shim.

```go
package gitsync

import (
	"strings"
	"entire.io/entire/git-sync/internal/useragent"
)

// SetIdentity names the embedding service in every request git-sync makes:
// the HTTP User-Agent and the git-protocol "agent=" capability become
// "<service>/<version> git-sync/<git-sync-version> go-git/<go-git-version>"
// (non-git provider requests carry the same string without the go-git
// token). git-sync's own version is reported regardless; SetIdentity adds
// the service's identity in front so server operators can attribute traffic
// to the service, not just the library.
//
// The identity is process-wide and read on every request: call SetIdentity
// once at startup, before issuing requests from any Client. An empty
// service removes the identity; an empty version advertises the bare
// service name. Whitespace within either value is collapsed to "-" so the
// result stays a single User-Agent product token.
func SetIdentity(service, version string) {
	service = sanitizeToken(service)
	if service == "" {
		useragent.Identity = ""
		return
	}
	if version = sanitizeToken(version); version != "" {
		service += "/" + version
	}
	useragent.Identity = service
}

func sanitizeToken(s string) string {
	return strings.Join(strings.Fields(s), "-")
}
```

```go
package gitsync

import (
	"testing"
	"entire.io/entire/git-sync/internal/useragent"
	"github.com/go-git/go-git/v6/plumbing/protocol/capability"
)

func TestSetIdentity(t *testing.T) {
	// Not parallel — mutates process-wide user-agent state.
	orig := useragent.Identity
	t.Cleanup(func() { useragent.Identity = orig })

tests := []struct {
		name             string
		service, version string
		wantIdentity     string
	}{
		{"service and version", "mirror-worker", "abc1234", "mirror-worker/abc1234"},
		{"service only", "mirror-worker", "", "mirror-worker"},
		{"empty service clears", "", "abc1234", ""},
		{"whitespace collapsed", "mirror worker", "v1 2", "mirror-worker/v1-2"},
		{"blank service clears", "   ", "abc1234", ""},
	}
	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			SetIdentity(tt.service, tt.version)
			if useragent.Identity != tt.wantIdentity {
				t.Errorf("SetIdentity(%q, %q) → Identity = %q, want %q",
					t.service, tt.version, useragent.Identity, tt.wantIdentity)
			}
		})
	}
}

func TestModuleVersionInTestBinary(t *testing.T) {
	t.Parallel()

// In this repo's own test binary git-sync is the main module with no
	// stamped version, so the build-info lookup must fall back to "dev"
	// rather than leaking "(devel)" or an empty string into the UA.
	if got := moduleVersion(); got != "dev" {
		t.Errorf("moduleVersion() in test binary = %q, want %q", got, "dev")
	}
}
```
