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" · Issue #7363 · libgit2/libgit2 · GitHub
Skip to content

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" #7363

Open

Description

@liz7up

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 removed

Root 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)

Activity

  1. added 2 commits that reference this issue on Oct 13, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

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