Advertisement

Troubleshooting A Symlink — A Whodunnit For The Git Record Books

The repository was clean. The commit had passed review. The continuous
integration pipeline had smiled its little green check-mark smile. Then
somebody cloned the project on another machine, ran the build, and watched
it collapse because a configuration file was missing.

Except the file was not missing. It was supposedly right there.

Welcome to symlink troubleshooting in Git, where a file can exist, not
exist, point somewhere else, become an ordinary text file, and still make
git status insist that everything is perfectly normal. It is
less like debugging and more like questioning suspects in a locked-room
mystery. Fortunately, Git keeps unusually detailed fingerprints.

Meet the Victim: What Git Actually Stores for a Symlink

A symbolic link, or symlink, is a special filesystem object containing a
path to another file or directory. It does not contain the target file’s
actual data. Think of it as a forwarding address written on a very small,
very opinionated piece of paper.

Git records that piece of paper in two important parts:

  • The symlink target path is stored as a blob.
  • The tree entry uses the special mode 120000.

That mode is the first major clue. A normal non-executable file commonly
appears as 100644, an executable file as
100755, and a symlink as 120000. Git therefore
knows that config/current.yml is meant to be a link even
though the blob itself contains nothing more exciting than a string such
as ../environments/development.yml.

Git does not continuously verify that the target exists. A symlink may be
committed while its destination is absent, outside the repository, ignored
by Git, generated during a build, or available only in production. The
operating system can create a link to a nonexistent destination, so the
first checkout may succeed while the first attempt to open the target
fails spectacularly.

The Crime Scene: Common Symlink Symptoms

Symlink failures rarely introduce themselves politely. Instead, they wear
disguises:

  • A build says a file is missing even though ls shows it.
  • The link works on macOS or Linux but fails on Windows.
  • The file contains a path as plain text instead of opening its target.
  • Git reports a type change after a checkout, merge, or file copy.
  • The link works locally but breaks inside a container or CI runner.
  • A relative link reaches the wrong directory after being moved.
  • The destination differs only by capitalization.
  • The repository looks clean, yet the working tree does not contain a real link.

The trick is to separate three witnesses that developers often treat as
one: the committed tree, Git’s index, and the working directory. They may
all tell different versions of the story.

The Three Witnesses: Commit, Index, and Working Tree

Witness One: The Committed Tree

The commit tells you what the repository officially records. Inspect the
suspicious path with:

A healthy committed symlink should produce an entry beginning with:

Next, interrogate the blob itself:

For a symlink, the output should be its target path, such as:

If the tree reports 100644, the committed object is an
ordinary file, even if somebody intended it to behave like a link. Case
closed on Git; the wrong file type was committed.

Witness Two: The Index

The index, also called the staging area, represents what the next commit
would contain. Examine it with:

Again, 120000 identifies a staged symlink. If the commit says
120000 but the index says 100644, someone has
staged a type conversion. Perhaps a copy tool flattened the link, an editor
replaced it, or a well-meaning developer recreated it with
echo.

Witness Three: The Working Directory

Now inspect what the operating system actually created:

On Unix-like systems, ls -l normally displays the link and
target with an arrow. readlink prints the stored target path.
If readlink produces no result and returns an error, the
object is probably not a symlink.

Be careful with diagnostic tools that follow links automatically.
Operations comparable to stat may report information about
the target, while an operation comparable to lstat reports
information about the link itself. Ask the wrong question and your witness
confidently describes somebody else.

The Suspect Lineup

Suspect No. 1: A Broken or Missing Target

The simplest explanation remains popular because it keeps committing
crimes. Print the stored target, then resolve it from the directory
containing the link.

Suppose the repository contains:

The destination is resolved relative to app/config/, not
relative to the repository root and certainly not relative to whichever
directory your terminal happens to occupy. The expected destination is
therefore app/shared/current.yml.

Developers frequently calculate relative links from the location where
they run ln -s. The filesystem, unmoved by human intentions,
calculates them from the link’s location.

Suspect No. 2: An Absolute Path

A link targeting
/Users/alex/projects/acme/config/local.json may perform
flawlessly on Alex’s laptop. On Priya’s workstation, a Linux build agent,
or a container, Alex’s home directory is less useful than a map to buried
treasure on another planet.

Prefer repository-relative targets when the destination belongs to the
project. Absolute symlinks are sometimes appropriate for machine-managed
locations, but they should be created during deployment rather than
committed as if every computer shares the same directory structure.

Suspect No. 3: core.symlinks=false

This is the twist that earns the case its Git record-book status.

Git can detect that a filesystem or user account cannot create symbolic
links. When that happens, a repository may be initialized or cloned with
core.symlinks set to false. Git then checks out
each committed symlink as a small ordinary file containing the target
string.

The index can still record mode 120000. The working file can
still contain the correct target text. Consequently, Git may report a
completely clean working tree. Nothing has changed from Git’s perspective;
the link has merely been represented in a compatibility form.

Check the setting and its source:

On Windows, creating symlinks may depend on system policy, privileges,
filesystem support, and Developer Mode. Do not blindly switch the setting
to true. First confirm that the current environment can
actually create links.

Suspect No. 4: A Regular File Wearing a Symlink Costume

Someone may try to repair a missing link like this:

That creates a regular text file, not a symlink. The contents resemble a
symlink blob, but the filesystem type is wrong. Git may expose the change
as a type change, represented by T in short status output.

These commands help reveal transitions between modes such as
120000 and 100644. When the contents look
identical, the mode change may be the only evidence left at the scene.

Suspect No. 5: Case Sensitivity

A link to ../Assets/logo.svg is not necessarily equivalent
to ../assets/logo.svg. A case-insensitive filesystem may let
the mistake slide. A case-sensitive Linux runner will not.

Inspect every directory component, not just the filename. Cross-platform
teams should treat path capitalization as exact even when local machines
are unusually forgiving.

Suspect No. 6: The Target Was Never in the Repository

Git records the link and its target string independently. Committing a
symlink does not automatically commit the destination.

The target may be ignored by .gitignore, produced by a setup
script, stored in a submodule, mounted as a secret, or located outside the
repository. Verify its tracking status:

If the destination is intentionally generated, document the setup step.
Otherwise, every new developer gets to solve the same mystery, which is a
charming tradition only if your team dislikes productivity.

Suspect No. 7: Containers, Network Shares, and CI Environments

A symlink can cross a boundary the runtime does not share. The target may
exist on the host but not inside a container, or it may resolve outside a
mounted workspace. Network filesystems and shared folders may also apply
different link policies from local disks.

Inspect the link inside the failing environment. Host-side evidence is
useful, but it does not establish what a container, remote runner, or
deployment process can see.

A Reliable Git Symlink Troubleshooting Workflow

When the clues begin arguing, use this sequence instead of randomly
deleting things until the build becomes afraid of you.

  1. Check Git’s status.
    Run git status --short and look for T,
    modifications, deletions, or untracked replacements.
  2. Inspect the committed mode.
    Use git ls-tree HEAD -- path/to/link. Expect
    120000.
  3. Read the committed target.
    Use git cat-file -p HEAD:path/to/link.
  4. Inspect the index.
    Use git ls-files --stage -- path/to/link.
  5. Inspect the filesystem object.
    Use ls -l, file, and
    readlink.
  6. Resolve the target from the link’s directory.
    Confirm every path component and letter case.
  7. Check Git’s symlink configuration.
    Run
    git config --show-origin --get core.symlinks.
  8. Repeat inside the failing runtime.
    Diagnose from CI, the container, or the deployment host rather than
    assuming your laptop represents the universe.

How to Repair the Symlink Without Destroying Evidence

First record the intended target. Then remove only the incorrect
filesystem object and recreate the link:

Confirm that the staged mode is 120000 before committing. Do
not rely solely on the fact that opening the path reaches the expected
file. A copied target file can appear to work while quietly replacing the
link with duplicated content.

If the committed version is correct and only the working object is
damaged, remove the bad object and restore it from Git:

On a system configured with core.symlinks=false, restoration
may intentionally produce a plain file again. That is not a failed
restore; it is evidence that the environment cannot or will not materialize
the repository’s symlink as a native link.

Preventing the Next Symlink Mystery

  • Prefer relative links for files that live inside the repository.
  • Test clones on every operating system your team officially supports.
  • Add a CI check that verifies critical paths are actual symlinks.
  • Document targets that are generated, mounted, ignored, or external.
  • Avoid committing machine-specific absolute paths.
  • Review mode changes, not just textual diffs.
  • Preserve symlinks when copying, packaging, or extracting repositories.
  • Use exact capitalization for every target path component.

A simple Unix-like CI assertion might look like this:

Security deserves a final mention. Scripts running with elevated
privileges should not follow untrusted symlinks casually. Recursive file
operations that dereference links can affect files outside the directory
a script appears to manage. Know whether each tool follows the link or
operates on the link itself.

Field Notes: The Case of the Perfectly Clean Repository

My most memorable symlink investigation began with a message that every
developer loves receiving: “It works on my machine, but production says
the template does not exist.” The repository contained a link named
templates/current, which was supposed to point to
templates/releases/v4. On macOS, the application rendered the
correct files. In a Linux container launched from a Windows checkout, it
found a tiny file containing the characters
releases/v4.

The first suspect was Docker. Docker is frequently blamed because it is
nearby, complicated, and unable to defend itself in meetings. We inspected
bind mounts, working directories, image layers, and application path
handling. Everything looked reasonable. The container faithfully received
exactly what the host supplied: an ordinary file.

Next came Git. The repository browser displayed the path as a symlink, and
git status reported no changes. That combination seemed
impossible until we stopped asking whether the path’s contents were
correct and started asking what type of object existed at each layer.

git ls-tree HEAD reported mode 120000.
git ls-files --stage also reported 120000.
The commit and index agreed that the path was a symlink. The Windows
working directory disagreed. It contained a regular file whose contents
matched the stored target text exactly.

That exact match explained the clean status. Git had not lost the symlink
or secretly rewritten history. The local repository had
core.symlinks=false, so Git was deliberately using a
compatibility representation. The container merely inherited the flattened
file through the bind mount.

Enabling the operating system’s supported symlink-creation setup,
confirming that a test link could be created, changing the repository
configuration, and checking out the path again restored the native
symlink. The application immediately found its templates. Docker was
released without charge, although everyone agreed it remained suspicious
in an unrelated matter involving DNS.

The lasting lesson was not “turn on symlinks.” That would be too simple
and occasionally wrong. The lesson was to compare Git’s committed tree,
the index, and the filesystem instead of assuming they are interchangeable.
A clean status only means the working representation is acceptable under
the current Git configuration. It does not promise that every operating
system, archive utility, container mount, or deployment target sees the
same kind of object.

We later added a platform-specific setup note and a Linux CI test that
verified the path with test -L. The mystery never returned.
More importantly, when another link failed months later, nobody began by
reinstalling Docker. We checked mode 120000, read the target,
inspected core.symlinks, and solved the case before the first
coffee went cold.

Conclusion: Follow the Mode, Not the Alibi

Troubleshooting a Git symlink becomes much easier once you stop treating
the path as a single object. The commit records intent, the index records
the next snapshot, and the filesystem supplies a platform-dependent
working representation. Mode 120000, the stored target
string, core.symlinks, and the actual working-tree type form
the essential evidence.

Check those facts in order and most “impossible” symlink bugs become
ordinary mismatches involving paths, file types, permissions, case
sensitivity, or runtime boundaries. Git was keeping the records all
along. It was simply waiting for a detective who knew which ledger to
subpoena.

SEO Metadata

This site uses cookies to offer you a better browsing experience. By browsing this website, you agree to our use of cookies.