Test --prune respects --branch and --map filters · Entire
Test --prune respects --branch and --map filters
36012b2→main·
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() }