Skip to content

Multiple Overlapping Wildcard RefSpecs Incorrectly Prune Up-to-Date Refs #227

@janikmueller

Description

@janikmueller

Version

6.8.0

Operating System

MacOS

Bug description

When using multiple wildcard refspecs that map to the same destination pattern with setRemoveDeletedRefs(true), JGit incorrectly prunes refs that are:

  1. Advertised by the remote server
  2. Already up-to-date (local SHA == remote SHA)
  3. Matching the wildcard destination pattern

This causes refs to alternate between created and deleted on subsequent fetches.

Actual behavior

Run 1:

  • Remote advertises: refs/merge-requests/42/head → abc123
  • RefSpec 2 matches and creates: refs/remotes/merge-requests/42/head → abc123
  • TrackingRefUpdate status: NEW ✓

Run 2 (unchanged remote):

  • Remote advertises: refs/merge-requests/42/head → abc123 (same SHA)
  • RefSpec 1: Match, but up-to-date (not added to TrackingRefUpdates)
  • RefSpec 2: No match (checks refs/merge-requests/*/from)
  • Prune logic: "Local ref refs/remotes/merge-requests/42/head wasn't updated by ANY refspec → must be deleted!"
  • TrackingRefUpdate status: FORCED to 00000000... ✗
  • Result: ALL REFS DELETED ✗

Run 3:

  • Same as Run 1 - refs recreated
  • Pattern repeats: create → delete → create → delete...

Expected behavior

When fetching with multiple wildcard refspecs like +refs/merge-requests/*/head:refs/remotes/merge-requests/*/head and +refs/merge-requests/*/from:refs/remotes/merge-requests/*/head:

Run 1:

  • Remote advertises: refs/merge-requests/42/head → abc123
  • RefSpec matches and creates: refs/remotes/merge-requests/42/head → abc123
  • Result: Ref created ✓

Run 2 (unchanged remote):

  • Remote advertises: refs/merge-requests/42/head → abc123 (same SHA)
  • Local has: refs/remotes/merge-requests/42/head → abc123 (up-to-date)
  • Result: No changes, ref remains ✓

Run 3:

  • Result: Still stable ✓

Relevant log output

Other information

Impact

Affects any application using JGit with:

  • Multiple wildcard refspecs mapping to the same destination
  • Pruning enabled
  • Real-world use case: Cross-platform merge request/pull request support (GitHub + GitLab + others)

Minimal reproduction example:

import java.io.File;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Arrays;
import java.util.Collection;
import java.util.Comparator;

import org.eclipse.jgit.api.Git;
import org.eclipse.jgit.lib.Ref;
import org.eclipse.jgit.transport.FetchResult;
import org.eclipse.jgit.transport.RefSpec;
import org.eclipse.jgit.transport.TrackingRefUpdate;

public class JGitWildcardPruneBug {

    public static void main(String[] args) throws Exception {
        String remoteUrl = "https://gitlab.com/gitlab-org/gitlab-runner.git";

        try (TempDirectory tempDir = new TempDirectory("jgit-bug-test")) {
            try (Git git = Git.cloneRepository().setURI(remoteUrl).setDirectory(tempDir.getPath().toFile())
                    .setCloneAllBranches(false).call()) {

                // Add MULTIPLE refspecs that overlap in destination
                RefSpec wildcardRefSpec1 = new RefSpec("+refs/merge-requests/*/head:refs/merge-requests/*/head");
                RefSpec wildcardRefSpec2 = new RefSpec("+refs/merge-requests/*/from:refs/merge-requests/*/head");

                for (int run = 1; run <= 3; run++) {
                    System.out.println("=== RUN " + run + " ===");

                    FetchResult result1 = git.fetch().setRefSpecs(Arrays.asList(wildcardRefSpec1, wildcardRefSpec2))
                            .setRemoveDeletedRefs(true).call();
                    printFetchResult(result1);

                    printLocalMergeRequestRefs(git, "AFTER RUN " + run);
                    System.out.println();
                }
            }
        }
    }

    private static void printFetchResult(FetchResult result) {
        Collection<TrackingRefUpdate> updates = result.getTrackingRefUpdates();
        long mrUpdates = updates.stream().filter(u -> u.getLocalName().contains("merge-requests")).count();

        if (mrUpdates > 0) {
            System.out.println(" - MR ref updates: " + mrUpdates);
            updates.stream().filter(u -> u.getLocalName().contains("merge-requests")).limit(3).forEach(update -> {
                System.out.printf("    %s: %s -> %s (%s)%n", update.getLocalName(),
                        update.getOldObjectId().abbreviate(7).name(), update.getNewObjectId().abbreviate(7).name(),
                        update.getResult());
            });
            if (mrUpdates > 3) {
                System.out.println("    ... (" + (mrUpdates - 3) + " more)");
            }
        } else {
            System.out.println(" - No MR ref updates");
        }
    }

    private static void printLocalMergeRequestRefs(Git git, String label) throws Exception {
        Collection<Ref> refs = git.getRepository().getRefDatabase().getRefs();
        long mrCount = refs.stream().filter(ref -> ref.getName().contains("merge-requests")).count();

        System.out.println(label + " - Local MR refs: " + mrCount);
        if (mrCount == 0) {
            System.out.println("ALL MERGE REQUEST REFS WERE DELETED!");
        }
    }

    static class TempDirectory implements AutoCloseable {

        private final Path path;

        public TempDirectory(String prefix) throws IOException {
            this.path = Files.createTempDirectory(prefix);
        }

        public Path getPath() {
            return path;
        }

        @Override
        public void close() throws IOException {
            if (Files.exists(path)) {
                Files.walk(path).sorted(Comparator.reverseOrder()).map(Path::toFile).forEach(File::delete);
            }
        }
    }
}

Expected Output

=== RUN 1 ===

  • MR ref updates: 1720
    refs/merge-requests/1004/head: 0000000 -> f4a3dad (NEW)
    refs/merge-requests/1005/head: 0000000 -> 5ac68e9 (NEW)
    refs/merge-requests/1006/head: 0000000 -> 88ff5e7 (NEW)
    ... (1717 more)
    AFTER RUN 1 - Local MR refs: 1720

=== RUN 2 ===

  • No MR ref updates
    AFTER RUN 2 - Local MR refs: 1720

=== RUN 3 ===

  • No MR ref updates
    AFTER RUN 3 - Local MR refs: 1720

Actual Output

=== RUN 1 ===

  • MR ref updates: 1720
    refs/merge-requests/1004/head: 0000000 -> f4a3dad (NEW)
    refs/merge-requests/1005/head: 0000000 -> 5ac68e9 (NEW)
    refs/merge-requests/1006/head: 0000000 -> 88ff5e7 (NEW)
    ... (1717 more)
    AFTER RUN 1 - Local MR refs: 1720

=== RUN 2 ===

  • MR ref updates: 1720
    refs/merge-requests/1004/head: f4a3dad -> 0000000 (FORCED)
    refs/merge-requests/1005/head: 5ac68e9 -> 0000000 (FORCED)
    refs/merge-requests/1006/head: 88ff5e7 -> 0000000 (FORCED)
    ... (1717 more)
    AFTER RUN 2 - Local MR refs: 0
    ALL MERGE REQUEST REFS WERE DELETED!

=== RUN 3 ===

  • MR ref updates: 1720
    refs/merge-requests/1004/head: 0000000 -> f4a3dad (NEW)
    refs/merge-requests/1005/head: 0000000 -> 5ac68e9 (NEW)
    refs/merge-requests/1006/head: 0000000 -> 88ff5e7 (NEW)
    ... (1717 more)
    AFTER RUN 3 - Local MR refs: 1720

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions