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.go — openProjectRepo,
buildSyntheticCommits, readLockFileAtHEAD
internal/app/azldev/core/sources/sourceprep.go — trySyntheticHistory
(early exit when no changes)
internal/projectconfig/project.go — makeAbsolute produces the
unresolved absolute path used by the config
azldev component render <component>emits a bogusUnknown User"Uncommittedchanges" entry and regresses
release_numberwhenever the working tree isreached through a symlink (e.g.
/home/user/repos → /data/repos). Realproject-history entries are silently dropped.
Steps to reproduce
Expected
Three synthetic changelog entries and
release_number = 3, identical to theoutput produced when invoked from the canonical (resolved) path.
Actual
A single uncommitted change to
specs/r/rust/rust.spec. Diff contains:release_numberregresses from 3 to 2, dropping two real project-historyentries (
fix(rust): use target platform for compiler-rt runtimesandbuild: disable mingw subpackages via spec overlays).git statusisotherwise clean and
locks/rust.lockis unmodified.Workaround
Invoke
azldevfrom the resolved path so go-git and the project config agree:cd /data/repos/azurelinux azldev component render rust --verboseRoot cause
In
openProjectRepo, the project repo directory is obtained via go-git'sworktree filesystem root, which resolves symlinks:
But the absolute lock file path comes from the loaded project config
(
makeAbsoluteonconfig.Project.LockDir), which uses the unresolvedinvocation path:
buildSyntheticCommitsthen computes a repo-relative path:That path doesn't exist inside the git tree.
readLockFileAtHEADcatchesobject.ErrFileNotFound/ErrDirectoryNotFoundas "no committed lock —skip" and returns
(nil, nil), sobuildSyntheticCommitsreturns(nil, "", nil)with only a debug log:With zero fingerprint changes:
trySyntheticHistoryexits early.tryBumpStaticReleaseis never calledand no synthetic commits land on the cloned Fedora dist-git worktree.
BuildDirtyChange) is also bypassed — it runs onlyafter the
len(fpChanges) == 0short-circuit.The overlay-modified spec then sits in the staging worktree as plain
uncommitted changes on top of Fedora's HEAD commit.
mockrunsrpmautospecagainst that tree and rpmautospec emits its own dirty-changelog entry — that
is where the
Unknown User <please-configure-git-user@example.com>—Uncommitted changesline in the rendered output actually comes from.The author string is the giveaway: azldev's own
BuildDirtyChangewouldproduce
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
Suggested fix
Make
openProjectRepoand 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):
…and
EvalSymlinksthelockFileAbsPathas well before thefilepath.Rel,so both sides are canonicalised.
Option 2 — canonicalize only inside
buildSyntheticCommitsbeforecomputing the relative path, leaving
openProjectRepo's return valueunchanged.
Option 1 is preferable because every other caller of
openProjectRepoisexposed 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, buta missing parent directory when the configured
LockDiris non-empty isalmost always a misconfiguration. Log it at WARN (not DEBUG), and ideally
verify that the computed
lockFileRelPathactually lives under the repoworktree before attempting the
git show.Affected code
internal/app/azldev/core/sources/synthistory.go—openProjectRepo,buildSyntheticCommits,readLockFileAtHEADinternal/app/azldev/core/sources/sourceprep.go—trySyntheticHistory(early exit when no changes)
internal/projectconfig/project.go—makeAbsoluteproduces theunresolved absolute path used by the config