Skip to content

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

@dmcilvaney

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