Skip to content

component render silently drops synthetic history when the project repo path traverses a symlink #188

Description

@dmcilvaney

azldev component render <component> emits a bogus Unknown User "Uncommitted
changes" entry and regresses release_number whenever the working tree is
reached through a symlink (e.g. /home/user/repos → /data/repos). Real
project-history entries are silently dropped.

Steps to reproduce

ls -ld /home/user/repos                          # symlink to /data/repos
cd /home/user/repos/azurelinux                   # traverse the symlink
azldev component render rust --verbose 2>&1 | tee /tmp/render.log

grep -E "No lock file found at HEAD|No synthetic commits" /tmp/render.log
git -C /home/user/repos/azurelinux diff -- specs/r/rust/rust.spec | head

Expected

Three synthetic changelog entries and release_number = 3, identical to the
output produced when invoked from the canonical (resolved) path.

Actual

A single uncommitted change to specs/r/rust/rust.spec. Diff contains:

* Wed May 13 2026 Unknown User <please-configure-git-user@example.com> - 1.94.1-2
- Uncommitted changes

release_number regresses from 3 to 2, dropping two real project-history
entries (fix(rust): use target platform for compiler-rt runtimes and
build: disable mingw subpackages via spec overlays). git status is
otherwise clean and locks/rust.lock is unmodified.

Workaround

Invoke azldev from the resolved path so go-git and the project config agree:

cd /data/repos/azurelinux
azldev component render rust --verbose

Root cause

In openProjectRepo, the project repo directory is obtained via go-git's
worktree filesystem root, which resolves symlinks:

worktree.Filesystem.Root()  →  /data/repos/azurelinux

But the absolute lock file path comes from the loaded project config
(makeAbsolute on config.Project.LockDir), which uses the unresolved
invocation path:

lockFileAbsPath  →  /home/user/repos/azurelinux/locks/rust.lock

buildSyntheticCommits then computes a repo-relative path:

lockFileRelPath, _ := filepath.Rel(projectRepoDir, lockFileAbsPath)
// → "../../../home/user/repos/azurelinux/locks/rust.lock"

That path doesn't exist inside the git tree. readLockFileAtHEAD catches
object.ErrFileNotFound / ErrDirectoryNotFound as "no committed lock —
skip" and returns (nil, nil), so buildSyntheticCommits returns
(nil, "", nil) with only a debug log:

DBG No lock file found at HEAD; skipping synthetic history
    lockFile=../../../home/user/repos/azurelinux/locks/rust.lock
    reason="...at commit `9e4787709037aa8c1f7457bb2b00a038ab6b0f4b`: file not found"
DBG No synthetic commits to create; skipping history generation component=rust

With zero fingerprint changes:

  • trySyntheticHistory exits early. tryBumpStaticRelease is never called
    and no synthetic commits land on the cloned Fedora dist-git worktree.
  • Dirty detection (BuildDirtyChange) is also bypassed — it runs only
    after the len(fpChanges) == 0 short-circuit.

The overlay-modified spec then sits in the staging worktree as plain
uncommitted changes on top of Fedora's HEAD commit. mock runs rpmautospec
against that tree and rpmautospec emits its own dirty-changelog entry — that
is where the Unknown User <please-configure-git-user@example.com>
Uncommitted changes line in the rendered output actually comes from.

The author string is the giveaway: azldev's own BuildDirtyChange would
produce azldev <azldev@local> with the message "Local changes (uncommitted)".
Anything authored as Unknown User <please-configure-git-user@example.com>
is rpmautospec's fallback when it sees a dirty worktree but no configured
git user inside the mock chroot.

Minimal Go reproducer

r, _ := gogit.PlainOpenWithOptions(
    "/home/user/repos/azurelinux/base/comps/rust",
    &gogit.PlainOpenOptions{DetectDotGit: true, EnableDotGitCommonDir: true},
)
w, _ := r.Worktree()
root := w.Filesystem.Root()
// root → /data/repos/azurelinux

rel, _ := filepath.Rel(root, "/home/user/repos/azurelinux/locks/rust.lock")
// rel  → ../../../home/user/repos/azurelinux/locks/rust.lock

Suggested fix

Make openProjectRepo and the config-derived lock path symlink-consistent.
Two viable options, both in internal/app/azldev/core/sources/synthistory.go:

Option 1 — canonicalize the project repo dir on return (preferred):

root := worktree.Filesystem.Root()
if resolved, err := filepath.EvalSymlinks(root); err == nil {
    root = resolved
}
return repo, root, nil

…and EvalSymlinks the lockFileAbsPath as well before the filepath.Rel,
so both sides are canonicalised.

Option 2 — canonicalize only inside buildSyntheticCommits before
computing the relative path, leaving openProjectRepo's return value
unchanged.

Option 1 is preferable because every other caller of openProjectRepo is
exposed to the same hazard the moment it does any path arithmetic against
the returned directory.

Additional hardening

Consider tightening readLockFileAtHEAD: a missing lock file is normal, but
a missing parent directory when the configured LockDir is non-empty is
almost always a misconfiguration. Log it at WARN (not DEBUG), and ideally
verify that the computed lockFileRelPath actually lives under the repo
worktree before attempting the git show.

Affected code

  • internal/app/azldev/core/sources/synthistory.goopenProjectRepo,
    buildSyntheticCommits, readLockFileAtHEAD
  • internal/app/azldev/core/sources/sourceprep.gotrySyntheticHistory
    (early exit when no changes)
  • internal/projectconfig/project.gomakeAbsolute produces the
    unresolved absolute path used by the config

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions