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

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

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

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

## Changes

10

- cmd/git-sync

- Abootstrap.go+86

- Afetch.go+80

- Aflags.go+117

- Mmain.go+30/-589

- Aprobe.go+74

- Aroot.go+66

- Asyncplan.go+124

- Aversion.go+19

- Mgo.mod+3

- Mgo.sum+9

```
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)
}

```
