Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<name>.bak` (or `<name>.<timestamp>.bak` if `.bak` already exists), then creates the symlink.
- `--backup` backs up the existing file to `<name>.bak` (or `<name>.<timestamp>.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.
Expand Down
48 changes: 48 additions & 0 deletions integration_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
27 changes: 18 additions & 9 deletions internal/cli/detect.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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
}

Expand Down
110 changes: 110 additions & 0 deletions internal/cli/detect_test.go
Original file line number Diff line number Diff line change
@@ -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 })
}
21 changes: 17 additions & 4 deletions internal/cli/hooks.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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) {
Expand Down
33 changes: 33 additions & 0 deletions internal/cli/hooks_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions internal/cli/scan.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down
33 changes: 33 additions & 0 deletions internal/cli/scan_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
Loading
Loading