Repository navigation
Description
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:
- Ref updates and FETCH_HEAD.
update_one_tipmatches every advertised ref against every active refspec with plaingit_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_fetchheadmatches the same way. - Pruning.
git_remote_prunedecides 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_matchesfinds no match for a ref a negative refspec excludes, soget_stale_headsdoesn'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
doneExpected 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.
Activity
Matching push compares advertised names as bytes; link libgit2 issues
Fetch and push a set of refspecs at once, as git applies them (#45)
Report pushes git refuses, and fetches, the way git does (#46)