fix: NixOS symlink compatibility for mtime and permission checks (#459)
* fix: use os.Lstat for symlink-safe mtime and permission checks On NixOS and other systems using symlinks heavily (e.g., home-manager), os.Stat follows symlinks and returns the target's metadata. This causes: 1. False staleness detection when JSONL is symlinked - mtime of target changes unpredictably when symlinks are recreated 2. os.Chmod failing or changing wrong file's permissions when target is in read-only location (e.g., /nix/store) 3. os.Chtimes modifying target's times instead of the symlink itself Changes: - autoimport.go: Use Lstat for JSONL mtime in CheckStaleness() - import.go: Use Lstat in TouchDatabaseFile() for JSONL mtime - export.go: Skip chmod for symlinked files - multirepo.go: Use Lstat for JSONL mtime cache - multirepo_export.go: Use Lstat for mtime, skip chmod for symlinks - doctor/fix/permissions.go: Skip permission fixes for symlinked paths These changes are safe cross-platform: - On systems without symlinks, Lstat behaves identically to Stat - Symlink permission bits are ignored on Unix anyway - The extra Lstat syscall overhead is negligible Fixes symlink-related data loss on NixOS. See GitHub issue #379. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * test: add symlink behavior tests for NixOS compatibility Add tests that verify symlink handling behavior: - TestCheckStaleness_SymlinkedJSONL: verifies mtime detection uses symlink's own mtime (os.Lstat), not target's mtime (os.Stat) - TestPermissions_SkipsSymlinkedBeadsDir: verifies chmod is skipped for symlinked .beads directories - TestPermissions_SkipsSymlinkedDatabase: verifies chmod is skipped for symlinked database files while still fixing .beads dir perms Also adds devShell to flake.nix for local development with go, gopls, golangci-lint, and sqlite tools. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> --------- Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -71,7 +71,8 @@ func (s *SQLiteStorage) hydrateFromRepo(ctx context.Context, repoPath, sourceRep
|
||||
jsonlPath := filepath.Join(absRepoPath, ".beads", "issues.jsonl")
|
||||
|
||||
// Check if file exists
|
||||
fileInfo, err := os.Stat(jsonlPath)
|
||||
// Use Lstat to get the symlink's own mtime, not the target's (NixOS fix).
|
||||
fileInfo, err := os.Lstat(jsonlPath)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
// No JSONL file - skip this repo
|
||||
|
||||
@@ -162,13 +162,18 @@ func (s *SQLiteStorage) exportToRepo(ctx context.Context, repoPath string, issue
|
||||
}
|
||||
|
||||
// Set file permissions
|
||||
if err := os.Chmod(jsonlPath, 0644); err != nil { // nolint:gosec // G302: 0644 intentional for git-tracked files
|
||||
// Non-fatal
|
||||
debug.Logf("Debug: failed to set permissions on %s: %v\n", jsonlPath, err)
|
||||
// Skip chmod for symlinks - os.Chmod follows symlinks and would change the target's
|
||||
// permissions, which may be in a read-only location (e.g., /nix/store on NixOS).
|
||||
if info, statErr := os.Lstat(jsonlPath); statErr == nil && info.Mode()&os.ModeSymlink == 0 {
|
||||
if err := os.Chmod(jsonlPath, 0644); err != nil { // nolint:gosec // G302: 0644 intentional for git-tracked files
|
||||
// Non-fatal
|
||||
debug.Logf("Debug: failed to set permissions on %s: %v\n", jsonlPath, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Update mtime cache for this repo
|
||||
fileInfo, err := os.Stat(jsonlPath)
|
||||
// Use Lstat to get the symlink's own mtime, not the target's (NixOS fix).
|
||||
fileInfo, err := os.Lstat(jsonlPath)
|
||||
if err == nil {
|
||||
_, err = s.db.ExecContext(ctx, `
|
||||
INSERT OR REPLACE INTO repo_mtimes (repo_path, jsonl_path, mtime_ns, last_checked)
|
||||
|
||||
Reference in New Issue
Block a user