microsoft / microsoft/azure-linux-dev-tools

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

Open
#188 0 comments 0 reactions 0 assignees View on GitHub
area: changelog bug revisit
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

Open the contributing 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

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.