Add initial public gitsync package · Entire

Add initial public gitsync package

3e236b4main·

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

Sessions

5fe00ff669ffView transcript

Changes

3


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