Merge pull request #35 from entireio/soph/move-to-cobra · Entire

Merge pull request #35 from entireio/soph/move-to-cobra

752b6e3→main·

Soph·2mo ago·10 files·+608 added/-589 removed

Move git-sync CLI to cobra for per-subcommand help

Changes

10

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80

package main

import (
    "errors"
    "fmt"

"entire.io/entire/git-sync"
    "entire.io/entire/git-sync/internal/validation"
    "entire.io/entire/git-sync/unstable"
    "github.com/spf13/cobra"
)

func newBootstrapCmd() *cobra.Command {
    var (
        mappings    []string
        jsonOutput  bool
        sourceAuth  gitsync.EndpointAuth
        targetAuth  gitsync.EndpointAuth
        branches    string
        protocolVal = newProtocolFlag()
        req         = unstable.BootstrapRequest{}
    )

cmd := &cobra.Command{
        Use:           "bootstrap [flags] <source-url> <target-url>",
        Short:         "Seed an empty target by streaming the source pack",
        Args:          cobra.MaximumNArgs(2),
        SilenceErrors: true,
        SilenceUsage:  true,
        RunE: func(cmd *cobra.Command, args []string) error {
            req.Protocol = gitsync.ProtocolMode(protocolVal)

if req.Source.URL == "" && len(args) > 0 {
                req.Source.URL = args[0]
            }
            if req.Target.URL == "" && len(args) > 1 {
                req.Target.URL = args[1]
            }

if branches != "" {
                req.Scope.Branches = splitCSV(branches)
            }
            for _, raw := range mappings {
                mapping, err := validation.ParseMapping(raw)
                if err != nil {
                    return fmt.Errorf("parse mapping %q: %w", raw, err)
                }
                req.Scope.Mappings = append(req.Scope.Mappings, gitsync.RefMapping{
                    Source: mapping.Source,
                    Target: mapping.Target,
                })
            }

if req.Source.URL == "" || req.Target.URL == "" {
                return errors.New("bootstrap requires source and target repository URLs")
            }

result, err := unstable.New(unstable.Options{
                Auth: gitsync.StaticAuthProvider{Source: sourceAuth, Target: targetAuth},
            }).Bootstrap(cmd.Context(), req)
            if err != nil {
                return fmt.Errorf("bootstrap: %w", err)
            }
            printOutput(jsonOutput, result)
            return nil
        },
    }

addSourceEndpoint(cmd, &req.Source)
    addTargetEndpoint(cmd, &req.Target)
    addSourceAuth(cmd, &sourceAuth)
    addTargetAuth(cmd, &targetAuth)

cmd.Flags().StringVar(&branches, "branch", "", "comma-separated branch list; default is all source branches")
    cmd.Flags().StringArrayVar(&mappings, "map", nil, "ref mapping in src:dst form; short names map branches, full refs map exact refs")
    cmd.Flags().BoolVar(&req.IncludeTags, "tags", false, "mirror tags")
    cmd.Flags().BoolVar(&req.Options.CollectStats, "stats", false, "print transfer statistics")
    cmd.Flags().BoolVar(&req.Options.MeasureMemory, "measure-memory", false, "sample elapsed time and Go heap usage")
    cmd.Flags().BoolVar(&jsonOutput, "json", false, "print JSON output")
    cmd.Flags().Int64Var(&req.Options.MaxPackBytes, "max-pack-bytes", 0, "abort bootstrap if the streamed source pack exceeds this many bytes")
    cmd.Flags().Int64Var(&req.Options.TargetMaxPackBytes, "target-max-pack-bytes", 0, "target receive-pack body size limit; batches are planned and auto-subdivided to fit")
    addProtocolFlag(cmd, &protocolVal)
    cmd.Flags().BoolVarP(&req.Options.Verbose, "verbose", "v", false, "verbose logging")

return cmd
}

``

Acmd/git-sync/bootstrap.go+86

``
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80

package main

import (
    "errors"
    "fmt"
    "strings"

"entire.io/entire/git-sync"
    "entire.io/entire/git-sync/internal/validation"
    "entire.io/entire/git-sync/unstable"
    "github.com/go-git/go-git/v6/plumbing"
    "github.com/spf13/cobra"
)

func newFetchCmd() *cobra.Command {
    var (
        haveRefs      []string
        haveHashesRaw []string
        jsonOutput    bool
        sourceAuth    gitsync.EndpointAuth
        branches      string
        protocolVal   = newProtocolFlag()
        req           = unstable.FetchRequest{}
    )

cmd := &cobra.Command{
        Use:           "fetch [flags] <source-url>",
        Short:         "Negotiate a fetch against the source and report packed objects",
        Args:          cobra.MaximumNArgs(1),
        SilenceErrors: true,
        SilenceUsage:  true,
        RunE: func(cmd *cobra.Command, args []string) error {
            req.Protocol = gitsync.ProtocolMode(protocolVal)

if req.Source.URL == "" && len(args) > 0 {
                req.Source.URL = args[0]
            }
            if req.Source.URL == "" {
                return errors.New("fetch requires a source repository URL")
            }
            if branches != "" {
                req.Scope.Branches = splitCSV(branches)
            }

haveHashes := make([]plumbing.Hash, 0, len(haveHashesRaw))
            for _, raw := range haveHashesRaw {
                hash := plumbing.NewHash(strings.TrimSpace(raw))
                if hash.IsZero() {
                    return fmt.Errorf("invalid --have %q", raw)
                }
                haveHashes = append(haveHashes, hash)
            }

req.HaveRefs = append(req.HaveRefs, haveRefs...)
            req.HaveHashes = append(req.HaveHashes, haveHashes...)

result, err := unstable.New(unstable.Options{
                Auth: gitsync.StaticAuthProvider{Source: sourceAuth},
            }).Fetch(cmd.Context(), req)
            if err != nil {
                return fmt.Errorf("fetch: %w", err)
            }
            printOutput(jsonOutput, result)
            return nil
        },
    }

addSourceEndpoint(cmd, &req.Source)
    addSourceAuth(cmd, &sourceAuth)

cmd.Flags().StringVar(&branches, "branch", "", "comma-separated branch list; default is all source branches")
    cmd.Flags().BoolVar(&req.IncludeTags, "tags", false, "include tags in the fetch request")
    addProtocolFlag(cmd, &protocolVal)
    cmd.Flags().BoolVar(&req.Options.CollectStats, "stats", false, "print transfer statistics")
    cmd.Flags().BoolVar(&req.Options.MeasureMemory, "measure-memory", false, "sample elapsed time and Go heap usage")
    cmd.Flags().BoolVar(&jsonOutput, "json", false, "print JSON output")
    cmd.Flags().StringArrayVar(&haveRefs, "have-ref", nil, "source ref name to advertise as have; short names map to branches")
    cmd.Flags().StringArrayVar(&haveHashesRaw, "have", nil, "explicit object hash to advertise as have")

return cmd
}

``

Acmd/git-sync/fetch.go+80

package main

import (
    "errors"
    "fmt"
    "os"
    "strings"

"entire.io/entire/git-sync"
    "entire.io/entire/git-sync/internal/validation"
    "entire.io/entire/git-sync/unstable"
    "github.com/spf13/cobra"
)

func addSourceEndpoint(cmd *cobra.Command, ep *gitsync.Endpoint) {
    cmd.Flags().StringVar(&ep.URL, "source-url", "", "source repository URL")
    cmd.Flags().BoolVar(&ep.FollowInfoRefsRedirect, "source-follow-info-refs-redirect",
        envBool("GITSYNC_SOURCE_FOLLOW_INFO_REFS_REDIRECT"),
        "send follow-up source RPCs to the final /info/refs redirect host")
}

func addTargetEndpoint(cmd *cobra.Command, ep *gitsync.Endpoint) {
    cmd.Flags().StringVar(&ep.URL, "target-url", "", "target repository URL")
    cmd.Flags().BoolVar(&ep.FollowInfoRefsRedirect, "target-follow-info-refs-redirect",
        envBool("GITSYNC_TARGET_FOLLOW_INFO_REFS_REDIRECT"),
        "send follow-up target RPCs to the final /info/refs redirect host")
}

func addSourceAuth(cmd *cobra.Command, auth *gitsync.EndpointAuth) {
    cmd.Flags().StringVar(&auth.Token, "source-token", envOr("GITSYNC_SOURCE_TOKEN", ""), "source token/password")
    cmd.Flags().StringVar(&auth.Username, "source-username", envOr("GITSYNC_SOURCE_USERNAME", "git"), "source basic auth username")
    cmd.Flags().StringVar(&auth.BearerToken, "source-bearer-token", envOr("GITSYNC_SOURCE_BEARER_TOKEN", ""), "source bearer token")
    cmd.Flags().BoolVar(&auth.SkipTLSVerify, "source-insecure-skip-tls-verify",
        envBool("GITSYNC_SOURCE_INSECURE_SKIP_TLS_VERIFY"),
        "skip TLS certificate verification for the source")
}

func addTargetAuth(cmd *cobra.Command, auth *gitsync.EndpointAuth) {
    cmd.Flags().StringVar(&auth.Token, "target-token", envOr("GITSYNC_TARGET_TOKEN", ""), "target token/password")
    cmd.Flags().StringVar(&auth.Username, "target-username", envOr("GITSYNC_TARGET_USERNAME", "git"), "target basic auth username")
    cmd.Flags().StringVar(&auth.BearerToken, "target-bearer-token", envOr("GITSYNC_TARGET_BEARER_TOKEN", ""), "target bearer token")
    cmd.Flags().BoolVar(&auth.SkipTLSVerify, "target-insecure-skip-tls-verify",
        envBool("GITSYNC_TARGET_INSECURE_SKIP_TLS_VERIFY"),
        "skip TLS certificate verification for the target")
}

func addProtocolFlag(cmd *cobra.Command, mode *protocolModeFlag) {
    cmd.Flags().Var(mode, "protocol", "protocol mode: auto, v1, or v2")
}

func newProtocolFlag() protocolModeFlag {
    return protocolModeFlag(protocolMode(envOr("GITSYNC_PROTOCOL", validation.ProtocolAuto)))
}

func envOr(key, fallback string) string {
    value := os.Getenv(key)
    if value == "" {
        return fallback
    }
    return value
}

func envBool(key string) bool {
    value := strings.TrimSpace(strings.ToLower(os.Getenv(key)))
    return value == "1" || value == "true" || value == "yes" || value == "on"
}

func splitCSV(value string) []string {
    parts := strings.Split(value, ",")
    out := make([]string, 0, len(parts))
    for _, part := range parts {
        part = strings.TrimSpace(part)
        if part != "" {
            out = append(out, part)
        }
    }
    return out
}

}

Acmd/git-sync/flags.go+117

``

1 unmodified line

2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80

package main

import (
    "context"
    "encoding/json"
    "errors"
    "flag"
    "fmt"
    "os"
    "strings"

"entire.io/entire/git-sync"
    "entire.io/entire/git-sync/cmd/git-sync/internal/versioninfo"
    "entire.io/entire/git-sync/internal/validation"
    "entire.io/entire/git-sync/unstable"
    "github.com/go-git/go-git/v6/plumbing"
    "github.com/spf13/cobra"
)

func main() {
    if err := run(context.Background(), os.Args[1:]); err != nil {
    err := run(context.Background(), os.Args[1:])
    if err == nil {
        return
    }
    if !errors.Is(err, errSilent) {
        fmt.Fprintf(os.Stderr, "error: %v\n", err)
        os.Exit(1)
    }
    os.Exit(1)
}

func run(ctx context.Context, args []string) error {
    if len(args) == 0 {
        return usageError("")
    }

switch args[0] {
    case "sync":
        return runSyncLike(ctx, "sync", args[1:], false, gitsync.ModeSync)
    case "replicate":
        return runSyncLike(ctx, "replicate", args[1:], false, gitsync.ModeReplicate)
    case "plan":
        return runSyncLike(ctx, "plan", args[1:], true, "")
    case "bootstrap":
        return runBootstrap(ctx, args[1:])
    case "probe":
        return runProbe(ctx, args[1:])
    case "fetch":
        return runFetch(ctx, args[1:])
    case "version", "--version":
        fmt.Printf("git-sync %s (commit %s, built %s)\n",
            versioninfo.Version, versioninfo.Commit, versioninfo.Date)
        rootCmd := newRootCmd()
        rootCmd.SetArgs(args)
        err := rootCmd.ExecuteContext(ctx)
        if err == nil {
            return nil
        case "help", "-h", "--help":
            return usageError("")
        default:
            return usageError(fmt.Sprintf("unknown command %q", args[0]))
    }
}

func runSyncLike(ctx context.Context, name string, args []string, dryRun bool, defaultMode gitsync.OperationMode) error {
    fs := flag.NewFlagSet(name, flag.ContinueOnError)
    fs.SetOutput(os.Stderr)

var mappings multiStringFlag
    var jsonOutput bool
    var sourceAuth gitsync.EndpointAuth
    var targetAuth gitsync.EndpointAuth
    req := unstable.SyncRequest{DryRun: dryRun}

fs.StringVar(&req.Source.URL, "source-url", "", "source repository URL")
    fs.StringVar(&req.Target.URL, "target-url", "", "target repository URL")
    fs.BoolVar(&req.Source.FollowInfoRefsRedirect, "source-follow-info-refs-redirect", envBool("GITSYNC_SOURCE_FOLLOW_INFO_REFS_REDIRECT"), "send follow-up source RPCs to the final /info/refs redirect host")
    fs.BoolVar(&req.Target.FollowInfoRefsRedirect, "target-follow-info-refs-redirect", envBool("GITSYNC_TARGET_FOLLOW_INFO_REFS_REDIRECT"), "send follow-up target RPCs to the final /info/refs redirect host")

fs.StringVar(&sourceAuth.Token, "source-token", envOr("GITSYNC_SOURCE_TOKEN", ""), "source token/password")
    fs.StringVar(&targetAuth.Token, "target-token", envOr("GITSYNC_TARGET_TOKEN", ""), "target token/password")
    fs.StringVar(&sourceAuth.Username, "source-username", envOr("GITSYNC_SOURCE_USERNAME", "git"), "source basic auth username")
    fs.StringVar(&targetAuth.Username, "target-username", envOr("GITSYNC_TARGET_USERNAME", "git"), "target basic auth username")
    fs.BoolVar(&sourceAuth.SkipTLSVerify, "source-insecure-skip-tls-verify", envBool("GITSYNC_SOURCE_INSECURE_SKIP_TLS_VERIFY"), "skip TLS certificate verification for the source")
    fs.BoolVar(&targetAuth.SkipTLSVerify, "target-insecure-skip-tls-verify", envBool("GITSYNC_TARGET_INSECURE_SKIP_TLS_VERIFY"), "skip TLS certificate verification for the target")

branches := fs.String("branch", "", "comma-separated branch list; default is all source branches")
    fs.Var(&mappings, "map", "ref mapping in src:dst form; short names map branches, full refs map exact refs")
    modeValue := operationModeFlag(defaultOperationMode(defaultMode))
    if name == "plan" {
        fs.Var(&modeValue, "mode", "operation mode: sync or replicate")
    }
    fs.BoolVar(&req.Policy.IncludeTags, "tags", false, "mirror tags")
    fs.BoolVar(&req.Policy.Force, "force", false, "allow non-fast-forward branch updates and retarget tags")
    fs.BoolVar(&req.Policy.Prune, "prune", false, "delete managed target refs that no longer exist on source")
    fs.BoolVar(&req.Options.CollectStats, "stats", false, "print transfer statistics")
    fs.BoolVar(&req.Options.MeasureMemory, "measure-memory", false, "sample elapsed time and Go heap usage")
    fs.BoolVar(&jsonOutput, "json", false, "print JSON output")
    fs.IntVar(&req.Options.MaterializedMaxObjects, "materialized-max-objects", unstable.DefaultMaterializedMaxObjects, "abort non-relay materialized syncs above this many objects")
    fs.Int64Var(&req.Options.MaxPackBytes, "max-pack-bytes", 0, "abort bootstrap-relay push if the streamed source pack exceeds this many bytes")
    fs.Int64Var(&req.Options.TargetMaxPackBytes, "target-max-pack-bytes", 0, "target receive-pack body size limit; batches are planned and auto-subdivided to fit")
    protocolValue := protocolModeFlag(protocolMode(envOr("GITSYNC_PROTOCOL", validation.ProtocolAuto)))
    fs.Var(&protocolValue, "protocol", "protocol mode: auto, v1, or v2")
    fs.BoolVar(&req.Options.Verbose, "verbose", false, "verbose logging")

if err := fs.Parse(args); err != nil {
        return fmt.Errorf("parse flags: %w", err)
    }
    req.Policy.Mode = gitsync.OperationMode(modeValue)
    req.Policy.Protocol = gitsync.ProtocolMode(protocolValue)

positional := fs.Args()
    if req.Source.URL == "" && len(positional) > 0 {
        req.Source.URL = positional[0]
    }
    if req.Target.URL == "" && len(positional) > 1 {
        req.Target.URL = positional[1]
    }
    if len(positional) > 2 {
        return usageError("too many positional arguments")
    }

if *branches != "" {
        req.Scope.Branches = splitCSV(*branches)
    }
    for _, raw := range mappings {
        mapping, err := validation.ParseMapping(raw)
        if err != nil {
            return fmt.Errorf("parse mapping %q: %w", raw, err)
        }
        req.Scope.Mappings = append(req.Scope.Mappings, gitsync.RefMapping{
            Source: mapping.Source,
            Target: mapping.Target,
        })
    }

if req.Source.URL == "" || req.Target.URL == "" {
        return usageError(name + " requires source and target repository URLs")
    }

client := unstable.New(unstable.Options{
        Auth: gitsync.StaticAuthProvider{Source: sourceAuth, Target: targetAuth},
    })
    var (
        result unstable.Result
        err    error
    )
    if dryRun {
        result, err = client.Plan(ctx, req)
    } else {
        if req.Policy.Mode == gitsync.ModeReplicate {
            result, err = client.Replicate(ctx, req)
        } else {
            result, err = client.Sync(ctx, req)
        }
    }
    if err != nil {
        return fmt.Errorf("sync: %w", err)
    }
    printOutput(jsonOutput, result)

if !dryRun && result.Blocked > 0 {
        return errors.New("one or more branches were skipped because the target was not fast-forwardable")
    }
    return nil
    //nolint:wrapcheck // cobra surfaces errors that are already user-facing (RunE-prefixed or cobra arg validation); main prints them with an "error:" prefix
    return err
}

func runBootstrap(ctx context.Context, args []string) error {
    fs := flag.NewFlagSet("bootstrap", flag.ContinueOnError)
    fs.SetOutput(os.Stderr)
    // errSilent signals that main should exit non-zero without re-printing the
    // error (it has already been printed alongside the usage block).
    var errSilent = errors.New("")

var mappings multiStringFlag
    var jsonOutput bool
    var sourceAuth gitsync.EndpointAuth
    var targetAuth gitsync.EndpointAuth
    req := unstable.BootstrapRequest{}

branches := fs.String("branch", "", "comma-separated branch list; default is all source branches")
    fs.Var(&mappings, "map", "ref mapping in src:dst form; short names map branches, full refs map exact refs")
    fs.BoolVar(&req.IncludeTags, "tags", false, "mirror tags")
    fs.BoolVar(&req.Options.CollectStats, "stats", false, "print transfer statistics")
    fs.BoolVar(&req.Options.MeasureMemory, "measure-memory", false, "sample elapsed time and Go heap usage")
    fs.BoolVar(&jsonOutput, "json", false, "print JSON output")
    fs.Int64Var(&req.Options.MaxPackBytes, "max-pack-bytes", 0, "abort bootstrap if the streamed source pack exceeds this many bytes")
    fs.Int64Var(&req.Options.TargetMaxPackBytes, "target-max-pack-bytes", 0, "target receive-pack body size limit; batches are planned and auto-subdivided to fit")
    bootstrapProtocol := protocolModeFlag(protocolMode(envOr("GITSYNC_PROTOCOL", validation.ProtocolAuto)))
    fs.Var(&bootstrapProtocol, "protocol", "protocol mode: auto, v1, or v2")
    fs.BoolVar(&req.Options.Verbose, "v", false, "verbose logging")

if err := fs.Parse(args); err != nil {
        return fmt.Errorf("parse flags: %w", err)
    }
    req.Protocol = gitsync.ProtocolMode(bootstrapProtocol)

if req.Source.URL == "" || req.Target.URL == "" {
        return usageError("bootstrap requires source and target repository URLs")
    }

result, err := unstable.New(unstable.Options{
        Auth: gitsync.StaticAuthProvider{Source: sourceAuth, Target: targetAuth},
    }).Bootstrap(ctx, req)
    if err != nil {
        return fmt.Errorf("bootstrap: %w", err)
    }
    printOutput(jsonOutput, result)
    return nil
}

func runProbe(ctx context.Context, args []string) error {
    fs := flag.NewFlagSet("probe", flag.ContinueOnError)
    fs.SetOutput(os.Stderr)

var jsonOutput bool
    var sourceAuth gitsync.EndpointAuth
    var targetAuth gitsync.EndpointAuth
    var targetFollowInfoRefsRedirect bool
    req := unstable.ProbeRequest{}
    fs.StringVar(&req.Source.URL, "source-url", "", "source repository URL")
    targetURL := fs.String("target-url", "", "optional target repository URL")
    fs.BoolVar(&req.Source.FollowInfoRefsRedirect, "source-follow-info-refs-redirect", envBool("GITSYNC_SOURCE_FOLLOW_INFO_REFS_REDIRECT"), "send follow-up source RPCs to the final /info/refs redirect host")
    fs.BoolVar(&targetFollowInfoRefsRedirect, "target-follow-info-refs-redirect", envBool("GITSYNC_TARGET_FOLLOW_INFO_REFS_REDIRECT"), "send follow-up target RPCs to the final /info/refs redirect host")
    fs.StringVar(&sourceAuth.Token, "source-token", envOr("GITSYNC_SOURCE_TOKEN", ""), "source token/password")
    fs.StringVar(&targetAuth.Token, "target-token", envOr("GITSYNC_TARGET_TOKEN", ""), "target token/password")
    fs.StringVar(&sourceAuth.Username, "source-username", envOr("GITSYNC_SOURCE_USERNAME", "git"), "source basic auth username")
    fs.StringVar(&targetAuth.Username, "target-username", envOr("GITSYNC_TARGET_USERNAME", "git"), "target basic auth username")
    fs.StringVar(&sourceAuth.BearerToken, "source-bearer-token", envOr("GITSYNC_SOURCE_BEARER_TOKEN", ""), "source bearer token")
    fs.StringVar(&targetAuth.BearerToken, "target-bearer-token", envOr("GITSYNC_TARGET_BEARER_TOKEN", ""), "target bearer token")
    fs.BoolVar(&sourceAuth.SkipTLSVerify, "source-insecure-skip-tls-verify", envBool("GITSYNC_SOURCE_INSECURE_SKIP_TLS_VERIFY"), "skip TLS certificate verification for the source")
    fs.BoolVar(&targetAuth.SkipTLSVerify, "target-insecure-skip-tls-verify", envBool("GITSYNC_TARGET_INSECURE_SKIP_TLS_VERIFY"), "skip TLS certificate verification for the target")
    fs.BoolVar(&req.IncludeTags, "tags", false, "include tag ref prefixes in probe")
    probeProtocol := protocolModeFlag(protocolMode(envOr("GITSYNC_PROTOCOL", validation.ProtocolAuto)))
    fs.Var(&probeProtocol, "protocol", "protocol mode: auto, v1, or v2")
    fs.BoolVar(&req.Options.CollectStats, "stats", false, "print transfer statistics")
    fs.BoolVar(&req.Options.MeasureMemory, "measure-memory", false, "sample elapsed time and Go heap usage")
    fs.BoolVar(&jsonOutput, "json", false, "print JSON output")

if err := fs.Parse(args); err != nil {
        return fmt.Errorf("parse flags: %w", err)
    }
    req.Protocol = gitsync.ProtocolMode(probeProtocol)

positional := fs.Args()
    if req.Source.URL == "" && len(positional) > 0 {
        req.Source.URL = positional[0]
    }
    if *targetURL == "" && len(positional) > 1 {
        *targetURL = positional[1]
    }
    if len(positional) > 2 {
        return usageError("too many positional arguments")
    }
    if req.Source.URL == "" {
        return usageError("probe requires a source repository URL")
    }
    if *targetURL != "" {
        req.Target = &gitsync.Endpoint{
            URL:                    *targetURL,
            FollowInfoRefsRedirect: targetFollowInfoRefsRedirect,
        }
    }

result, err := unstable.New(unstable.Options{
        Auth: gitsync.StaticAuthProvider{Source: sourceAuth, Target: targetAuth},
    }).Probe(ctx, req)
    if err != nil {
        return fmt.Errorf("probe: %w", err)
    }
    printOutput(jsonOutput, result)
    return nil
}

func runFetch(ctx context.Context, args []string) error {
    fs := flag.NewFlagSet("fetch", flag.ContinueOnError)
    fs.SetOutput(os.Stderr)

var haveRefs multiStringFlag
    var haveHashesRaw multiStringFlag
    var jsonOutput bool
    var sourceAuth gitsync.EndpointAuth
    req := unstable.FetchRequest{}

fs.StringVar(&req.Source.URL, "source-url", "", "source repository URL")
    fs.BoolVar(&req.Source.FollowInfoRefsRedirect, "source-follow-info-refs-redirect", envBool("GITSYNC_SOURCE_FOLLOW_INFO_REFS_REDIRECT"), "send follow-up source RPCs to the final /info/refs redirect host")
    fs.StringVar(&sourceAuth.Token, "source-token", envOr("GITSYNC_SOURCE_TOKEN", ""), "source token/password")
    fs.StringVar(&sourceAuth.Username, "source-username", envOr("GITSYNC_SOURCE_USERNAME", "git"), "source basic auth username")
    fs.StringVar(&sourceAuth.BearerToken, "source-bearer-token", envOr("GITSYNC_SOURCE_BEARER_TOKEN", ""), "source bearer token")
    fs.BoolVar(&sourceAuth.SkipTLSVerify, "source-insecure-skip-tls-verify", envBool("GITSYNC_SOURCE_INSECURE_SKIP_TLS_VERIFY"), "skip TLS certificate verification for the source")
    branches := fs.String("branch", "", "comma-separated branch list; default is all source branches")
    fs.BoolVar(&req.IncludeTags, "tags", false, "include tags in the fetch request")
    fetchProtocol := protocolModeFlag(protocolMode(envOr("GITSYNC_PROTOCOL", validation.ProtocolAuto)))
    fs.Var(&fetchProtocol, "protocol", "protocol mode: auto, v1, or v2")
    fs.BoolVar(&req.Options.CollectStats, "stats", false, "print transfer statistics")
    fs.BoolVar(&req.Options.MeasureMemory, "measure-memory", false, "sample elapsed time and Go heap usage")
    fs.BoolVar(&jsonOutput, "json", false, "print JSON output")
    fs.Var(&haveRefs, "have-ref", nil, "source ref name to advertise as have; short names map to branches")
    fs.Var(&haveHashesRaw, "have", nil, "explicit object hash to advertise as have")

if err := fs.Parse(args); err != nil {
        return fmt.Errorf("parse flags: %w", err)
    }
    req.Protocol = gitsync.ProtocolMode(fetchProtocol)

positional := fs.Args()
    if req.Source.URL == "" && len(positional) > 0 {
        req.Source.URL = positional[0]
    }
    if len(positional) > 1 {
        return usageError("too many positional arguments")
    }
    if req.Source.URL == "" {
        return usageError("fetch requires a source repository URL")
    }
    if *branches != "" {
        req.Scope.Branches = splitCSV(*branches)
    }

req.HaveRefs = append(req.HaveRefs, haveRefs...)
    req.HaveHashes = append(req.HaveHashes, haveHashes...)
    result, err := unstable.New(unstable.Options{
        Auth: gitsync.StaticAuthProvider{Source: sourceAuth},
    }).Fetch(ctx, req)
    if err != nil {
        return fmt.Errorf("fetch: %w", err)
    }
    printOutput(jsonOutput, result)
    return nil
}

func printOutput(jsonOutput bool, value interface{ Lines() []string }) {
    if jsonOutput {
        data, err := marshalOutput(value)
        if err != nil {
            fmt.Fprintf(os.Stderr, "error: encode JSON output: %v\n", err)
            os.Exit(1)
        }
        fmt.Println(string(data))
        return
    }

for _, line := range value.Lines() {
        fmt.Println(line)
    }
}

func marshalOutput(value interface{}) ([]byte, error) {
    data, err := json.MarshalIndent(value, "", "  ")
    if err != nil {
        return nil, fmt.Errorf("marshal JSON: %w", err)
    }
    return data, nil
}

type multiStringFlag []string

func (m *multiStringFlag) String() string {
    return strings.Join(*m, ",")
}

func (m *multiStringFlag) Set(value string) error {
    *m = append(*m, value)
    return nil
}

func envOr(key, fallback string) string {
    value := os.Getenv(key)
    if value == "" {
        return fallback
    }
    return value
}

func usageError(message string) error {
    usage := fmt.Sprintf(`usage:
  %%[1]s sync [flags] <source-url> <target-url>
  %%[1]s replicate [flags] <source-url> <target-url>
  %%[1]s plan [flags] <source-url> <target-url>
  %%[1]s bootstrap [flags] <source-url> <target-url>
  %%[1]s probe [flags] <source-url> [target-url]
  %%[1]s fetch [flags] <source-url>
  %%[1]s version

sync flags:
  --branch main,dev
  --map main:stable
  --tags
  --force
  --prune
  --stats
  --measure-memory
  --json
  --materialized-max-objects %%[2]d
  --max-pack-bytes <bytes>
  --target-max-pack-bytes <bytes>
  --protocol auto|v1|v2
  --source-token ...
  --target-token ...
  --source-username git
  --target-username git
  --source-bearer-token ...
  --target-bearer-token ...
  --source-insecure-skip-tls-verify
  --target-insecure-skip-tls-verify
  --source-follow-info-refs-redirect
  --target-follow-info-refs-redirect
  -v

`, os.Args[0], unstable.DefaultMaterializedMaxObjects)
    if message == "" {
        return errors.New(strings.TrimSpace(usage))
    }
    return fmt.Errorf("%s\n\n%s", message, usage)
}

func showUsage(cmd *cobra.Command, err error) {
    fmt.Fprint(cmd.OutOrStderr(), cmd.UsageString())
    fmt.Fprintf(cmd.OutOrStderr(), "\nError: %v\n", err)
}