diff --git a/README.md b/README.md index f781522..021b179 100644 --- a/README.md +++ b/README.md @@ -235,7 +235,7 @@ agentlink --verbose # detailed output for any command (or -v) When a target path already contains a real file (not a symlink), agentlink stops and reports the conflict with the file size and last-modified date. It never silently overwrites your files. Options: -- `--backup` backs up the existing file to `.bak` (or `..bak` if `.bak` already exists), then creates the symlink. +- `--backup` backs up the existing file to `.bak` (or `..bak`, with a numeric suffix for further collisions), then creates the symlink. Backups use an exclusive hard link so an existing backup is never overwritten. On filesystems without hard-link support, sync stops and leaves the original unchanged. - `--force` replaces a regular file without backup. Use when you've already inspected or don't care about the existing content. - `--dry-run` is a hard preview mode. It does not create symlinks, remove files, fix broken links, or write backups, even when combined with `--backup` or `--force`. - Neither flag: agentlink reports the conflict and skips the file. diff --git a/integration_test.go b/integration_test.go index b97ee26..33fa2ff 100644 --- a/integration_test.go +++ b/integration_test.go @@ -137,6 +137,54 @@ func TestIntegrationBasicWorkflow(t *testing.T) { } } +func TestIntegrationSyncForceCannotReplaceSourceWithSelfLink(t *testing.T) { + if testing.Short() { + t.Skip("Skipping integration test in short mode") + } + + tmpDir := t.TempDir() + origDir, err := os.Getwd() + if err != nil { + t.Fatal(err) + } + binaryPath := filepath.Join(origDir, "agentlink") + if _, err := os.Stat(binaryPath); err != nil { + t.Fatalf("Binary not found at %s. Make sure to run 'go build -o agentlink ./cmd/agentlink' first", binaryPath) + } + + sourcePath := filepath.Join(tmpDir, "AGENTS.md") + const sourceContent = "# irreplaceable source\n" + if err := os.WriteFile(sourcePath, []byte(sourceContent), 0644); err != nil { + t.Fatal(err) + } + configContent := "source: AGENTS.md\nlinks:\n - ./nested/../AGENTS.md\n" + if err := os.WriteFile(filepath.Join(tmpDir, ".agentlink.yaml"), []byte(configContent), 0644); err != nil { + t.Fatal(err) + } + + cmd := exec.Command(binaryPath, "sync", "--force") + cmd.Dir = tmpDir + output, err := cmd.CombinedOutput() + if err == nil { + t.Fatalf("sync --force accepted source as its own link:\n%s", output) + } + + info, err := os.Lstat(sourcePath) + if err != nil { + t.Fatalf("source was removed: %v\nOutput: %s", err, output) + } + if info.Mode()&os.ModeSymlink != 0 { + t.Fatalf("source was replaced with a symlink\nOutput: %s", output) + } + data, err := os.ReadFile(sourcePath) + if err != nil { + t.Fatal(err) + } + if string(data) != sourceContent { + t.Fatalf("source content changed to %q", data) + } +} + func TestIntegrationDoctorCommand(t *testing.T) { if testing.Short() { t.Skip("Skipping integration test in short mode") diff --git a/internal/cli/detect.go b/internal/cli/detect.go index ced1bfc..9e6f26f 100644 --- a/internal/cli/detect.go +++ b/internal/cli/detect.go @@ -87,18 +87,11 @@ func runDetect(cmd *cobra.Command, args []string) error { func generateConfig(detected []registry.Detected) error { configPath := ".agentlink.yaml" - if _, err := os.Stat(configPath); err == nil { - if !force { - printError(".agentlink.yaml already exists (use --force to overwrite)") - return fmt.Errorf("config file already exists") - } - } - // Build link list from detected tools var links []string seen := make(map[string]bool) - // Always include AGENTS.md as a link target + // AGENTS.md is the source, so never include it as its own link target. seen["AGENTS.md"] = true for _, d := range detected { @@ -110,8 +103,24 @@ func generateConfig(detected []registry.Detected) error { } } + if len(links) == 0 { + if len(detected) == 0 { + printInfo("No supported tools detected; no config generated") + } else { + printInfo("Detected tools read AGENTS.md directly; no config is needed") + } + return nil + } + + if _, err := os.Stat(configPath); err == nil { + if !force { + printError(".agentlink.yaml already exists (use --force to overwrite)") + return fmt.Errorf("config file already exists") + } + } + if dryRun { - printInfo("Would generate .agentlink.yaml with %d links", len(links)+1) + printInfo("Would generate .agentlink.yaml with %d links", len(links)) return nil } diff --git a/internal/cli/detect_test.go b/internal/cli/detect_test.go new file mode 100644 index 0000000..3169594 --- /dev/null +++ b/internal/cli/detect_test.go @@ -0,0 +1,110 @@ +package cli + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/martinmose/agentlink/internal/config" + "github.com/martinmose/agentlink/internal/registry" +) + +func TestGenerateConfigNativeToolsIsSuccessfulNoOp(t *testing.T) { + withDetectTestDir(t) + setDetectTestFlags(t) + + detected := []registry.Detected{{Tool: registry.Tool{Name: "Native", RepoFileName: "AGENTS.md", ReadsAgentsMD: true}}} + stdout, _ := captureOutput(t, func() { + if err := generateConfig(detected); err != nil { + t.Fatalf("generateConfig() error = %v, want nil", err) + } + }) + if !strings.Contains(stdout, "Detected tools read AGENTS.md directly; no config is needed") { + t.Fatalf("generateConfig() output = %q, want native-tool no-op message", stdout) + } + if _, statErr := os.Stat(".agentlink.yaml"); !os.IsNotExist(statErr) { + t.Fatalf(".agentlink.yaml exists after no-op generation; stat error = %v", statErr) + } +} + +func TestGenerateConfigNoDetectedToolsIsSuccessfulNoOp(t *testing.T) { + withDetectTestDir(t) + setDetectTestFlags(t) + + stdout, _ := captureOutput(t, func() { + if err := generateConfig(nil); err != nil { + t.Fatalf("generateConfig(nil) error = %v, want nil", err) + } + }) + if !strings.Contains(stdout, "No supported tools detected; no config generated") { + t.Fatalf("generateConfig(nil) output = %q, want no-tools no-op message", stdout) + } + if _, statErr := os.Stat(".agentlink.yaml"); !os.IsNotExist(statErr) { + t.Fatalf(".agentlink.yaml exists after no-op generation; stat error = %v", statErr) + } +} + +func TestGenerateConfigEmptyLinkSetDoesNotOverwriteExistingConfigWithForce(t *testing.T) { + withDetectTestDir(t) + setDetectTestFlags(t) + force = true + const existing = "source: README.md\nlinks:\n - AGENTS.md\n" + if err := os.WriteFile(".agentlink.yaml", []byte(existing), 0644); err != nil { + t.Fatal(err) + } + + if err := generateConfig(nil); err != nil { + t.Fatalf("generateConfig(nil) error = %v, want nil", err) + } + got, err := os.ReadFile(".agentlink.yaml") + if err != nil { + t.Fatal(err) + } + if string(got) != existing { + t.Fatalf("existing config was modified: got %q, want %q", got, existing) + } +} + +func TestGenerateConfigProducesLoadableConfig(t *testing.T) { + dir := withDetectTestDir(t) + setDetectTestFlags(t) + + detected := []registry.Detected{ + {Tool: registry.Tool{Name: "Native", RepoFileName: "AGENTS.md", ReadsAgentsMD: true}}, + {Tool: registry.Tool{Name: "Claude", RepoFileName: "CLAUDE.md"}}, + {Tool: registry.Tool{Name: "Claude duplicate", RepoFileName: "CLAUDE.md"}}, + } + if err := generateConfig(detected); err != nil { + t.Fatalf("generateConfig() error = %v", err) + } + + cfg, err := config.LoadConfig(filepath.Join(dir, ".agentlink.yaml")) + if err != nil { + t.Fatalf("generated config is not loadable: %v", err) + } + if len(cfg.Links) != 1 || filepath.Base(cfg.Links[0]) != "CLAUDE.md" { + t.Fatalf("generated links = %v, want one CLAUDE.md link", cfg.Links) + } +} + +func withDetectTestDir(t *testing.T) string { + t.Helper() + oldDir, err := os.Getwd() + if err != nil { + t.Fatal(err) + } + dir := t.TempDir() + if err := os.Chdir(dir); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = os.Chdir(oldDir) }) + return dir +} + +func setDetectTestFlags(t *testing.T) { + t.Helper() + oldDryRun, oldForce := dryRun, force + dryRun, force = false, false + t.Cleanup(func() { dryRun, force = oldDryRun, oldForce }) +} diff --git a/internal/cli/hooks.go b/internal/cli/hooks.go index b116b05..214a427 100644 --- a/internal/cli/hooks.go +++ b/internal/cli/hooks.go @@ -468,7 +468,7 @@ func fileContainsAgentlink(path string) bool { func appendOrCreateHook(path, content string) error { // If file exists and already has our marker, skip if fileContainsAgentlink(path) { - return nil + return ensureUserExecutable(path) } // If file doesn't exist, create with shebang @@ -477,7 +477,7 @@ func appendOrCreateHook(path, content string) error { if err := os.WriteFile(path, []byte(full), 0755); err != nil { return err } - return nil + return ensureUserExecutable(path) } // Append to existing hook @@ -487,8 +487,21 @@ func appendOrCreateHook(path, content string) error { } defer f.Close() - _, err = f.WriteString("\n" + content) - return err + if _, err = f.WriteString("\n" + content); err != nil { + return err + } + if err := f.Close(); err != nil { + return err + } + return ensureUserExecutable(path) +} + +func ensureUserExecutable(path string) error { + info, err := os.Stat(path) + if err != nil { + return err + } + return os.Chmod(path, info.Mode().Perm()|0100) } func removeMarkedSection(path string) (bool, error) { diff --git a/internal/cli/hooks_test.go b/internal/cli/hooks_test.go index 118ad72..d489bd1 100644 --- a/internal/cli/hooks_test.go +++ b/internal/cli/hooks_test.go @@ -116,6 +116,39 @@ func TestInstallGitHooksRejectsRelativeHooksPath(t *testing.T) { } } +func TestAppendOrCreateHookMakesExistingHookExecutable(t *testing.T) { + hookPath := filepath.Join(t.TempDir(), "post-merge") + if err := os.WriteFile(hookPath, []byte("#!/bin/sh\necho existing\n"), 0644); err != nil { + t.Fatal(err) + } + if err := appendOrCreateHook(hookPath, gitHookContent("/tmp/agentlink")); err != nil { + t.Fatalf("appendOrCreateHook() failed: %v", err) + } + assertUserExecutable(t, hookPath) +} + +func TestAppendOrCreateHookRepairsModeWhenAlreadyInstalled(t *testing.T) { + hookPath := filepath.Join(t.TempDir(), "post-merge") + if err := os.WriteFile(hookPath, []byte("#!/bin/sh\n"+gitHookContent("/tmp/agentlink")), 0644); err != nil { + t.Fatal(err) + } + if err := appendOrCreateHook(hookPath, gitHookContent("/tmp/agentlink")); err != nil { + t.Fatalf("appendOrCreateHook() failed: %v", err) + } + assertUserExecutable(t, hookPath) +} + +func assertUserExecutable(t *testing.T, path string) { + t.Helper() + info, err := os.Stat(path) + if err != nil { + t.Fatal(err) + } + if info.Mode().Perm()&0100 == 0 { + t.Fatalf("mode = %v, want user-executable", info.Mode().Perm()) + } +} + func parseLaunchdProgramArguments(content string) ([]string, error) { decoder := xml.NewDecoder(strings.NewReader(content)) var lastKey string diff --git a/internal/cli/scan.go b/internal/cli/scan.go index 0638aee..3494dfa 100644 --- a/internal/cli/scan.go +++ b/internal/cli/scan.go @@ -133,6 +133,10 @@ func runScan(cmd *cobra.Command, args []string) error { printInfo("Dry run - no changes made") } + if errors > 0 { + return fmt.Errorf("scan completed with %d link error(s)", errors) + } + return nil } diff --git a/internal/cli/scan_test.go b/internal/cli/scan_test.go index 4f82c9d..d241b49 100644 --- a/internal/cli/scan_test.go +++ b/internal/cli/scan_test.go @@ -6,6 +6,39 @@ import ( "testing" ) +func TestRunScanReturnsErrorWhenLinkCannotBeCreated(t *testing.T) { + root := t.TempDir() + repo := filepath.Join(root, "repo") + if err := os.MkdirAll(filepath.Join(repo, ".git"), 0755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(repo, "AGENTS.md"), []byte("instructions"), 0644); err != nil { + t.Fatal(err) + } + + targets := repoLinkTargets() + if len(targets) == 0 { + t.Fatal("registry has no repo link targets to exercise") + } + if err := os.WriteFile(filepath.Join(repo, targets[0]), []byte("conflict"), 0644); err != nil { + t.Fatal(err) + } + + oldDryRun, oldForce, oldVerbose, oldScanDir := dryRun, force, verbose, scanDir + t.Cleanup(func() { + dryRun, force, verbose, scanDir = oldDryRun, oldForce, oldVerbose, oldScanDir + }) + dryRun, force, verbose, scanDir = false, false, false, "" + + err := runScan(scanCmd, []string{root}) + if err == nil { + t.Fatal("runScan() error = nil, want aggregate link error") + } + if got := err.Error(); got != "scan completed with 1 link error(s)" { + t.Fatalf("runScan() error = %q, want aggregate error count", got) + } +} + func TestFindGitReposDetectsStandardRepo(t *testing.T) { root := t.TempDir() repo := filepath.Join(root, "repo") diff --git a/internal/cli/sync.go b/internal/cli/sync.go index f228e41..cda0cc5 100644 --- a/internal/cli/sync.go +++ b/internal/cli/sync.go @@ -13,6 +13,7 @@ import ( var ( syncBackup bool + backupNow = time.Now ) var syncCmd = &cobra.Command{ @@ -181,17 +182,28 @@ func processLink(manager *symlink.Manager, linkPath, sourcePath string) error { func backupFile(path string) error { bakPath := path + ".bak" + timestamped := fmt.Sprintf("%s.%s.bak", path, backupNow().Format("20060102-150405")) + for suffix := -1; ; suffix++ { + if suffix == 0 { + bakPath = timestamped + } else if suffix > 0 { + bakPath = fmt.Sprintf("%s.%d", timestamped, suffix) + } - // If .bak already exists, use timestamped name - if _, err := os.Stat(bakPath); err == nil { - ts := time.Now().Format("20060102-150405") - bakPath = fmt.Sprintf("%s.%s.bak", path, ts) - } + // Link creates the destination exclusively. Unlike a check followed by + // Rename, this cannot overwrite a backup created concurrently. + if err := os.Link(path, bakPath); err != nil { + if os.IsExist(err) { + continue + } + return fmt.Errorf("failed to create no-clobber backup %s from %s (the filesystem may not support hard links; original left unchanged): %w", bakPath, path, err) + } + if err := os.Remove(path); err != nil { + _ = os.Remove(bakPath) + return fmt.Errorf("failed to remove %s after backing up to %s: %w", path, bakPath, err) + } - if err := os.Rename(path, bakPath); err != nil { - return fmt.Errorf("failed to back up %s to %s: %w", path, bakPath, err) + printInfo("Backed up %s -> %s", path, bakPath) + return nil } - - printInfo("Backed up %s -> %s", path, bakPath) - return nil } diff --git a/internal/cli/sync_test.go b/internal/cli/sync_test.go index 6f35659..735c45e 100644 --- a/internal/cli/sync_test.go +++ b/internal/cli/sync_test.go @@ -3,7 +3,9 @@ package cli import ( "os" "path/filepath" + "strings" "testing" + "time" "github.com/martinmose/agentlink/internal/symlink" ) @@ -44,3 +46,49 @@ func TestProcessLinkDryRunBackupDoesNotMutateExistingFile(t *testing.T) { t.Fatal("dry-run backup created a backup file") } } + +func TestBackupFileNeverOverwritesExistingBackups(t *testing.T) { + oldBackupNow := backupNow + backupNow = func() time.Time { return time.Date(2026, 7, 10, 12, 34, 56, 0, time.UTC) } + defer func() { backupNow = oldBackupNow }() + + tmpDir := t.TempDir() + path := filepath.Join(tmpDir, "AGENTS.md") + if err := os.WriteFile(path, []byte("current"), 0644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path+".bak", []byte("original backup"), 0644); err != nil { + t.Fatal(err) + } + + // Occupy the timestamped name. The implementation must choose a suffix + // instead of replacing it. + timestamped := path + ".20260710-123456.bak" + if err := os.WriteFile(timestamped, []byte("timestamped backup"), 0644); err != nil { + t.Fatal(err) + } + + if err := backupFile(path); err != nil { + t.Fatalf("backupFile() failed: %v", err) + } + data, err := os.ReadFile(path + ".bak") + if err != nil || string(data) != "original backup" { + t.Fatalf("original backup changed: data=%q err=%v", data, err) + } + entries, err := os.ReadDir(tmpDir) + if err != nil { + t.Fatal(err) + } + foundCurrent := false + for _, entry := range entries { + if strings.HasPrefix(entry.Name(), "AGENTS.md.") { + data, readErr := os.ReadFile(filepath.Join(tmpDir, entry.Name())) + if readErr == nil && string(data) == "current" { + foundCurrent = true + } + } + } + if !foundCurrent { + t.Fatal("new backup content was not preserved under a unique name") + } +} diff --git a/internal/config/config.go b/internal/config/config.go index 44f8c29..c6c4a02 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -37,6 +37,14 @@ func LoadConfig(path string) (*Config, error) { return nil, fmt.Errorf("failed to expand paths in %s: %w", path, err) } + // Validate again after expansion so path aliases are compared as normalized, + // absolute paths. This must happen before sync is allowed to touch the + // filesystem: with --force, a link that aliases the source would otherwise + // replace the source itself. + if err := config.Validate(); err != nil { + return nil, fmt.Errorf("invalid config in %s: %w", path, err) + } + return &config, nil } @@ -48,12 +56,23 @@ func (c *Config) Validate() error { if len(c.Links) == 0 { return fmt.Errorf("links cannot be empty") } + + source := filepath.Clean(c.Source) + for _, link := range c.Links { + if filepath.Clean(link) == source { + return fmt.Errorf("link path %q cannot be the same as source path %q", link, c.Source) + } + } return nil } // ExpandPaths expands ~ and makes relative paths absolute based on configDir func (c *Config) ExpandPaths(configDir string) error { - var err error + absoluteConfigDir, err := filepath.Abs(configDir) + if err != nil { + return fmt.Errorf("failed to resolve config directory: %w", err) + } + configDir = absoluteConfigDir // Expand source path c.Source, err = expandPath(c.Source, configDir) diff --git a/internal/config/config_test.go b/internal/config/config_test.go index c7c5947..5df6df8 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -3,6 +3,7 @@ package config import ( "os" "path/filepath" + "strings" "testing" ) @@ -73,6 +74,22 @@ func TestValidateConfig(t *testing.T) { }, wantErr: true, }, + { + name: "link is source", + config: Config{ + Source: "AGENTS.md", + Links: []string{"AGENTS.md"}, + }, + wantErr: true, + }, + { + name: "link is syntactic alias of source", + config: Config{ + Source: "docs/../AGENTS.md", + Links: []string{"./AGENTS.md"}, + }, + wantErr: true, + }, } for _, tt := range tests { @@ -85,6 +102,49 @@ func TestValidateConfig(t *testing.T) { } } +func TestLoadConfigRejectsSourceLinkAliases(t *testing.T) { + tmpDir := t.TempDir() + outsideName := filepath.Base(tmpDir) + + tests := []struct { + name string + source string + link string + }{ + {name: "identical relative paths", source: "AGENTS.md", link: "AGENTS.md"}, + {name: "dot segment", source: "AGENTS.md", link: "./AGENTS.md"}, + {name: "parent segment", source: "AGENTS.md", link: "nested/../AGENTS.md"}, + { + name: "relative and absolute paths", + source: "AGENTS.md", + link: filepath.Join(tmpDir, "AGENTS.md"), + }, + { + name: "config directory parent alias", + source: "AGENTS.md", + link: filepath.Join("..", outsideName, "AGENTS.md"), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + configPath := filepath.Join(tmpDir, strings.ReplaceAll(tt.name, " ", "-")+".yaml") + content := "source: " + tt.source + "\nlinks:\n - " + tt.link + "\n" + if err := os.WriteFile(configPath, []byte(content), 0644); err != nil { + t.Fatal(err) + } + + _, err := LoadConfig(configPath) + if err == nil { + t.Fatal("LoadConfig() accepted a link resolving to the source path") + } + if !strings.Contains(err.Error(), "cannot be the same as source path") { + t.Fatalf("LoadConfig() error = %q, want source/link identity error", err) + } + }) + } +} + func TestExpandPaths(t *testing.T) { homeDir, err := os.UserHomeDir() if err != nil { @@ -119,6 +179,23 @@ func TestExpandPaths(t *testing.T) { } } +func TestExpandPathsMakesPathsAbsoluteWithRelativeConfigDir(t *testing.T) { + config := Config{ + Source: "AGENTS.md", + Links: []string{"./nested/../CLAUDE.md"}, + } + + if err := config.ExpandPaths("."); err != nil { + t.Fatalf("ExpandPaths() failed: %v", err) + } + if !filepath.IsAbs(config.Source) { + t.Errorf("source is not absolute: %s", config.Source) + } + if !filepath.IsAbs(config.Links[0]) { + t.Errorf("link is not absolute: %s", config.Links[0]) + } +} + func TestFindConfigPath(t *testing.T) { // Save current directory origDir, err := os.Getwd() diff --git a/internal/symlink/manager.go b/internal/symlink/manager.go index cdf87e1..00c2d9e 100644 --- a/internal/symlink/manager.go +++ b/internal/symlink/manager.go @@ -186,6 +186,17 @@ func (m *Manager) RemoveLink(linkPath, expectedTarget string) error { // FixLink creates or fixes a symlink based on its current status func (m *Manager) FixLink(linkPath, targetPath string) (string, error) { + // Resolve directory symlinks immediately before inspecting or mutating the + // destination. Do not resolve the destination leaf: a correctly managed + // symlink is expected to resolve to targetPath and must remain valid. + samePath, err := sameEffectivePath(linkPath, targetPath) + if err != nil { + return "", fmt.Errorf("failed to validate link path %s: %w", linkPath, err) + } + if samePath { + return "", fmt.Errorf("link path %s resolves to source %s; refusing to replace source", linkPath, targetPath) + } + info := m.CheckLink(linkPath, targetPath) switch info.Status { @@ -255,3 +266,47 @@ func (m *Manager) FixLink(linkPath, targetPath string) (string, error) { return "", fmt.Errorf("unknown link status for %s", linkPath) } } + +// sameEffectivePath compares paths after resolving symlinks in their parent +// directories. The leaf is deliberately left unresolved so an existing, +// correct managed symlink is not mistaken for an alias of its source. +func sameEffectivePath(linkPath, targetPath string) (bool, error) { + link, err := resolveParentSymlinks(linkPath) + if err != nil { + return false, err + } + target, err := resolveParentSymlinks(targetPath) + if err != nil { + return false, err + } + return link == target, nil +} + +// resolveParentSymlinks also supports nonexistent parent directories by +// resolving the nearest existing ancestor, then restoring the missing suffix. +func resolveParentSymlinks(path string) (string, error) { + abs, err := filepath.Abs(path) + if err != nil { + return "", err + } + + parent := filepath.Dir(filepath.Clean(abs)) + suffix := []string{filepath.Base(abs)} + for { + resolved, err := filepath.EvalSymlinks(parent) + if err == nil { + parts := append([]string{resolved}, suffix...) + return filepath.Clean(filepath.Join(parts...)), nil + } + if !os.IsNotExist(err) { + return "", err + } + + next := filepath.Dir(parent) + if next == parent { + return "", err + } + suffix = append([]string{filepath.Base(parent)}, suffix...) + parent = next + } +} diff --git a/internal/symlink/manager_test.go b/internal/symlink/manager_test.go index eafc159..bbfebf9 100644 --- a/internal/symlink/manager_test.go +++ b/internal/symlink/manager_test.go @@ -359,3 +359,85 @@ func TestFixLinkForceRefusesDirectories(t *testing.T) { t.Fatalf("directory contents were not preserved, data=%q err=%v", data, err) } } + +func TestFixLinkRefusesDestinationAliasedToSourceByParentSymlink(t *testing.T) { + tmpDir := t.TempDir() + realDir := filepath.Join(tmpDir, "real") + if err := os.Mkdir(realDir, 0755); err != nil { + t.Fatal(err) + } + source := filepath.Join(realDir, "AGENTS.md") + if err := os.WriteFile(source, []byte("keep source"), 0644); err != nil { + t.Fatal(err) + } + aliasDir := filepath.Join(tmpDir, "alias") + if err := os.Symlink(realDir, aliasDir); err != nil { + t.Fatal(err) + } + alias := filepath.Join(aliasDir, "AGENTS.md") + + manager := NewManager(false, true, false) + if _, err := manager.FixLink(alias, source); err == nil { + t.Fatal("FixLink() replaced a source reached through a symlinked parent") + } + data, err := os.ReadFile(source) + if err != nil { + t.Fatal(err) + } + if string(data) != "keep source" { + t.Fatalf("source content changed to %q", data) + } + if info, err := os.Lstat(source); err != nil || !info.Mode().IsRegular() { + t.Fatalf("source is no longer a regular file: info=%v err=%v", info, err) + } +} + +func TestFixLinkAllowsCorrectManagedSymlinkResolvingToSource(t *testing.T) { + tmpDir := t.TempDir() + source := filepath.Join(tmpDir, "AGENTS.md") + link := filepath.Join(tmpDir, "CLAUDE.md") + if err := os.WriteFile(source, []byte("source"), 0644); err != nil { + t.Fatal(err) + } + if err := os.Symlink("AGENTS.md", link); err != nil { + t.Fatal(err) + } + + action, err := NewManager(false, true, false).FixLink(link, source) + if err != nil { + t.Fatalf("FixLink() rejected correct managed symlink: %v", err) + } + if action != "skip" { + t.Fatalf("FixLink() action = %q, want skip", action) + } +} + +func TestFixLinkHardlinkRequiresForceButCanBeReplaced(t *testing.T) { + tmpDir := t.TempDir() + source := filepath.Join(tmpDir, "AGENTS.md") + link := filepath.Join(tmpDir, "CLAUDE.md") + if err := os.WriteFile(source, []byte("source"), 0644); err != nil { + t.Fatal(err) + } + if err := os.Link(source, link); err != nil { + t.Skipf("hardlinks unavailable: %v", err) + } + + if _, err := NewManager(false, false, false).FixLink(link, source); err == nil { + t.Fatal("FixLink() replaced hardlink without force") + } + action, err := NewManager(false, true, false).FixLink(link, source) + if err != nil { + t.Fatalf("FixLink() failed to replace hardlink with force: %v", err) + } + if action != "replace" { + t.Fatalf("FixLink() action = %q, want replace", action) + } + if got := NewManager(false, false, false).CheckLink(link, source).Status; got != StatusOK { + t.Fatalf("replacement link status = %v, want %v", got, StatusOK) + } + data, err := os.ReadFile(source) + if err != nil || string(data) != "source" { + t.Fatalf("source was not preserved: data=%q err=%v", data, err) + } +}