# Add initial public gitsync package

`3e236b4`→[main](/content/gh/entireio/git-sync/commits/main/index.html)·

Soph·3mo ago·3 files·+448 added/-0 removed

## Sessions

5fe00ff669ffView transcript

## Changes

3

- pkg/gitsync
  
  - Aclient.go+119
  
  - Aclient_test.go+55
  
  - Atypes.go+274

```

package gitsync

import (
	"context"
	"fmt"

"github.com/soph/git-sync/internal/syncer"
)

// Options configures a Client. It is intentionally small in the first public cut.
type Options struct{}

// Client provides the public orchestration API for git-sync.
type Client struct{}

// New constructs a new Client.
func New(opts Options) *Client {
	_ = opts
	return &Client{}
}

// Probe inspects a source remote and optional target remote.
func (c *Client) Probe(ctx context.Context, req ProbeRequest) (ProbeResult, error) {
	if err := req.Validate(); err != nil {
		return ProbeResult{}, err
	}
	result, err := syncer.Probe(ctx, buildProbeConfig(req))
	if err != nil {
		return ProbeResult{}, err
	}
	return fromSyncerProbeResult(result), nil
}

// Plan computes ref actions without pushing.
func (c *Client) Plan(ctx context.Context, req PlanRequest) (PlanResult, error) {
	if err := req.Validate(); err != nil {
		return PlanResult{}, err
	}
	result, err := syncer.Run(ctx, buildSyncConfig(req.Source, req.SourceAuth, req.Target, req.TargetAuth, req.Scope, req.Policy, req.CollectStats, true))
	if err != nil {
		return PlanResult{}, err
	}
	return fromSyncerResult(result), nil
}

// Sync executes a sync between two remotes.
func (c *Client) Sync(ctx context.Context, req SyncRequest) (SyncResult, error) {
	if err := req.Validate(); err != nil {
		return SyncResult{}, err
	}
	result, err := syncer.Run(ctx, buildSyncConfig(req.Source, req.SourceAuth, req.Target, req.TargetAuth, req.Scope, req.Policy, req.CollectStats, false))
	if err != nil {
		return SyncResult{}, err
	}
	return fromSyncerResult(result), nil
}

func buildProbeConfig(req ProbeRequest) syncer.Config {
	cfg := syncer.Config{
		Source:       syncer.Endpoint{URL: req.Source.URL, Username: req.SourceAuth.Username, Token: req.SourceAuth.Token, BearerToken: req.SourceAuth.BearerToken, SkipTLSVerify: req.SourceAuth.SkipTLSVerify},
		IncludeTags:  req.IncludeTags,
		ShowStats:    req.CollectStats,
		ProtocolMode: string(req.Protocol),
	}
	if req.Target != nil {
		cfg.Target = syncer.Endpoint{URL: req.Target.URL, Username: req.TargetAuth.Username, Token: req.TargetAuth.Token, BearerToken: req.TargetAuth.BearerToken, SkipTLSVerify: req.TargetAuth.SkipTLSVerify}
	}
	return cfg
}

func buildSyncConfig(source Endpoint, sourceAuth EndpointAuth, target Endpoint, targetAuth EndpointAuth, scope RefScope, policy SyncPolicy, collectStats, dryRun bool) syncer.Config {
	return syncer.Config{
		Source:                 syncer.Endpoint{URL: source.URL, Username: sourceAuth.Username, Token: sourceAuth.Token, BearerToken: sourceAuth.BearerToken, SkipTLSVerify: sourceAuth.SkipTLSVerify},
		Target:                 syncer.Endpoint{URL: target.URL, Username: targetAuth.Username, Token: targetAuth.Token, BearerToken: targetAuth.BearerToken, SkipTLSVerify: targetAuth.SkipTLSVerify},
		Branches:               append([]string(nil), scope.Branches...),
		Mappings:               append([]RefMapping(nil), scope.Mappings...),
		IncludeTags:            policy.IncludeTags,
		DryRun:                 dryRun,
		ShowStats:              collectStats,
		Force:                  policy.Force,
		Prune:                  policy.Prune,
		ProtocolMode:           protocolString(policy.Protocol),
		MaterializedMaxObjects: syncer.DefaultMaterializedMaxObjects,
	}
}

func protocolString(mode ProtocolMode) string {
	if mode == "" {
		return string(ProtocolAuto)
	}
	return string(mode)
}

func (r SyncRequest) Validate() error {
	if r.Source.URL == "" {
		return fmt.Errorf("source URL is required")
	}
	if r.Target.URL == "" {
		return fmt.Errorf("target URL is required")
	}
	return nil
}

func (r PlanRequest) Validate() error {
	if r.Source.URL == "" {
		return fmt.Errorf("source URL is required")
	}
	if r.Target.URL == "" {
		return fmt.Errorf("target URL is required")
	}
	return nil
}

func (r ProbeRequest) Validate() error {
	if r.Source.URL == "" {
		return fmt.Errorf("source URL is required")
	}
	return nil
}
```

Apkg/gitsync/client.go+119

```

package gitsync

import (
	"testing"

"github.com/go-git/go-git/v6/plumbing"
)

func TestBuildSyncConfigUsesDefaultProtocolAndMaterializedLimit(t *testing.T) {
	cfg := buildSyncConfig(
		Endpoint{URL: "https://source.example/repo.git"},
		EndpointAuth{Token: "src"},
		Endpoint{URL: "https://target.example/repo.git"},
		EndpointAuth{Token: "dst"},
		RefScope{Branches: []string{"main"}},
		SyncPolicy{},
		true,
		false,
	)

if cfg.ProtocolMode != string(ProtocolAuto) {
		t.Fatalf("protocol mode = %q, want %q", cfg.ProtocolMode, ProtocolAuto)
	}
	if cfg.MaterializedMaxObjects <= 0 {
		t.Fatalf("materialized max objects = %d, want positive default", cfg.MaterializedMaxObjects)
	}
	if !cfg.ShowStats {
		t.Fatalf("show stats = false, want true")
	}
	if cfg.DryRun {
		t.Fatalf("dry run = true, want false")
	}
	if cfg.Source.Token != "src" || cfg.Target.Token != "dst" {
		t.Fatalf("unexpected token mapping: %+v %+v", cfg.Source, cfg.Target)
	}
}

func TestValidateRequests(t *testing.T) {
	if err := (ProbeRequest{}).Validate(); err == nil {
		t.Fatalf("expected probe validation error")
	}
	if err := (PlanRequest{}).Validate(); err == nil {
		t.Fatalf("expected plan validation error")
	}
	if err := (SyncRequest{}).Validate(); err == nil {
		t.Fatalf("expected sync validation error")
	}
}

func TestFromSyncerResultZeroHashesAreEmptyStrings(t *testing.T) {
	got := hashString(plumbing.ZeroHash)
	if got != "" {
		t.Fatalf("hashString(zero) = %q, want empty string", got)
	}
}
```

Apkg/gitsync/client_test.go+55

```

package gitsync

import (
	"github.com/go-git/go-git/v6/plumbing"

"github.com/soph/git-sync/internal/planner"
	"github.com/soph/git-sync/internal/syncer"
	"github.com/soph/git-sync/internal/validation"
)

// ProtocolMode controls source-side protocol negotiation.
type ProtocolMode string

const (
	ProtocolAuto ProtocolMode = validation.ProtocolAuto
	ProtocolV1   ProtocolMode = validation.ProtocolV1
	ProtocolV2   ProtocolMode = validation.ProtocolV2
)

// Endpoint identifies a remote Git endpoint.
type Endpoint struct {
	URL string
}

// EndpointAuth carries explicit per-request auth and TLS settings.
type EndpointAuth struct {
	Username      string
	Token         string
	BearerToken   string
	SkipTLSVerify bool
}

// RefMapping is an explicit source-to-target ref mapping.
type RefMapping = validation.RefMapping

// RefScope constrains which refs a request manages.
type RefScope struct {
	Branches []string
	Mappings []RefMapping
}

// SyncPolicy controls high-level sync behavior.
type SyncPolicy struct {
	IncludeTags bool
	Force       bool
	Prune       bool
	Protocol    ProtocolMode
}

// ProbeRequest inspects source refs and optional target capabilities.
type ProbeRequest struct {
	Source       Endpoint
	SourceAuth   EndpointAuth
	Target       *Endpoint
	TargetAuth   EndpointAuth
	IncludeTags  bool
	Protocol     ProtocolMode
	CollectStats bool
}

// PlanRequest computes ref actions without pushing.
type PlanRequest struct {
	Source       Endpoint
	SourceAuth   EndpointAuth
	Target       Endpoint
	TargetAuth   EndpointAuth
	Scope        RefScope
	Policy       SyncPolicy
	CollectStats bool
}

// SyncRequest executes a sync between two remotes.
type SyncRequest struct {
	Source       Endpoint
	SourceAuth   EndpointAuth
	Target       Endpoint
	TargetAuth   EndpointAuth
	Scope        RefScope
	Policy       SyncPolicy
	CollectStats bool
}

// RefKind distinguishes branch refs from tag refs.
type RefKind string

const (
	RefKindBranch RefKind = RefKind(planner.RefKindBranch)
	RefKindTag    RefKind = RefKind(planner.RefKindTag)
)

// Action describes the planned or executed operation on a ref.
type Action string

const (
	ActionCreate Action = Action(planner.ActionCreate)
	ActionUpdate Action = Action(planner.ActionUpdate)
	ActionDelete Action = Action(planner.ActionDelete)
	ActionSkip   Action = Action(planner.ActionSkip)
	ActionBlock  Action = Action(planner.ActionBlock)
)

// RefPlan describes the outcome for a single ref.
type RefPlan struct {
	Branch     string  `json:"branch"`
	SourceRef  string  `json:"source_ref"`
	TargetRef  string  `json:"target_ref"`
	SourceHash string  `json:"source_hash"`
	TargetHash string  `json:"target_hash"`
	Kind       RefKind `json:"kind"`
	Action     Action  `json:"action"`
	Reason     string  `json:"reason"`
}

// RefInfo identifies a named ref.
type RefInfo struct {
	Name string `json:"name"`
	Hash string `json:"hash"`
}

// ServiceStats tracks transfer statistics for a single service.
type ServiceStats struct {
	Name          string `json:"name"`
	Requests      int    `json:"requests"`
	RequestBytes  int64  `json:"request_bytes"`
	ResponseBytes int64  `json:"response_bytes"`
	Wants         int    `json:"wants"`
	Haves         int    `json:"haves"`
	Commands      int    `json:"commands"`
}

// Stats summarizes transfer metrics.
type Stats struct {
	Enabled bool                     `json:"enabled"`
	Items   map[string]*ServiceStats `json:"items"`
}

// Measurement summarizes elapsed time and Go heap usage.
type Measurement struct {
	Enabled            bool   `json:"enabled"`
	ElapsedMillis      int64  `json:"elapsed_millis"`
	PeakAllocBytes     uint64 `json:"peak_alloc_bytes"`
	PeakHeapInuseBytes uint64 `json:"peak_heap_inuse_bytes"`
	TotalAllocBytes    uint64 `json:"total_alloc_bytes"`
	GCCount            uint32 `json:"gc_count"`
}

// ProbeResult holds structured probe output suitable for workers.
type ProbeResult struct {
	SourceURL     string      `json:"source_url"`
	TargetURL     string      `json:"target_url,omitempty"`
	RequestedMode string      `json:"requested_mode"`
	Protocol      string      `json:"protocol"`
	RefPrefixes   []string    `json:"ref_prefixes"`
	Capabilities  []string    `json:"source_capabilities"`
	TargetCaps    []string    `json:"target_capabilities,omitempty"`
	Refs          []RefInfo   `json:"refs"`
	Stats         Stats       `json:"stats"`
	Measurement   Measurement `json:"measurement"`
}

// SyncResult holds structured sync output suitable for workers.
type SyncResult struct {
	Plans              []RefPlan   `json:"plans"`
	Pushed             int         `json:"pushed"`
	Skipped            int         `json:"skipped"`
	Blocked            int         `json:"blocked"`
	Deleted            int         `json:"deleted"`
	DryRun             bool        `json:"dry_run"`
	Relay              bool        `json:"relay"`
	RelayMode          string      `json:"relay_mode"`
	RelayReason        string      `json:"relay_reason"`
	Batching           bool        `json:"batching"`
	BatchCount         int         `json:"batch_count"`
	PlannedBatchCount  int         `json:"planned_batch_count"`
	TempRefs           []string    `json:"temp_refs"`
	BootstrapSuggested bool        `json:"bootstrap_suggested"`
	Stats              Stats       `json:"stats"`
	Measurement        Measurement `json:"measurement"`
	Protocol           string      `json:"protocol"`
}

// PlanResult is the dry-run form of SyncResult.
type PlanResult = SyncResult

func fromSyncerProbeResult(result syncer.ProbeResult) ProbeResult {
	out := ProbeResult{
		SourceURL:     result.SourceURL,
		TargetURL:     result.TargetURL,
		RequestedMode: result.RequestedMode,
		Protocol:      result.Protocol,
		RefPrefixes:   append([]string(nil), result.RefPrefixes...),
		Capabilities:  append([]string(nil), result.Capabilities...),
		TargetCaps:    append([]string(nil), result.TargetCaps...),
		Refs:          make([]RefInfo, 0, len(result.Refs)),
		Stats:         fromSyncerStats(result.Stats),
		Measurement:   fromSyncerMeasurement(result.Measurement),
	}
	for _, ref := range result.Refs {
		out.Refs = append(out.Refs, RefInfo{Name: ref.Name, Hash: ref.Hash.String()})
	}
	return out
}

func fromSyncerResult(result syncer.Result) SyncResult {
	out := SyncResult{
		Plans:              make([]RefPlan, 0, len(result.Plans)),
		Pushed:             result.Pushed,
		Skipped:            result.Skipped,
		Blocked:            result.Blocked,
		Deleted:            result.Deleted,
		DryRun:             result.DryRun,
		Relay:              result.Relay,
		RelayMode:          result.RelayMode,
		RelayReason:        result.RelayReason,
		Batching:           result.Batching,
		BatchCount:         result.BatchCount,
		PlannedBatchCount:  result.PlannedBatchCount,
		TempRefs:           append([]string(nil), result.TempRefs...),
		BootstrapSuggested: result.BootstrapSuggested,
		Stats:              fromSyncerStats(result.Stats),
		Measurement:        fromSyncerMeasurement(result.Measurement),
		Protocol:           result.Protocol,
	}
	for _, plan := range result.Plans {
		out.Plans = append(out.Plans, RefPlan{
			Branch:     plan.Branch,
			SourceRef:  plan.SourceRef.String(),
			TargetRef:  plan.TargetRef.String(),
			SourceHash: hashString(plan.SourceHash),
			TargetHash: hashString(plan.TargetHash),
			Kind:       RefKind(plan.Kind),
			Action:     Action(plan.Action),
			Reason:     plan.Reason,
		})
	}
	return out
}

func fromSyncerStats(stats syncer.Stats) Stats {
	out := Stats{Enabled: stats.Enabled, Items: make(map[string]*ServiceStats, len(stats.Items))}
	for key, item := range stats.Items {
		copyItem := *item
		out.Items[key] = &ServiceStats{
			Name:          copyItem.Name,
			Requests:      copyItem.Requests,
			RequestBytes:  copyItem.RequestBytes,
			ResponseBytes: copyItem.ResponseBytes,
			Wants:         copyItem.Wants,
			Haves:         copyItem.Haves,
			Commands:      copyItem.Commands,
		}
	}
	return out
}

func fromSyncerMeasurement(m syncer.Measurement) Measurement {
	return Measurement{
		Enabled:            m.Enabled,
		ElapsedMillis:      m.ElapsedMillis,
		PeakAllocBytes:     m.PeakAllocBytes,
		PeakHeapInuseBytes: m.PeakHeapInuseBytes,
		TotalAllocBytes:    m.TotalAllocBytes,
		GCCount:            m.GCCount,
	}
}

func hashString(hash plumbing.Hash) string {
	if hash.IsZero() {
		return ""
	}
	return hash.String()
}
```

Apkg/gitsync/types.go+274
