Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 13 additions & 6 deletions client.go
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,9 @@ func (r SyncRequest) Validate() error {
if err := validateOperationMode(r.Policy.Mode); err != nil {
return err
}
if err := r.Policy.Validate(); err != nil {
return err
}
if _, err := validation.NormalizeProtocolMode(string(r.Policy.Protocol)); err != nil {
return fmt.Errorf("normalize protocol: %w", err)
}
Expand All @@ -172,6 +175,9 @@ func (r PlanRequest) Validate() error {
if err := validateOperationMode(r.Policy.Mode); err != nil {
return err
}
if err := r.Policy.Validate(); err != nil {
return err
}
if _, err := validation.NormalizeProtocolMode(string(r.Policy.Protocol)); err != nil {
return fmt.Errorf("normalize protocol: %w", err)
}
Expand Down Expand Up @@ -228,12 +234,13 @@ func bridgeScope(scope RefScope) internalbridge.RefScope {

func bridgePolicy(policy SyncPolicy) internalbridge.SyncPolicy {
return internalbridge.SyncPolicy{
Mode: internalbridge.OperationMode(policy.Mode),
IncludeTags: policy.IncludeTags,
Force: policy.Force,
Prune: policy.Prune,
BestEffort: policy.BestEffort,
Protocol: internalbridge.ProtocolMode(policy.Protocol),
Mode: internalbridge.OperationMode(policy.Mode),
IncludeTags: policy.IncludeTags,
ForceWithLease: policy.ForceWithLease,
ForceBlind: policy.ForceBlind,
Prune: policy.Prune,
BestEffort: policy.BestEffort,
Protocol: internalbridge.ProtocolMode(policy.Protocol),
}
}

Expand Down
14 changes: 14 additions & 0 deletions client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,20 @@ func TestValidateRequests(t *testing.T) {
}).Validate(); err == nil {
t.Fatalf("expected duplicate mapping validation error")
}
if err := (SyncRequest{
Source: Endpoint{URL: "https://source.example/repo.git"},
Target: Endpoint{URL: "https://target.example/repo.git"},
Policy: SyncPolicy{ForceWithLease: true, ForceBlind: true},
}).Validate(); err == nil {
t.Fatalf("expected force-with-lease + force-blind to be rejected at the request edge")
}
if err := (SyncRequest{
Source: Endpoint{URL: "https://source.example/repo.git"},
Target: Endpoint{URL: "https://target.example/repo.git"},
Policy: SyncPolicy{Mode: ModeReplicate, ForceWithLease: true},
}).Validate(); err == nil {
t.Fatalf("expected replicate + force to be rejected at the request edge")
}
}

func TestClientReturnsAuthProviderErrors(t *testing.T) {
Expand Down
17 changes: 13 additions & 4 deletions cmd/git-sync-bench/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,10 @@ func run(ctx context.Context, args []string) error {
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(&cfg.Policy.IncludeTags, "tags", false, "mirror tags")
fs.BoolVar(&cfg.Policy.Force, "force", false, "allow non-fast-forward branch updates and retarget tags")
fs.BoolVar(&cfg.Policy.ForceWithLease, "force-with-lease", false, "allow non-fast-forward branch updates with per-run lease")
fs.BoolVar(&cfg.Policy.ForceBlind, "force-blind", false, "allow non-fast-forward branch updates; overwrite regardless of current target tip")
var legacyForce bool
fs.BoolVar(&legacyForce, "force", false, "removed: pick --force-with-lease or --force-blind")
fs.BoolVar(&cfg.Policy.Prune, "prune", false, "delete managed target refs that no longer exist on source")
fs.BoolVar(&cfg.Options.CollectStats, "stats", false, "collect transfer statistics")
fs.BoolVar(&cfg.Options.MeasureMemory, "measure-memory", true, "sample elapsed time and Go heap usage")
Expand All @@ -119,6 +122,12 @@ func run(ctx context.Context, args []string) error {
if err := fs.Parse(args); err != nil {
return fmt.Errorf("parse flags: %w", err)
}
if legacyForce {
return usageError("--force has been removed; use --force-with-lease or --force-blind")
}
if cfg.Policy.ForceWithLease && cfg.Policy.ForceBlind {
return usageError("--force-with-lease and --force-blind are mutually exclusive")
}
cfg.Policy.Protocol = gitsync.ProtocolMode(benchProtocol)
if len(fs.Args()) > 0 {
return usageError("unexpected positional arguments")
Expand Down Expand Up @@ -152,8 +161,8 @@ func run(ctx context.Context, args []string) error {
return err
}
if sc == scenarioBootstrap {
if cfg.Policy.Force || cfg.Policy.Prune {
return usageError("bootstrap benchmarks do not support --force or --prune")
if cfg.Policy.ForceWithLease || cfg.Policy.ForceBlind || cfg.Policy.Prune {
return usageError("bootstrap benchmarks do not support force flags or --prune")
}
}

Expand Down Expand Up @@ -450,7 +459,7 @@ func splitCSV(value string) []string {
}

func usageError(message string) error {
usage := fmt.Sprintf("usage:\n %s --source-url <repo> [flags]\n\nflags:\n --scenario bootstrap|sync\n --repeat 3\n --work-dir /tmp/git-sync-bench\n --keep-targets\n --json\n --branch main,release\n --map main:stable\n --tags\n --force\n --prune\n --stats\n --measure-memory\n --max-pack-bytes 104857600\n --target-max-pack-bytes 104857600\n --protocol auto|v1|v2\n -v\n", os.Args[0])
usage := fmt.Sprintf("usage:\n %s --source-url <repo> [flags]\n\nflags:\n --scenario bootstrap|sync\n --repeat 3\n --work-dir /tmp/git-sync-bench\n --keep-targets\n --json\n --branch main,release\n --map main:stable\n --tags\n --force-with-lease\n --force-blind\n --prune\n --stats\n --measure-memory\n --max-pack-bytes 104857600\n --target-max-pack-bytes 104857600\n --protocol auto|v1|v2\n -v\n", os.Args[0])
if message == "" {
return errors.New(strings.TrimSpace(usage))
}
Expand Down
35 changes: 33 additions & 2 deletions cmd/git-sync/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -557,14 +557,45 @@ func TestRun_Sync_AllRefsWarnsOnNg(t *testing.T) {
func TestRun_Replicate_SubcommandRejectsForce(t *testing.T) {
err := run(context.Background(), []string{
modeReplicate,
"--force-with-lease",
"http://127.0.0.1:1/source.git",
"http://127.0.0.1:1/target.git",
})
if err == nil {
t.Fatal("expected replicate --force-with-lease to be rejected")
}
if !strings.Contains(err.Error(), "replicate does not support force flags") {
t.Fatalf("unexpected error: %v", err)
}
}

func TestRun_LegacyForceFlagRejected(t *testing.T) {
err := run(context.Background(), []string{
"sync",
"--force",
"http://127.0.0.1:1/source.git",
"http://127.0.0.1:1/target.git",
})
if err == nil {
t.Fatal("expected replicate --force to be rejected")
t.Fatal("expected --force to be rejected")
}
if !strings.Contains(err.Error(), "--force has been removed") {
t.Fatalf("unexpected error: %v", err)
}
}

func TestRun_ForceWithLeaseAndBlindAreMutuallyExclusive(t *testing.T) {
err := run(context.Background(), []string{
"sync",
"--force-with-lease",
"--force-blind",
"http://127.0.0.1:1/source.git",
"http://127.0.0.1:1/target.git",
})
if err == nil {
t.Fatal("expected --force-with-lease and --force-blind together to be rejected")
}
if !strings.Contains(err.Error(), "replicate does not support --force") {
if !strings.Contains(err.Error(), "force-with-lease") || !strings.Contains(err.Error(), "force-blind") {
t.Fatalf("unexpected error: %v", err)
}
}
Expand Down
12 changes: 11 additions & 1 deletion cmd/git-sync/syncplan.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ func newSyncLikeCmd(name, short string, dryRun bool, defaultMode gitsync.Operati
branches string
modeValue = operationModeFlag(defaultOperationMode(defaultMode))
protocolVal = newProtocolFlag()
legacyForce bool
req = unstable.SyncRequest{DryRun: dryRun}
)

Expand All @@ -41,6 +42,9 @@ func newSyncLikeCmd(name, short string, dryRun bool, defaultMode gitsync.Operati
SilenceErrors: true,
SilenceUsage: true,
RunE: func(cmd *cobra.Command, args []string) error {
if legacyForce {
return errors.New("--force has been removed; use --force-with-lease (previous semantics) or --force-blind (real overwrite)")
}
req.Policy.Mode = gitsync.OperationMode(modeValue)
req.Policy.Protocol = gitsync.ProtocolMode(protocolVal)

Expand Down Expand Up @@ -109,7 +113,13 @@ func newSyncLikeCmd(name, short string, dryRun bool, defaultMode gitsync.Operati
cmd.Flags().Var(&modeValue, "mode", "operation mode: sync or replicate")
}
cmd.Flags().BoolVar(&req.Policy.IncludeTags, "tags", false, "mirror tags")
cmd.Flags().BoolVar(&req.Policy.Force, "force", false, "allow non-fast-forward branch updates and retarget tags")
cmd.Flags().BoolVar(&req.Policy.ForceWithLease, "force-with-lease", false, "allow non-fast-forward branch updates and retarget tags; receive-pack rejects updates where the target moved during the run (lease captured at session start)")
cmd.Flags().BoolVar(&req.Policy.ForceBlind, "force-blind", false, "allow non-fast-forward branch updates and retarget tags; overwrite regardless of current target tip (matches `git push --force`)")
cmd.Flags().BoolVar(&legacyForce, "force", false, "removed: pick --force-with-lease (previous semantics) or --force-blind (real overwrite)")
if err := cmd.Flags().MarkHidden("force"); err != nil {
panic(err)
}
cmd.MarkFlagsMutuallyExclusive("force-with-lease", "force-blind")
cmd.Flags().BoolVar(&req.Policy.Prune, "prune", false, "delete managed target refs that no longer exist on source")
// Tag inclusion is now handled at the library level (AllRefs implies
// it in BuildDesiredRefs). Replicate keeps strict failure semantics —
Expand Down
42 changes: 38 additions & 4 deletions docs/usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -205,9 +205,9 @@ best-effort completeness against hostile targets.

`sync --all-refs` blocks updates to non-branch refs (notes, pulls, custom
namespaces) by default — those refs don't generally form fast-forward
chains, so the same `--force` opt-in that retargets tags is required to
update them. `replicate` doesn't run that check; its overwrite contract
covers other-kind refs without `--force`.
chains, so the same `--force-with-lease` opt-in that retargets tags is
required to update them. `replicate` doesn't run that check; its overwrite
contract covers other-kind refs without a force flag.

`SyncPolicy.BestEffort` is independent of scope and can be set without
`AllRefs` if a library caller wants per-ref warn semantics on a narrower
Expand All @@ -222,6 +222,40 @@ git-sync sync \
<target-url>
```

## Force Updates and the Per-Run Lease

Non-fast-forward updates and tag retargets are opt-in. git-sync exposes two
flags that mirror `git push`'s force semantics:

- **`--force-with-lease`** — allow non-fast-forward updates, but include the
target tip captured at session start as the push command's expected-old
value. If another writer moves the target between session start and the
push, receive-pack rejects the update with a "remote ref does not match
expected old value" error and the sync fails without clobbering the racing
write. The lease window is one sync run; git-sync keeps no state between
runs.
- **`--force-blind`** — allow non-fast-forward updates and zero out the
expected-old, telling receive-pack to overwrite regardless of current
target value. Matches `git push --force` semantics. Use this when the
target was edited out-of-band and you intend to overwrite whatever is
there.

The two flags are mutually exclusive. Without either, divergent or
non-ancestor refs are reported as blocked and the sync exits non-zero
before any push, so the lease check is a second line of defense against
races for users who opt into non-fast-forward updates.

`bootstrap` and `replicate` do not accept force flags. Bootstrap seeds an
empty target where every ref is a create. Replicate's contract is
source-authoritative overwrite: divergent branches and tags are retargeted
against the source unconditionally, so there is no fast-forward gate for a
force flag to opt out of.

The pre-0.5 `--force` flag is removed. Its semantics were lease-protected
(it never sent a zero expected-old), so the closest direct replacement is
`--force-with-lease`. `--force-blind` is new behavior with no pre-0.5
analog.

## HEAD / Default Branch

git-sync surfaces the source's symref HEAD target — the source's default
Expand Down Expand Up @@ -288,7 +322,7 @@ That means local testing against a dummy GitHub repo can reuse your regular Git

- Source-side discovery and fetch can use protocol v2 when supported. Push stays on the existing v1 `receive-pack` path. `--protocol auto` tries v2 first and falls back to v1. `--protocol v2` requires the source to negotiate v2.
- Source fetch advertises current target tip hashes as `have`, so reruns download less when source and target already share history.
- Branches are updated only when the target tip is an ancestor of the source tip, unless `--force` is set. Tags are immutable by default. Retargeting an existing tag requires `--force`. With `--prune`, managed target refs that are absent on source are deleted.
- Branches are updated only when the target tip is an ancestor of the source tip, unless `--force-with-lease` or `--force-blind` is set. Tags are immutable by default; retargeting an existing tag requires one of the force flags. With `--prune`, managed target refs that are absent on source are deleted.
- If `sync` finds blocked refs, it exits non-zero before pushing anything.
- `--stats` adds per-service request, byte, want, have, and command counters to the output.

Expand Down
6 changes: 5 additions & 1 deletion internal/convert/convert.go
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,9 @@ func PlansToPushPlans(plans []planner.BranchPlan) []gitproto.PushPlan {
}

// PlansToPushCommands converts planner BranchPlans directly to gitproto PushCommands.
func PlansToPushCommands(plans []planner.BranchPlan) []gitproto.PushCommand {
// When forceBlind is true, non-delete commands send a zero expected-old so
// receive-pack overwrites regardless of current target value; see SyncPolicy.
func PlansToPushCommands(plans []planner.BranchPlan, forceBlind bool) []gitproto.PushCommand {
out := make([]gitproto.PushCommand, len(plans))
for i, p := range plans {
out[i] = gitproto.PushCommand{
Expand All @@ -71,6 +73,8 @@ func PlansToPushCommands(plans []planner.BranchPlan) []gitproto.PushCommand {
}
if out[i].Delete {
out[i].New = plumbing.ZeroHash
} else if forceBlind {
out[i].Old = plumbing.ZeroHash
}
}
return out
Expand Down
10 changes: 9 additions & 1 deletion internal/convert/convert_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ func TestPlansToPushCommands(t *testing.T) {
{TargetRef: plumbing.NewBranchReferenceName("old"), TargetHash: oldHash, Action: planner.ActionDelete},
}

got := PlansToPushCommands(plans)
got := PlansToPushCommands(plans, false)
want := []gitproto.PushCommand{
{Name: ref, Old: oldHash, New: newHash},
{Name: plumbing.NewBranchReferenceName("old"), Old: oldHash, Delete: true},
Expand All @@ -67,4 +67,12 @@ func TestPlansToPushCommands(t *testing.T) {
t.Fatalf("got[%d] = %+v, want %+v", i, got[i], want[i])
}
}

gotBlind := PlansToPushCommands(plans, true)
if !gotBlind[0].Old.IsZero() {
t.Fatalf("force-blind update should zero Old, got %v", gotBlind[0].Old)
}
if gotBlind[1].Old != oldHash {
t.Fatalf("force-blind delete should keep target hash, got %v want %v", gotBlind[1].Old, oldHash)
}
}
41 changes: 40 additions & 1 deletion internal/gitproto/push.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"fmt"
"io"
"os"
"strings"

"github.com/go-git/go-git/v6/plumbing"
"github.com/go-git/go-git/v6/plumbing/format/packfile"
Expand Down Expand Up @@ -104,6 +105,44 @@ func buildUpdateRequest(
return req, hasDelete, hasUpdates, nil
}

// leaseFailureMarkers are receive-pack ng reason substrings that indicate the
// captured target tip didn't match what was on the server at push time. Match
// is case-insensitive. CommandStatusErr.Status is a free-form string in go-git,
// so substring matching is the only option absent upstream sentinels.
var leaseFailureMarkers = []string{
"stale info",
"fetch first",
"non-fast-forward",
"does not match",
}

// IsLeaseFailure reports whether a receive-pack ng reason indicates the
// captured target tip no longer matched at push time. Callers that downgrade
// per-ref rejections to warnings (BestEffort) must still treat these as fatal
// to preserve --force-with-lease semantics.
func IsLeaseFailure(status string) bool {
lowered := strings.ToLower(status)
for _, marker := range leaseFailureMarkers {
if strings.Contains(lowered, marker) {
return true
}
}
return false
}

// annotateLeaseFailure wraps a lease-failure CommandStatusErr with a retry/
// override hint. Other receive-pack errors pass through unchanged.
func annotateLeaseFailure(err error) error {
var cs *packp.CommandStatusErr
if !errors.As(err, &cs) {
return err
}
if !IsLeaseFailure(cs.Status) {
return err
}
return fmt.Errorf("%w (target ref %s moved or differs from session start; rerun, or use --force-blind to overwrite)", err, cs.ReferenceName)
}

// sendReceivePack encodes and POSTs a receive-pack request, then decodes the report.
func sendReceivePack(
ctx context.Context,
Expand Down Expand Up @@ -148,7 +187,7 @@ func sendReceivePack(
}
if onRejection == nil {
if err := report.Error(); err != nil {
return fmt.Errorf("report-status: %w", err)
return fmt.Errorf("report-status: %w", annotateLeaseFailure(err))
}
return nil
}
Expand Down
Loading
Loading