Negative fetch refspecs only affect negotiation: excluded refs are still updated, written to FETCH_HEAD, and pruned · Issue #7435 · libgit2/libgit2 · GitHub
Skip to content

Negative fetch refspecs only affect negotiation: excluded refs are still updated, written to FETCH_HEAD, and pruned #7435

Open

Description

@odrobnik

A negative refspec (^refs/heads/private) in a fetch is honoured when libgit2 picks the refs to want (filter_wants → git_remote__matching_refspec, the only place negatives are checked). It is not honoured after the download:

  1. Ref updates and FETCH_HEAD. update_one_tip matches every advertised ref against every active refspec with plain git_refspec_src_matches(). The positive wildcard therefore still creates or updates the remote-tracking ref of an excluded branch whenever that branch's objects are present, which they are whenever it shares history with a fetched branch. The negative refspec itself has no destination, so it is treated as "store in FETCH_HEAD" and adds the excluded ref to FETCH_HEAD. git_remote_write_fetchhead matches the same way.
  2. Pruning. git_remote_prune decides a remote-tracking ref is stale without consulting negative refspecs. So once the remote deletes an excluded branch, its remote-tracking ref is pruned. git leaves it alone: refspec_find_all_matches finds no match for a ref a negative refspec excludes, so get_stale_heads doesn't consider it.

git applies negative refspecs to everything it fetches (apply_negative_refspecs in get_ref_map), except tag auto-following.

Reproduction steps

A C program that fetches origin the way git fetch [--prune] does, with git_remote_fetch(remote, NULL, &opts, NULL):

fetchrepro.c
/*
 * Fetch from "origin" as `git fetch [--prune] origin [<refspec>...]`
 * would: git_remote_fetch() with the given refspecs, or with NULL (the
 * remote's configured refspecs) when there are none.
 *
 * usage: fetchrepro <repository> [--prune] [<refspec>...]
 */
#include <stdio.h>
#include <string.h>
#include <git2.h>

static int check(int error, const char *what)
{
	if (error < 0) {
		const git_error *e = git_error_last();
		fprintf(stderr, "%s: %s\n", what, e ? e->message : "unknown error");
		return 1;
	}
	return 0;
}

int main(int argc, char **argv)
{
	git_repository *repo = NULL;
	git_remote *remote = NULL;
	git_fetch_options opts = GIT_FETCH_OPTIONS_INIT;
	git_strarray refspecs = { NULL, 0 };
	int first = 2, failed;

	if (argc < 2) {
		fprintf(stderr, "usage: %s <repository> [--prune] [<refspec>...]\n", argv[0]);
		return 2;
	}
	if (argc > 2 && !strcmp(argv[2], "--prune")) {
		opts.prune = GIT_FETCH_PRUNE;
		first = 3;
	}
	refspecs.strings = argv + first;
	refspecs.count = (size_t)(argc - first);

	git_libgit2_init();
	failed = check(git_repository_open(&repo, argv[1]), "open") ||
		check(git_remote_lookup(&remote, repo, "origin"), "lookup") ||
		check(git_remote_fetch(remote, refspecs.count ? &refspecs : NULL, &opts, NULL), "fetch");

	git_remote_free(remote);
	git_repository_free(repo);
	git_libgit2_shutdown();
	return failed;
}

A script that runs the same fetches with git and with fetchrepro on identical clones:

repro.sh
#!/bin/sh
# Negative fetch refspecs in libgit2: compare `git fetch` with
# git_remote_fetch() (./fetchrepro) on identical clones.
set -e
FETCH=${FETCH:-./fetchrepro}
export GIT_CONFIG_GLOBAL=/dev/null GIT_AUTHOR_NAME=a GIT_AUTHOR_EMAIL=a@b \
       GIT_COMMITTER_NAME=a GIT_COMMITTER_EMAIL=a@b
rm -rf src git libgit2 git-prune libgit2-prune

git init -q -b main src
git -C src commit -q --allow-empty -m one
git -C src branch private
git -C src branch gone

echo '### 1. fetch with remote.origin.fetch = +refs/heads/*:refs/remotes/origin/*, ^refs/heads/private'
for r in git libgit2; do
    git clone -q src $r
    git -C $r config --add remote.origin.fetch '^refs/heads/private'
    git -C $r update-ref -d refs/remotes/origin/private   # start without it
done
git -C git fetch -q origin
$FETCH libgit2
for r in git libgit2; do
    echo "--- $r: remote-tracking refs"
    git -C $r for-each-ref --format='%(refname)' refs/remotes/origin
    echo "--- $r: FETCH_HEAD"
    cut -f2- $r/.git/FETCH_HEAD
done

echo
echo '### 2. fetch --prune after the remote deletes "private" and "gone"'
for r in git-prune libgit2-prune; do
    git clone -q src $r     # has origin/private and origin/gone
    git -C $r config --add remote.origin.fetch '^refs/heads/private'
done
git -C src branch -q -D private gone
git -C git-prune fetch -q --prune origin
$FETCH libgit2-prune --prune
for r in git-prune libgit2-prune; do
    echo "--- $r: remote-tracking refs"
    git -C $r for-each-ref --format='%(refname)' refs/remotes/origin
done

Expected behavior

As with git 2.54, origin/private is neither created by the fetch nor removed by the prune, and FETCH_HEAD doesn't list it.

Actual behavior

### 1. fetch with remote.origin.fetch = +refs/heads/*:refs/remotes/origin/*, ^refs/heads/private
--- git: remote-tracking refs
refs/remotes/origin/HEAD
refs/remotes/origin/gone
refs/remotes/origin/main
--- git: FETCH_HEAD
	branch 'main' of src
not-for-merge	branch 'gone' of src
--- libgit2: remote-tracking refs
refs/remotes/origin/HEAD
refs/remotes/origin/gone
refs/remotes/origin/main
refs/remotes/origin/private
--- libgit2: FETCH_HEAD
	branch 'main' of src
not-for-merge	branch 'gone' of src
not-for-merge	branch 'private' of src

### 2. fetch --prune after the remote deletes "private" and "gone"
--- git-prune: remote-tracking refs
refs/remotes/origin/HEAD
refs/remotes/origin/main
refs/remotes/origin/private
--- libgit2-prune: remote-tracking refs
refs/remotes/origin/HEAD
refs/remotes/origin/main

In libgit2's clone, the fetch creates refs/remotes/origin/private and lists branch 'private' in FETCH_HEAD. fetch --prune then deletes refs/remotes/origin/private.

Version of libgit2 (release number or SHA1)

v1.9.4. The code involved is unchanged in v1.9.7 and on main (42a89d7), which the links above point to.

Operating system(s) tested

macOS 27 (arm64)


For reference, GitKit, which packages libgit2 for SwiftPM, works around this by compiling remote.c through a shim (Cocoanetics/GitKit#45). The shim routes remote.c's git_refspec_src_matches() calls through a matcher that never lets a negative refspec select a ref, and that drops a ref excluded by a negative refspec of the same set (the fetch's refspecs, or the configured ones for opportunistic updates). The tag auto-follow probe is left alone. In git_remote_prune, the shim counts an excluded remote ref as still present. I'm happy to turn that into a proper patch if that direction suits you.

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