# Test --prune respects --branch and --map filters

`36012b2`→[main](/content/gh/entireio/git-sync/commits/main/index.html)·  
  
pjbgf·2mo ago·2 files·+326 added/-0 removed

Two integration cases prove --prune leaves orphan target branches alone  
once the user has narrowed scope with --branch main or --map main:stable.  
A package-level TestMain registers the empty go-git config loader so host  
gpgSign settings can't break test commits.

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

## Sessions

6d070296380bView transcript

## Changes

2

- internal
  
  - planner
  
    - Mplanner_test.go+159

- syncer
  
    - Mintegration_test.go+167

```
425 unmodified lines
```

// TestBuildPlansPrunePreservesUnrelatedBranchesUnderFilter is a regression
// guard for the prune-scoping rule in planner.go: --prune deletes orphan
// target branches only when the user has not narrowed the source ref set
// with --branch or --map. With either filter present, branches that exist
// only on the target are out of scope and must be preserved.
func TestBuildPlansPrunePreservesUnrelatedBranchesUnderFilter(t *testing.T) {
	t.Parallel()

mainHash := plumbing.NewHash("1111111111111111111111111111111111111111")
	releaseHash := plumbing.NewHash("2222222222222222222222222222222222222222")

mainRef := plumbing.NewBranchReferenceName("main")
	stableRef := plumbing.NewBranchReferenceName("stable")
	releaseRef := plumbing.NewBranchReferenceName("release")

sourceRefs := map[plumbing.ReferenceName]plumbing.Hash{
		mainRef: mainHash,
	}

tests := []struct {
		name         string
		cfg          PlanConfig
		targetRefs   map[plumbing.ReferenceName]plumbing.Hash
		wantManaged  plumbing.ReferenceName
		preservedRef plumbing.ReferenceName
	}{
		{
			name: "branch filter --branch main --prune",
			cfg: PlanConfig{
				Branches: []string{"main"},
				Prune:    true,
			},
			targetRefs: map[plumbing.ReferenceName]plumbing.Hash{
				mainRef:    mainHash,
				releaseRef: releaseHash,
			},
			wantManaged:  mainRef,
			preservedRef: releaseRef,
		},
		{
			name: "rename mapping --map main:stable --prune",
			cfg: PlanConfig{
				Mappings: []RefMapping{{Source: "main", Target: "stable"}},
				Prune:    true,
			},
			targetRefs: map[plumbing.ReferenceName]plumbing.Hash{
				stableRef:  mainHash,
				releaseRef: releaseHash,
			},
			wantManaged:  stableRef,
			preservedRef: releaseRef,
		},
	}

for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			t.Parallel()

desired, managed, err := BuildDesiredRefs(sourceRefs, tt.cfg)
			if err != nil {
				t.Fatalf("BuildDesiredRefs: %v", err)
			}

plans, err := BuildPlans(nil, desired, tt.targetRefs, managed, tt.cfg)
			if err != nil {
				t.Fatalf("BuildPlans: %v", err)
			}

for _, p := range plans {
				if p.TargetRef == tt.preservedRef {
					t.Fatalf("unrelated target branch %s emitted plan %+v; --prune must preserve it under filtered scope", tt.preservedRef, p)
				}
				if p.Action == ActionDelete && p.TargetRef != tt.preservedRef {
					t.Fatalf("unexpected delete plan for %s: %+v", p.TargetRef, p)
				}
			}

if _, ok := managed[tt.preservedRef]; ok {
				t.Fatalf("managed map leaked unrelated target ref %s under filtered prune scope", tt.preservedRef)
			}
			if _, ok := managed[tt.wantManaged]; !ok {
				t.Fatalf("expected managed scope to include %s, got %+v", tt.wantManaged, managed)
			}
		})
	}
}

// TestBuildReplicationPlansPrunePreservesUnrelatedBranchesUnderFilter is the
// replicate-mode counterpart to the sync test above. The prune-scoping rule
// must hold whether the operation is sync or replicate.
func TestBuildReplicationPlansPrunePreservesUnrelatedBranchesUnderFilter(t *testing.T) {
	t.Parallel()

mainHash := plumbing.NewHash("1111111111111111111111111111111111111111")
	releaseHash := plumbing.NewHash("2222222222222222222222222222222222222222")

sourceRefs := map[plumbing.ReferenceName]plumbing.Hash{
		mainRef: mainHash,
	}

tests := []struct {
		name         string
		cfg          PlanConfig
		targetRefs   map[plumbing.ReferenceName]plumbing.Hash
		preservedRef plumbing.ReferenceName
	}{
		{
			name: "branch filter --branch main --prune",
			cfg: PlanConfig{
				Branches: []string{"main"},
				Prune:    true,
			},
			targetRefs: map[plumbing.ReferenceName]plumbing.Hash{
				mainRef:    mainHash,
				releaseRef: releaseHash,
			},
			preservedRef: releaseRef,
		},
		{
			name: "rename mapping --map main:stable --prune",
			cfg: PlanConfig{
				Mappings: []RefMapping{{Source: "main", Target: "stable"}},
				Prune:    true,
			},
			targetRefs: map[plumbing.ReferenceName]plumbing.Hash{
				stableRef:  mainHash,
				releaseRef: releaseHash,
			},
			preservedRef: releaseRef,
		},
	}

for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			t.Parallel()

desired, managed, err := BuildDesiredRefs(sourceRefs, tt.cfg)
			if err != nil {
				t.Fatalf("BuildDesiredRefs: %v", err)
			}

plans, err := BuildReplicationPlans(desired, tt.targetRefs, managed, tt.cfg)
			if err != nil {
				t.Fatalf("BuildReplicationPlans: %v", err)
			}

for _, p := range plans {
				if p.TargetRef == tt.preservedRef {
					t.Fatalf("unrelated target branch %s emitted plan %+v; --prune must preserve it under filtered scope", tt.preservedRef, p)
				}
			}
		})
	}
}

func TestBuildPlansTagBlock(t *testing.T) {
	repo, err := git.Init(memory.NewStorage(), nil)
	if err != nil {
		// handle error
	}
}

// TestRun_IntegrationPrunePreservesUnrelatedTargetBranchUnderFilter is the
// end-to-end counterpart to TestBuildPlansPrunePreservesUnrelatedBranchesUnderFilter
// in internal/planner. Once a user filters source refs with --branch or --map,
// --prune must only prune within that scope; branches that exist solely on the
target are out of scope and must survive.
func TestRun_IntegrationPrunePreservesUnrelatedTargetBranchUnderFilter(t *testing.T) {
	const orphanBranch = "release"

tests := []struct {
		name     string
		cfg      func(sourceURL, targetURL string) Config
		wantRefs []plumbing.ReferenceName
	}{
		{
			name: "branch filter --branch main --prune",
			cfg: func(sourceURL, targetURL string) Config {
				return Config{
					Source:   Endpoint{URL: sourceURL},
					Target:   Endpoint{URL: targetURL},
					Branches: []string{testBranch},
					Prune:    true,
				}
			},
			wantRefs: []plumbing.ReferenceName{
				plumbing.NewBranchReferenceName(testBranch),
				plumbing.NewBranchReferenceName(orphanBranch),
			},
		},
		{
			name: "rename mapping --map main:stable --prune",
			cfg: func(sourceURL, targetURL string) Config {
				return Config{
					Source:   Endpoint{URL: sourceURL},
					Target:   Endpoint{URL: targetURL},
					Mappings: []RefMapping{{Source: testBranch, Target: "stable"}},
					Prune:    true,
				}
			},
			wantRefs: []plumbing.ReferenceName{
				plumbing.NewBranchReferenceName("stable"),
				plumbing.NewBranchReferenceName(orphanBranch),
			},
		},
	}

for _, tt := range tests {
		t.Run(tt.name, func(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(orphanBranch)
			if err := targetRepo.Storer.SetReference(plumbing.NewHashReference(orphanRef, targetHead.Hash())); err != nil {
				t.Fatalf("seed orphan branch: %v", err)
			}

if _, err := Run(context.Background(), tt.cfg(sourceServer.RepoURL(), targetServer.RepoURL())); err != nil {
				t.Fatalf("Run: %v", err)
			}

for _, ref := range tt.wantRefs {
				if _, err := targetRepo.Reference(ref, true); err != nil {
					t.Fatalf("expected ref %s on target after filtered prune, got err=%v", ref, err)
				}
			}

orphanAfter, err := targetRepo.Reference(orphanRef, true)
			if err != nil {
				t.Fatalf("orphan branch missing after filtered prune: %v", err)
			}
			if orphanAfter.Hash() != targetHead.Hash() {
				t.Fatalf("orphan branch hash changed: pre=%s post=%s", targetHead.Hash(), orphanAfter.Hash())
			}
		})
	}
}

func TestRun_IntegrationTagsPruneDeletesTargetLocalTag(t *testing.T) {
	sourceRepo, sourceFS := newSourceRepo(t)
	makeCommits(t, sourceRepo, sourceFS, 2)
	targetRepo, targetFS := newSourceRepo(t)

sourceServer := newSmartHTTPRepoServer(t, sourceRepo)
	targetServer := newSmartHTTPRepoServer(t, targetRepo)
	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 failed: %v", err)
	}

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

makeCommits(t, targetRepo, targetFS, 1)
	targetOnlyHead, err := targetRepo.Reference(plumbing.NewBranchReferenceName(testBranch), true)
	if err != nil {
		t.Fatalf("target-only head: %v", err)
	}
	targetLocalTag := plumbing.NewTagReferenceName("prod-rollback")
	if err := targetRepo.Storer.SetReference(plumbing.NewHashReference(targetLocalTag, targetOnlyHead.Hash())); err != nil {
		t.Fatalf("set target-local tag: %v", err)
	}
	if err := targetRepo.Storer.SetReference(plumbing.NewHashReference(plumbing.NewBranchReferenceName(testBranch), sourceHead.Hash())); err != nil {
		t.Fatalf("reset target branch after target-local tag setup: %v", err)
	}

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("tag prune sync failed: %v", err)
	}
	if result.Deleted != 1 {
		t.Fatalf("expected exactly one deleted ref, got %+v", result)
	}
	if _, err := targetRepo.Reference(sourceTag, true); err != nil {
		t.Fatalf("expected source tag on target: %v", err)
	}
	if _, err := targetRepo.Reference(targetLocalTag, true); !errors.Is(err, plumbing.ErrReferenceNotFound) {
		t.Fatalf("expected target-local tag to be pruned, got %v", err)
	}
}

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
}

func TestMain(m *testing.M) {
	// Ensures empty config files for system/global so that test execution
	// is not affected by environmental settings (e.g. commit.gpgSign=true).
	if err := plugin.Register(plugin.ConfigLoader(), func() plugin.ConfigSource {
		return config.NewEmpty()
	}); err != nil {
		panic("register go-git empty config loader: " + err.Error())
	}

m.Run()
}
