diff --git a/README.md b/README.md index 20ddbd6..600371a 100644 --- a/README.md +++ b/README.md @@ -44,6 +44,11 @@ lnk init This creates `$XDG_CONFIG_HOME/lnk` (or `~/.config/lnk`) and initializes a Git repository with `main` as the default branch. +**Safety Features:** +- **Idempotent**: Running `lnk init` multiple times is safe and won't break existing repositories +- **Repository Protection**: Won't overwrite existing non-lnk Git repositories (exits with error) +- **Fresh Repository Detection**: Automatically detects if a directory contains an existing repository + ### Initialize with remote ```bash @@ -54,6 +59,11 @@ lnk init -r git@github.com:user/dotfiles.git This initializes the repository with `main` as the default branch and adds the specified URL as the `origin` remote, allowing you to sync your dotfiles with a Git hosting service. +**Remote Handling:** +- **Idempotent**: Adding the same remote URL multiple times is safe (no-op) +- **Conflict Detection**: Adding different remote URLs fails with clear error message +- **Existing Remote Support**: Works safely with repositories that already have remotes configured + ### Add a file ```bash @@ -87,6 +97,12 @@ lnk init # Initialize with remote for syncing with GitHub lnk init --remote https://github.com/user/dotfiles.git +# Running init again is safe (idempotent) +lnk init # No error, no changes + +# Adding same remote again is safe +lnk init -r https://github.com/user/dotfiles.git # No error, no changes + # Add some dotfiles lnk add ~/.bashrc lnk add ~/.vimrc @@ -103,11 +119,27 @@ git log --oneline git push origin main ``` +### Safety Examples + +```bash +# Attempting to init over existing non-lnk repository +mkdir ~/.config/lnk && cd ~/.config/lnk +git init && echo "important" > file.txt && git add . && git commit -m "important data" +cd ~ +lnk init # ERROR: Won't overwrite existing repository + +# Attempting to add conflicting remote +lnk init -r https://github.com/user/repo1.git +lnk init -r https://github.com/user/repo2.git # ERROR: Different URL conflict +``` + ## Error Handling - Adding a nonexistent file: exits with error - Adding a directory: exits with "directories are not supported" - Removing a non-symlink: exits with "file is not managed by lnk" +- **Repository conflicts**: `lnk init` protects existing non-lnk repositories from accidental overwrite +- **Remote conflicts**: Adding different remote URLs to existing remotes fails with descriptive error - Git operations show stderr output on failure ## Development diff --git a/internal/core/lnk.go b/internal/core/lnk.go index 797a9d1..72e9442 100644 --- a/internal/core/lnk.go +++ b/internal/core/lnk.go @@ -48,7 +48,19 @@ func (l *Lnk) Init() error { return fmt.Errorf("failed to create lnk directory: %w", err) } - // Initialize Git repository + // Check if there's already a Git repository + if l.git.IsGitRepository() { + // Repository exists, check if it's a lnk repository + if l.git.IsLnkRepository() { + // It's a lnk repository, init is idempotent - do nothing + return nil + } else { + // It's not a lnk repository, error to prevent data loss + return fmt.Errorf("directory %s appears to contain an existing Git repository that is not managed by lnk. Please backup or move the existing repository before initializing lnk", l.repoPath) + } + } + + // No existing repository, initialize Git repository if err := l.git.Init(); err != nil { return fmt.Errorf("failed to initialize git repository: %w", err) } diff --git a/internal/git/git.go b/internal/git/git.go index b87d3dc..2220a35 100644 --- a/internal/git/git.go +++ b/internal/git/git.go @@ -26,13 +26,13 @@ func (g *Git) Init() error { cmd := exec.Command("git", "init", "-b", "main") cmd.Dir = g.repoPath - output, err := cmd.CombinedOutput() + _, err := cmd.CombinedOutput() if err != nil { // Fallback to regular init + branch rename for older Git versions cmd = exec.Command("git", "init") cmd.Dir = g.repoPath - output, err = cmd.CombinedOutput() + output, err := cmd.CombinedOutput() if err != nil { return fmt.Errorf("git init failed: %w\nOutput: %s", err, string(output)) } @@ -51,6 +51,19 @@ func (g *Git) Init() error { // AddRemote adds a remote to the repository func (g *Git) AddRemote(name, url string) error { + // Check if remote already exists + existingURL, err := g.getRemoteURL(name) + if err == nil { + // Remote exists, check if URL matches + if existingURL == url { + // Same URL, idempotent - do nothing + return nil + } + // Different URL, error + return fmt.Errorf("remote %s already exists with different URL: %s (trying to add: %s)", name, existingURL, url) + } + + // Remote doesn't exist, add it cmd := exec.Command("git", "remote", "add", name, url) cmd.Dir = g.repoPath @@ -62,6 +75,58 @@ func (g *Git) AddRemote(name, url string) error { return nil } +// getRemoteURL returns the URL for a remote, or error if not found +func (g *Git) getRemoteURL(name string) (string, error) { + cmd := exec.Command("git", "remote", "get-url", name) + cmd.Dir = g.repoPath + + output, err := cmd.Output() + if err != nil { + return "", err + } + + return strings.TrimSpace(string(output)), nil +} + +// IsGitRepository checks if the directory contains a Git repository +func (g *Git) IsGitRepository() bool { + gitDir := filepath.Join(g.repoPath, ".git") + _, err := os.Stat(gitDir) + return err == nil +} + +// IsLnkRepository checks if the repository appears to be managed by lnk +func (g *Git) IsLnkRepository() bool { + if !g.IsGitRepository() { + return false + } + + // Check if this looks like a lnk repository + // We consider it a lnk repo if: + // 1. It has no commits (fresh repo), OR + // 2. All commits start with "lnk:" pattern + + commits, err := g.GetCommits() + if err != nil { + return false + } + + // If no commits, it's a fresh repo - could be lnk + if len(commits) == 0 { + return true + } + + // If all commits start with "lnk:", it's definitely ours + // If ANY commit doesn't start with "lnk:", it's probably not ours + for _, commit := range commits { + if !strings.HasPrefix(commit, "lnk:") { + return false + } + } + + return true +} + // AddAndCommit stages a file and commits it func (g *Git) AddAndCommit(filename, message string) error { // Stage the file @@ -176,10 +241,11 @@ func (g *Git) GetCommits() ([]string, error) { cmd := exec.Command("git", "log", "--oneline", "--format=%s") cmd.Dir = g.repoPath - output, err := cmd.Output() + output, err := cmd.CombinedOutput() if err != nil { // If there are no commits yet, return empty slice - if strings.Contains(string(output), "does not have any commits yet") { + outputStr := string(output) + if strings.Contains(outputStr, "does not have any commits yet") { return []string{}, nil } return nil, fmt.Errorf("git log failed: %w", err) diff --git a/test/integration_test.go b/test/integration_test.go index 9e02bea..e15445a 100644 --- a/test/integration_test.go +++ b/test/integration_test.go @@ -230,6 +230,116 @@ func (suite *LnkIntegrationTestSuite) TestInitWithRemote() { suite.Equal(remoteURL, strings.TrimSpace(string(output))) } +func (suite *LnkIntegrationTestSuite) TestInitIdempotent() { + // Test that running init multiple times is safe + err := suite.lnk.Init() + suite.Require().NoError(err) + + lnkDir := filepath.Join(suite.tempDir, "lnk") + + // Add a file to the repo to ensure it's not lost + testFile := filepath.Join(lnkDir, "test.txt") + err = os.WriteFile(testFile, []byte("test content"), 0644) + suite.Require().NoError(err) + + // Run init again - should be idempotent + err = suite.lnk.Init() + suite.Require().NoError(err) + + // File should still exist + suite.FileExists(testFile) + content, err := os.ReadFile(testFile) + suite.Require().NoError(err) + suite.Equal("test content", string(content)) +} + +func (suite *LnkIntegrationTestSuite) TestInitWithExistingRemote() { + // Test init with remote when remote already exists (same URL) + remoteURL := "https://github.com/user/dotfiles.git" + + // First init with remote + err := suite.lnk.Init() + suite.Require().NoError(err) + err = suite.lnk.AddRemote("origin", remoteURL) + suite.Require().NoError(err) + + // Init again with same remote should be idempotent + err = suite.lnk.Init() + suite.Require().NoError(err) + err = suite.lnk.AddRemote("origin", remoteURL) + suite.Require().NoError(err) + + // Verify remote is still correct + lnkDir := filepath.Join(suite.tempDir, "lnk") + cmd := exec.Command("git", "remote", "get-url", "origin") + cmd.Dir = lnkDir + output, err := cmd.Output() + suite.Require().NoError(err) + suite.Equal(remoteURL, strings.TrimSpace(string(output))) +} + +func (suite *LnkIntegrationTestSuite) TestInitWithDifferentRemote() { + // Test init with different remote when remote already exists + err := suite.lnk.Init() + suite.Require().NoError(err) + + // Add first remote + err = suite.lnk.AddRemote("origin", "https://github.com/user/dotfiles.git") + suite.Require().NoError(err) + + // Try to add different remote - should error + err = suite.lnk.AddRemote("origin", "https://github.com/user/other-repo.git") + suite.Error(err) + suite.Contains(err.Error(), "already exists with different URL") +} + +func (suite *LnkIntegrationTestSuite) TestInitWithNonLnkRepo() { + // Test init when directory contains a non-lnk Git repository + lnkDir := filepath.Join(suite.tempDir, "lnk") + err := os.MkdirAll(lnkDir, 0755) + suite.Require().NoError(err) + + // Create a non-lnk git repo in the lnk directory + cmd := exec.Command("git", "init") + cmd.Dir = lnkDir + err = cmd.Run() + suite.Require().NoError(err) + + // Add some content to make it look like a real repo + testFile := filepath.Join(lnkDir, "important-file.txt") + err = os.WriteFile(testFile, []byte("important data"), 0644) + suite.Require().NoError(err) + + // Configure git and commit + cmd = exec.Command("git", "config", "user.name", "Test User") + cmd.Dir = lnkDir + err = cmd.Run() + suite.Require().NoError(err) + + cmd = exec.Command("git", "config", "user.email", "test@example.com") + cmd.Dir = lnkDir + err = cmd.Run() + suite.Require().NoError(err) + + cmd = exec.Command("git", "add", "important-file.txt") + cmd.Dir = lnkDir + err = cmd.Run() + suite.Require().NoError(err) + + cmd = exec.Command("git", "commit", "-m", "important commit") + cmd.Dir = lnkDir + err = cmd.Run() + suite.Require().NoError(err) + + // Now try to init lnk - should error to protect existing repo + err = suite.lnk.Init() + suite.Error(err) + suite.Contains(err.Error(), "appears to contain an existing Git repository") + + // Verify the original file is still there + suite.FileExists(testFile) +} + func TestLnkIntegrationSuite(t *testing.T) { suite.Run(t, new(LnkIntegrationTestSuite)) }