Files
beads/internal/autoimport/symlink_test.go
Darin e0734e230f 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>
2025-12-05 13:22:09 -08:00

134 lines
4.3 KiB
Go

package autoimport
import (
"context"
"os"
"path/filepath"
"testing"
"time"
"github.com/steveyegge/beads/internal/storage/memory"
)
// TestCheckStaleness_SymlinkedJSONL verifies that mtime detection uses the symlink's
// own mtime, not the target's mtime. This is critical for NixOS and similar systems
// where files may be symlinked to read-only locations.
//
// Behavior being tested:
// - When JSONL is a symlink, CheckStaleness should use os.Lstat (symlink mtime)
// - NOT os.Stat (which would follow the symlink and get target's mtime)
func TestCheckStaleness_SymlinkedJSONL(t *testing.T) {
tmpDir := t.TempDir()
// Create the target JSONL file with old mtime
targetDir := filepath.Join(tmpDir, "target")
if err := os.MkdirAll(targetDir, 0755); err != nil {
t.Fatal(err)
}
targetPath := filepath.Join(targetDir, "issues.jsonl")
if err := os.WriteFile(targetPath, []byte(`{"id":"test-1"}`), 0644); err != nil {
t.Fatal(err)
}
// Set target's mtime to 1 hour ago
oldTime := time.Now().Add(-1 * time.Hour)
if err := os.Chtimes(targetPath, oldTime, oldTime); err != nil {
t.Fatal(err)
}
// Create the .beads directory structure with a symlink to the target
beadsDir := filepath.Join(tmpDir, ".beads")
if err := os.MkdirAll(beadsDir, 0755); err != nil {
t.Fatal(err)
}
symlinkPath := filepath.Join(beadsDir, "issues.jsonl")
if err := os.Symlink(targetPath, symlinkPath); err != nil {
t.Fatal(err)
}
// The symlink itself was just created (recent mtime)
// The target file has old mtime (1 hour ago)
// If we use os.Stat (follows symlink), we'd get the target's old mtime
// If we use os.Lstat (symlink's own mtime), we'd get the recent mtime
// Set last_import_time to 30 minutes ago (between target mtime and symlink mtime)
importTime := time.Now().Add(-30 * time.Minute)
store := memory.New("")
ctx := context.Background()
store.SetMetadata(ctx, "last_import_time", importTime.Format(time.RFC3339))
dbPath := filepath.Join(beadsDir, "beads.db")
// With correct behavior (os.Lstat):
// - Symlink mtime: now (just created)
// - Import time: 30 min ago
// - Result: stale = true (symlink is newer than import)
//
// With incorrect behavior (os.Stat):
// - Target mtime: 1 hour ago
// - Import time: 30 min ago
// - Result: stale = false (target is older than import) - WRONG!
stale, err := CheckStaleness(ctx, store, dbPath)
if err != nil {
t.Fatalf("CheckStaleness failed: %v", err)
}
if !stale {
t.Error("Expected stale=true when symlinked JSONL is newer than last import")
t.Error("This indicates os.Stat is being used instead of os.Lstat")
t.Error("os.Stat follows the symlink and returns target's mtime (old)")
t.Error("os.Lstat returns the symlink's own mtime (recent)")
}
}
// TestCheckStaleness_SymlinkedJSONL_NotStale verifies the inverse case:
// when the symlink itself is older than the last import, it should not be stale.
func TestCheckStaleness_SymlinkedJSONL_NotStale(t *testing.T) {
tmpDir := t.TempDir()
// Create target file
targetDir := filepath.Join(tmpDir, "target")
if err := os.MkdirAll(targetDir, 0755); err != nil {
t.Fatal(err)
}
targetPath := filepath.Join(targetDir, "issues.jsonl")
if err := os.WriteFile(targetPath, []byte(`{"id":"test-1"}`), 0644); err != nil {
t.Fatal(err)
}
// Create symlink
beadsDir := filepath.Join(tmpDir, ".beads")
if err := os.MkdirAll(beadsDir, 0755); err != nil {
t.Fatal(err)
}
symlinkPath := filepath.Join(beadsDir, "issues.jsonl")
if err := os.Symlink(targetPath, symlinkPath); err != nil {
t.Fatal(err)
}
// Set symlink's mtime to 1 hour ago
oldTime := time.Now().Add(-1 * time.Hour)
// Note: os.Chtimes follows symlinks, so we use os.Lchtimes if available
// On most systems, symlink mtime is set at creation and can't be changed
// So we'll set the import time to be in the future instead
_ = oldTime
// Set last_import_time to just now (after symlink creation)
importTime := time.Now().Add(1 * time.Second)
store := memory.New("")
ctx := context.Background()
store.SetMetadata(ctx, "last_import_time", importTime.Format(time.RFC3339))
dbPath := filepath.Join(beadsDir, "beads.db")
stale, err := CheckStaleness(ctx, store, dbPath)
if err != nil {
t.Fatalf("CheckStaleness failed: %v", err)
}
if stale {
t.Error("Expected stale=false when last import is after symlink creation")
}
}