Skip to content

gix status reports phantom submodule modifications when the gitlink target diverges from <modules>/<name> #2585

@tymcauley

Description

Current behavior 😯

When a submodule's worktree .git is a gitlink file (modern form), Submodule::git_dir_try_old_form() (gix/src/submodule/mod.rs:239) skips the file's gitdir: pointer entirely and falls back to the name-derived path <common_dir>/modules/<name>. When the gitlink target and the name-derived path resolve to different directories — which can happen after a submodule rename or a manual relocation of .git/modules/<name> — gix opens the wrong repository.

Every consumer of Submodule::open() is affected. The most user-visible symptom is gix status reporting a SubmoduleModification against the superproject's index when real git status reports clean. (I noticed it via starship, which uses gix internally for prompt status.)

Expected behavior 🤔

Follow the gitlink's gitdir: pointer, like git does (setup.c::read_gitfile_gently). The pointer is authoritative; the name-derived path is a guess that's only correct in the typical case where the submodule was added with its current name and never moved.

Git behavior

Git reads the worktree's .git gitlink, parses gitdir: <relative-path>, resolves it against the worktree's .git location, and opens that directory. The name-derived <common_dir>/modules/<name> path is not consulted at open time.

Steps to reproduce 🕹

Minimal shell repro that builds the divergent state from scratch:

set -e
cd /tmp && rm -rf gix-submod-bug && mkdir gix-submod-bug && cd gix-submod-bug

# Upstream module to use as a submodule.
git init -q upstream && cd upstream
git config user.email t@e && git config user.name t
echo a > f && git add f && git commit -q -m a
echo b > f && git commit -qam b
cd ..

# Superproject with the submodule under a nested name.
git init -q super && cd super
git config user.email t@e && git config user.name t
git -c protocol.file.allow=always submodule add -q ../upstream outer/inner
git commit -q -m "add submodule outer/inner"

# Relocate the gitdir to a name-collision path and rewrite the gitlink.
mv .git/modules/outer/inner .git/modules/inner
rmdir .git/modules/outer
git config --file .git/modules/inner/config core.worktree ../../../outer/inner
printf 'gitdir: ../../.git/modules/inner\n' > outer/inner/.git

# Plant a different repo at the original (name-derived) path with a
# different HEAD so the divergence is observable.
git clone -q --bare ../upstream .git/modules/outer/inner
git -C .git/modules/outer/inner update-ref HEAD "$(git -C ../upstream rev-parse @~1)"

# Real git: clean.
git status --porcelain=v2
git submodule status outer/inner

# gix: reports `SubmoduleModification` for outer/inner against the parent index.

A self-contained reproducer test against this exact state is in https://github.com/tymcauley/gitoxide/tree/fix/submodule-gitlink-resolution:

  • gix/tests/fixtures/make_submodules.shsubmodule-with-divergent-gitlink fixture.
  • gix/tests/gix/submodule.rsgitlink_target_takes_precedence_over_name_in_git_dir_resolution.

Root cause

git_dir_try_old_form() was designed as a binary classifier — old form vs modern — not a generic gitdir resolver. Its body branches on worktree_gitdir.is_dir(): directory → use it; otherwise → fall back to the name-derived git_dir(). The "otherwise" case lumps "uninitialized submodule" and "modern gitlink file" together, even though they need different answers; the gitlink file's contents are never read.

state() compounds the issue: is_old_form is derived as maybe_old_path != git_dir, which works only because the buggy resolver returns identical values for modern submodules. Once the resolver is fixed to follow gitlinks, divergent modern forms would otherwise be misclassified as old form, so the heuristic needs replacing too.

A fix is on the same branch — git_dir_try_old_form() follows the gitlink via gix_discover::path::from_gitdir_file(), and state().is_old_form derives directly from worktree_git.is_dir(). Happy to open a PR if useful.

Related


Issue drafted with AI assistance (Claude Code) on top of my own analysis.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No fields configured for Bug.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions