# Cover sync-mode prune scope end-to-end

`8529ca7`→[main](/content/gh/entireio/git-sync/commits/main/index.html)·  pjbgf·2mo ago·1 file·+185 added/-0 removed

Three integration tests close the gap left by existing prune coverage:

- sync --prune deletes an orphan target branch and preserves the in-scope branch (no replicate-mode equivalent existed for sync).
- sync --prune leaves a target-only tag alone when --tags is absent.
- sync --prune --tags preserves a tag created on the target after ref discovery completes; the planner's snapshot never saw it, so no command targets it. Uses receivePackHook on the test server to inject the new tag inside the race window.

Assisted-by: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: Paulo Gomes <paulo@entire.io>

## Sessions

b27245bdaaceView transcript

## Changes

1
- internal/syncer
  
  - Mintegration_test.go+185

```
1721 unmodified lines
```

// TestRun_IntegrationSyncPruneDeletesOrphanedBranch fills the gap between the
// existing tag-prune coverage (TestRun_IntegrationTagsPruneAndForce) and the
// replicate-mode branch-prune coverage (TestRun_IntegrationReplicatePruneDeletesOrphanedManagedRef):
// neither exercises sync-mode branch prune end-to-end. Without a filter,
// --prune must delete orphan target branches while leaving in-scope refs alone.
func TestRun_IntegrationSyncPruneDeletesOrphanedBranch(t *testing.T) {
    sourceRepo, sourceFS := newSourceRepo(t)
    makeCommits(t, sourceRepo, sourceFS, 2)
    targetRepo, _ := newSourceRepo(t)

sourceServer := newSmartHTTPRepoServer(t, sourceRepo)
    targetServer := newSmartHTTPRepoServer(t, targetRepo)
    targetServer.receivePackThinCap = true
    defer sourceServer.Close()
    defer targetServer.Close()

if _, err := Run(context.Background(), Config{
        Source: Endpoint{URL: sourceServer.RepoURL()},
        Target: Endpoint{URL: targetServer.RepoURL()},
    }); err != nil {
        t.Fatalf("seed sync: %v", err)
    }

targetHead, err := targetRepo.Reference(plumbing.NewBranchReferenceName(testBranch), true)
    if err != nil {
        t.Fatalf("target head after seed: %v", err)
    }
    orphanRef := plumbing.NewBranchReferenceName("release")
    if err := targetRepo.Storer.SetReference(plumbing.NewHashReference(orphanRef, targetHead.Hash())); err != nil {
        t.Fatalf("seed orphan branch: %v", err)
    }

result, err := Run(context.Background(), Config{
        Source: Endpoint{URL: sourceServer.RepoURL()},
        Target: Endpoint{URL: targetServer.RepoURL()},
        Prune:  true,
    })
    if err != nil {
        t.Fatalf("sync --prune: %v", err)
    }
    if result.Deleted != 1 {
        t.Fatalf("expected exactly one deleted ref, got %+v", result)
    }

if _, err := targetRepo.Reference(orphanRef, true); !errors.Is(err, plumbing.ErrReferenceNotFound) {
        t.Fatalf("expected orphan branch pruned, got err=%v", err)
    }
    assertHeadsMatch(t, sourceRepo, targetRepo, testBranch)
}

// TestRun_IntegrationSyncPrunePreservesTargetTagWithoutIncludeTags pins the
// scope of --prune in sync mode: tags are only in scope when the user also
// passes --tags (IncludeTags). A tag that the target gained on its own —
// e.g. a release marker pushed after a previous sync — must survive a
// branch-only --prune run.
func TestRun_IntegrationSyncPrunePreservesTargetTagWithoutIncludeTags(t *testing.T) {
    sourceRepo, sourceFS := newSourceRepo(t)
    makeCommits(t, sourceRepo, sourceFS, 2)
    targetRepo, _ := newSourceRepo(t)

targetHead, err := targetRepo.Reference(plumbing.NewBranchReferenceName(testBranch), true)
    if err != nil {
        t.Fatalf("target head after seed: %v", err)
    }
    targetTag := plumbing.NewTagReferenceName("deployed")
    if err := targetRepo.Storer.SetReference(plumbing.NewHashReference(targetTag, targetHead.Hash())); err != nil {
        t.Fatalf("seed target-only tag: %v", err)
    }

result, err := Run(context.Background(), Config{
        Source: Endpoint{URL: sourceServer.RepoURL()},
        Target: Endpoint{URL: targetServer.RepoURL()},
        Prune:  true,
    })
    if err != nil {
        t.Fatalf("sync --prune: %v", err)
    }
    if result.Deleted != 0 {
        t.Fatalf("expected no deletes when --tags is unset, got %+v", result)
    }

tagAfter, err := targetRepo.Reference(targetTag, true)
    if err != nil {
        t.Fatalf("target-only tag missing after sync --prune: %v", err)
    }
    if tagAfter.Hash() != targetHead.Hash() {
        t.Fatalf("target-only tag hash changed: pre=%s post=%s", targetHead.Hash(), tagAfter.Hash())
    }
}

// TestRun_IntegrationSyncPruneTagsPreservesTagCreatedDuringSync simulates a
// race: the target gains a brand-new tag after git-sync has snapshotted its
// refs but before the receive-pack push lands. Even with --tags --prune, the
// new tag must survive — the prune set is built from the planning snapshot
// only, and the receive-pack protocol only acts on refs that appear in the
// command list. The injection point is the receive-pack hook on the test
// server: by the time it fires, target ref discovery is already complete and
// git-sync's plans are fixed.
func TestRun_IntegrationSyncPruneTagsPreservesTagCreatedDuringSync(t *testing.T) {
    sourceRepo, sourceFS := newSourceRepo(t)
    makeCommits(t, sourceRepo, sourceFS, 2)

sourceHead, err := sourceRepo.Reference(plumbing.NewBranchReferenceName(testBranch), true)
    if err != nil {
        t.Fatalf("source head: %v", err)
    }
    staleTag := plumbing.NewTagReferenceName("stale")
    if err := sourceRepo.Storer.SetReference(plumbing.NewHashReference(staleTag, sourceHead.Hash())); err != nil {
        t.Fatalf("set source stale tag: %v", err)
    }

targetRepo, _ := newSourceRepo(t)

if _, err := Run(context.Background(), Config{
        Source:      Endpoint{URL: sourceServer.RepoURL()},
        Target:      Endpoint{URL: targetServer.RepoURL()},
        IncludeTags: true,
    }); err != nil {
        t.Fatalf("seed sync: %v", err)
    }

// Drop the tag from source so the next sync produces a delete plan for it.
    if err := sourceRepo.Storer.RemoveReference(staleTag); err != nil {
        t.Fatalf("remove source stale tag: %v", err)
    }

raceTag := plumbing.NewTagReferenceName("deployed")
    var once sync.Once
    targetServer.receivePackHook = func(_ *packp.UpdateRequests, _ bool) *packp.ReportStatus {
        // Fires after target ref discovery and after the planner has fixed
        // the delete set, but before the test server applies the commands.
        // This is the race window: the new tag is invisible to the snapshot
        // the planner already used, so it must not appear in any command.
        once.Do(func() {
            if err := targetRepo.Storer.SetReference(plumbing.NewHashReference(raceTag, sourceHead.Hash())); err != nil {
                t.Fatalf("inject race tag: %v", err)
            }
        })
        return nil
    }

result, err := Run(context.Background(), Config{
        Source:      Endpoint{URL: sourceServer.RepoURL()},
        Target:      Endpoint{URL: targetServer.RepoURL()},
        IncludeTags: true,
        Prune:       true,
    })
    if err != nil {
        t.Fatalf("sync --prune --tags: %v", err)
    }
    if result.Deleted != 1 {
        t.Fatalf("expected exactly one deleted ref (stale), got %+v", result)
    }

if _, err := targetRepo.Reference(staleTag, true); !errors.Is(err, plumbing.ErrReferenceNotFound) {
        t.Fatalf("expected stale tag pruned, got err=%v", err)
    }
    tagAfter, err := targetRepo.Reference(raceTag, true)
    if err != nil {
        t.Fatalf("race-created tag missing after sync: %v", err)
    }
    if tagAfter.Hash() != sourceHead.Hash() {
        t.Fatalf("race-created tag hash changed: pre=%s post=%s", sourceHead.Hash(), tagAfter.Hash())
    }
}

func TestRun_IntegrationReplicateAgainstNoThinTarget(t *testing.T) {
    // Replicate must tolerate targets that advertise no-thin. Source upload-pack
    // never receives a thin-pack request from us (see gitproto/fetch.go), so
