microsoft / microsoft/azure-linux-dev-tools
`component render` silently drops synthetic history when the project repo path traverses a symlink
- Dominant language
- Go
- Stars
- 18
- Forks
- 27
- Avg merge
- 4d 33m
- Merged PRs (30d)
- 24
Description
`azldev component render ` 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
```bash
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:
```spec
* Wed May 13 2026 Unknown User - 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:
```bash
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:
```go
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 ` —
`Uncommitted changes` line in the rendered output actually comes from.
The author string is the giveaway: azldev's own `BuildDirtyChange` would
produce `azldev ` with the message `"Local changes (uncommitted)"`.
Anything authored as `Unknown User `
is rpmautospec's fallback when it sees a dirty worktree but no configured
git user inside the mock chroot.
### Minimal Go reproducer
```go
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):**
```go
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
Contributor guide
Research direction
Start in internal/app/azldev/core/sources/synthistory.go with openProjectRepo, buildSyntheticCommits, and readLockFileAtHEAD; compare the repository root with the config-derived path in internal/projectconfig/project.go. Then inspect trySyntheticHistory in sourceprep.go and run the symlink reproduction. Done means the symlinked invocation preserves the three synthetic changelog entries and release_number = 3 without the bogus uncommitted entry.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- git, go
- Domain
- cli, devtools
- Issue type
- Bug
- Difficulty
- 4/5
- Estimated time
- 3-5 days
- Activity status
- Quiet
- Clarity
- Clearly specified
- Newbie friendliness
- 55/100