checkout: phantom `GIT_ECONFLICT` when `core.symlinks=false` and the index contains symlink entries — status reports the workdir clean, SAFE checkout refuses with "N conflicts prevent checkout"
Body:
Summary
On a repository where core.symlinks=false (filesystem without symlink support; libgit2 itself materializes symlinks as regular files containing the link-target text via git_futils_fake_symlink in blob_content_to_link), a GIT_CHECKOUT_SAFE that only needs to delete an unmodified symlink entry fails with GIT_ECONFLICT / "1 conflict prevents checkout" — while git_status on the same workdir reports fully clean.
git CLI (tested 2.43) behaves correctly in the identical situation: status is clean and the checkout succeeds.
The asymmetry: the diff/status code path honors GIT_CONFIGMAP_SYMLINKS (diff_file.c, diff_file_content__load_workdir: a GIT_FILEMODE_LINK entry is compared as file content when symlinks are unsupported), but checkout.c's dirtiness check does not — checkout_is_workdir_modified → is_filemode_changed(..., data->respect_filemode) only normalizes S_IFLNK when core.filemode is false, and never consults core.symlinks. Verified still present on main (2026-09-03): is_filemode_changed is unchanged there.
Reproduction
libgit2 v1.8.0, Linux x86_64. Self-contained repro (creates its own repo):
/* gcc -o repro repro.c -I<include> <libgit2.a> -lpthread -lrt
* prints: workdir status entries: 0 (clean)
* SAFE checkout B: rc=-13 message="1 conflict prevents checkout"
*/
#include <git2.h>
#include <stdio.h>
#include <string.h>
#include <stdlib.h>
static void die(const char* what, int rc) {
const git_error* e = git_error_last();
fprintf(stderr, "%s failed rc=%d: %s\n", what, rc,
e && e->message ? e->message : "?");
exit(1);
}
int main(int argc, char** argv) {
if (argc < 2) { fprintf(stderr, "usage: %s <dir>\n", argv[0]); return 2; }
git_libgit2_init();
const char* dir = argv[1];
git_repository* repo = NULL;
if (git_repository_init(&repo, dir, 0) != 0) die("init", -1);
git_config* cfg = NULL;
if (git_repository_config(&cfg, repo) != 0) die("config", -1);
git_config_set_bool(cfg, "core.symlinks", 0); /* sandbox w/o symlink support */
git_config_set_bool(cfg, "core.filemode", 1); /* linux-style default */
git_config_free(cfg);
git_signature* sig = NULL;
if (git_signature_new(&sig, "t", "t@t", 1234567890, 0) != 0) die("sig", -1);
/* commit A: CLAUDE.md (regular) + AGENTS.md (symlink -> CLAUDE.md) */
git_index* index = NULL;
if (git_repository_index(&index, repo) != 0) die("index", -1);
git_oid blobClaude, blobLink, treeA, treeB, commitA, commitB;
if (git_blob_create_from_buffer(&blobClaude, repo, "hello\n", 6) != 0) die("blob1", -1);
if (git_blob_create_from_buffer(&blobLink, repo, "CLAUDE.md", 9) != 0) die("blob2", -1);
git_index_entry e = {0};
e.mode = GIT_FILEMODE_BLOB; e.id = blobClaude; e.path = "CLAUDE.md";
if (git_index_add(index, &e) != 0) die("add1", -1);
memset(&e, 0, sizeof(e));
e.mode = GIT_FILEMODE_LINK; e.id = blobLink; e.path = "AGENTS.md";
if (git_index_add(index, &e) != 0) die("add2", -1);
if (git_index_write_tree(&treeA, index) != 0) die("treeA", -1);
git_tree* t = NULL;
if (git_tree_lookup(&t, repo, &treeA) != 0) die("treeA-lookup", -1);
if (git_commit_create(&commitA, repo, "refs/heads/a", sig, sig, NULL,
"A: add AGENTS.md symlink", t, 0, NULL) != 0) die("commitA", -1);
git_tree_free(t);
/* commit B: AGENTS.md removed */
if (git_index_remove(index, "AGENTS.md", 0) != 0) die("rm", -1);
if (git_index_write_tree(&treeB, index) != 0) die("treeB", -1);
git_commit* p = NULL;
if (git_commit_lookup(&p, repo, &commitA) != 0) die("p", -1);
if (git_tree_lookup(&t, repo, &treeB) != 0) die("treeB-lookup", -1);
git_commit* parr[1] = { p };
if (git_commit_create(&commitB, repo, "refs/heads/b", sig, sig, NULL,
"B: drop AGENTS.md", t, 1, parr) != 0) die("commitB", -1);
git_tree_free(t);
git_commit_free(p);
/* materialize workdir for commit A like clone/checkout does with
* core.symlinks=false (fake_symlink -> regular file w/ target text) */
if (git_repository_set_head(repo, "refs/heads/a") != 0) die("set_head", -1);
git_object* objA = NULL;
if (git_object_lookup(&objA, repo, &commitA, GIT_OBJECT_COMMIT) != 0) die("objA", -1);
git_checkout_options co = GIT_CHECKOUT_OPTIONS_INIT;
co.checkout_strategy = GIT_CHECKOUT_FORCE;
int rc = git_checkout_tree(repo, objA, &co);
if (rc != 0) die("checkout A (FORCE)", rc);
git_object_free(objA);
git_tree* tA = NULL;
if (git_tree_lookup(&tA, repo, &treeA) != 0) die("treeA2", -1);
if (git_index_read_tree(index, tA) != 0) die("index-restore", -1);
git_index_write(index);
git_tree_free(tA);
/* status must consider the workdir clean */
git_status_options so = GIT_STATUS_OPTIONS_INIT;
so.flags = GIT_STATUS_OPT_INCLUDE_UNTRACKED | GIT_STATUS_OPT_RECURSE_UNTRACKED_DIRS;
git_status_list* sl = NULL;
if (git_status_list_new(&sl, repo, &so) != 0) die("status", -1);
size_t dirty = git_status_list_entrycount(sl);
git_status_list_free(sl);
printf("workdir status entries: %zu (%s)\n", dirty,
dirty == 0 ? "clean" : "DIRTY (unexpected)");
/* SAFE-checkout commit B: AGENTS.md is unmodified -> should be removed, rc=0 */
git_object* objB = NULL;
if (git_object_lookup(&objB, repo, &commitB, GIT_OBJECT_COMMIT) != 0) die("objB", -1);
git_checkout_options cb = GIT_CHECKOUT_OPTIONS_INIT;
cb.checkout_strategy = GIT_CHECKOUT_SAFE;
rc = git_checkout_tree(repo, objB, &cb);
const git_error* err = git_error_last();
printf("SAFE checkout B: rc=%d message=\"%s\"\n", rc,
rc == 0 ? "" : (err && err->message ? err->message : "?"));
git_object_free(objB);
git_index_free(index);
git_signature_free(sig);
git_repository_free(repo);
git_libgit2_shutdown();
return rc == 0 ? 0 : 1;
}Actual output (libgit2 1.8.0)
workdir status entries: 0 (clean)
SAFE checkout B: rc=-13 message="1 conflict prevents checkout"Expected behavior
git_checkout_tree returns 0 and removes the materialized AGENTS.md, matching both git CLI (verified: git status clean, git checkout succeeds) and libgit2's own status/diff behavior.
git CLI control (same scenario)
git init && git config core.symlinks false
echo hello > CLAUDE.md && git add CLAUDE.md
T=$(printf CLAUDE.md | git hash-object -w --stdin)
git update-index --add --cacheinfo 120000,$T,AGENTS.md
git commit -m A && git branch a
git rm --cached AGENTS.md && git commit -m B
git checkout a && printf CLAUDE.md > AGENTS.md
git status --porcelain # -> empty (clean)
git checkout master # -> rc=0, AGENTS.md removedRoot cause
src/libgit2/checkout.c, checkout_is_workdir_modified: the workdir-vs-baseline comparison calls is_filemode_changed(baseitem->mode, wditem->mode, data->respect_filemode), and is_filemode_changed only maps S_IFLNK to GIT_FILEMODE_BLOB when respect_filemode (from core.filemode) is false. With core.symlinks=false + core.filemode=true (the normal Linux-like case on a filesystem without symlink support), an index entry GIT_FILEMODE_LINK vs. a workdir regular file (that libgit2 itself wrote via git_futils_fake_symlink) is therefore judged "modified", and the GIT_DELTA_DELETED branch of checkout_action_with_wd turns that into CHECKOUT_ACTION__CONFLICT under GIT_CHECKOUT_SAFE.
The status/diff side does handle this correctly: src/libgit2/diff_file.c (diff_file_content__load_workdir) looks up GIT_CONFIGMAP_SYMLINKS and treats link entries as plain file content when symlinks are unsupported, which is why git_status reports the workdir clean while checkout refuses to touch it.
The same phantom conflict also hits GIT_DELTA_MODIFIED (symlink whose target changed between branches) and GIT_DELTA_TYPECHANGE (symlink ↔ blob/tree) — any SAFE checkout whose baseline contains a link entry and the workdir holds its materialized form.
Suggested fix direction
Make checkout's dirtiness check honor core.symlinks=false the same way diff does — e.g. carry a respect_symlinks flag (from GIT_CONFIGMAP_SYMLINKS, like can_symlink already is at checkout.c:2458) into checkout_is_workdir_modified and normalize S_IFLNK in is_filemode_changed when symlinks are unsupported.
Environment
- libgit2 version: 1.8.0 (static), also reproduced against current
main(code unchanged as of 2026-09-03) - OS: Linux x86_64 (also observed on HarmonyOS OHOS sandbox where the filesystem lacks symlink support, which is how we hit this in production)
Source: libgit2/libgit2