From 43b68bc0711cba9862e619131261dcb0aba1f585 Mon Sep 17 00:00:00 2001 From: Yar Kravtsov Date: Wed, 30 Jul 2025 10:41:03 +0300 Subject: [PATCH] fix(init): prevent data loss when reinitializing with existing content --- README.md | 48 +++++- cmd/init.go | 11 +- cmd/root_test.go | 348 ++++++++++++++++++++++++++++++++++++++ internal/core/lnk.go | 39 +++++ internal/core/lnk_test.go | 165 ++++++++++++++++++ 5 files changed, 602 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 46563cd..fb706a2 100644 --- a/README.md +++ b/README.md @@ -54,6 +54,9 @@ lnk init -r git@github.com:user/dotfiles.git # Skip automatic bootstrap lnk init -r git@github.com:user/dotfiles.git --no-bootstrap +# Force initialization (WARNING: overwrites existing managed files) +lnk init -r git@github.com:user/dotfiles.git --force + # Run bootstrap script manually lnk bootstrap ``` @@ -103,6 +106,33 @@ After: ~/.ssh/config -> ~/.config/lnk/laptop.lnk/.ssh/config (symlink) Your files live in `~/.config/lnk` (a Git repo). Common files go in the root, host-specific files go in `.lnk/` subdirectories. Lnk creates symlinks back to original locations. Edit files normally, use Git normally. +## Safety Features + +Lnk includes built-in safety checks to prevent accidental data loss: + +### Data Loss Prevention + +```bash +# This will be blocked if you already have managed files +lnk init -r git@github.com:user/dotfiles.git +# ❌ Directory ~/.config/lnk already contains managed files +# 💡 Use 'lnk pull' to update from remote instead of 'lnk init -r' + +# Use pull instead to safely update +lnk pull + +# Or force if you understand the risks (shows warning only when needed) +lnk init -r git@github.com:user/dotfiles.git --force +# ⚠️ Using --force flag: This will overwrite existing managed files +# 💡 Only use this if you understand the risks +``` + +### Smart Warnings + +- **Contextual alerts**: Warnings only appear when there are actually managed files to overwrite +- **Clear guidance**: Error messages suggest the correct command to use +- **Force override**: Advanced users can bypass safety checks when needed + ## Bootstrap Support Lnk automatically runs bootstrap scripts when cloning dotfiles repositories, making it easy to set up your development environment. Just add a `bootstrap.sh` file to your dotfiles repo. @@ -276,7 +306,7 @@ lnk pull # Get updates (work config won't affe ## Commands -- `lnk init [-r remote] [--no-bootstrap]` - Create repo (runs bootstrap automatically) +- `lnk init [-r remote] [--no-bootstrap] [--force]` - Create repo (runs bootstrap automatically) - `lnk add [--host HOST] [--recursive] [--dry-run] ...` - Move files to repo, create symlinks - `lnk rm [--host HOST] ` - Move files back, remove symlinks - `lnk list [--host HOST] [--all]` - List files managed by lnk @@ -293,6 +323,7 @@ lnk pull # Get updates (work config won't affe - `--all` - Show all configurations (common + all hosts) when listing - `-r, --remote URL` - Clone from remote URL when initializing - `--no-bootstrap` - Skip automatic execution of bootstrap script after cloning +- `--force` - Force initialization even if directory contains managed files (WARNING: overwrites existing content) ### Add Command Examples @@ -321,17 +352,18 @@ lnk add --host work ~/.gitconfig ~/.ssh/config ~/.npmrc - **Bulk operations** (multiple files, atomic transactions) - **Recursive processing** (directory contents individually) - **Preview mode** (dry-run for safety) +- **Data loss prevention** (safety checks with contextual warnings) - **Git-native** (standard Git repo, no special formats) ## Alternatives -| Tool | Complexity | Why choose it | -| ------- | ---------- | -------------------------------------------------------------------------- | -| **lnk** | Minimal | Just works, no config, Git-native, multihost, bootstrap, bulk ops, dry-run | -| chezmoi | High | Templates, encryption, cross-platform | -| yadm | Medium | Git power user, encryption | -| dotbot | Low | YAML config, basic features | -| stow | Low | Perl, symlink only | +| Tool | Complexity | Why choose it | +| ------- | ---------- | ----------------------------------------------------------------------------------------- | +| **lnk** | Minimal | Just works, no config, Git-native, multihost, bootstrap, bulk ops, dry-run, safety checks | +| chezmoi | High | Templates, encryption, cross-platform | +| yadm | Medium | Git power user, encryption | +| dotbot | Low | YAML config, basic features | +| stow | Low | Perl, symlink only | ## Contributing diff --git a/cmd/init.go b/cmd/init.go index 0e4f37c..2433445 100644 --- a/cmd/init.go +++ b/cmd/init.go @@ -15,9 +15,17 @@ func newInitCmd() *cobra.Command { RunE: func(cmd *cobra.Command, args []string) error { remote, _ := cmd.Flags().GetString("remote") noBootstrap, _ := cmd.Flags().GetBool("no-bootstrap") + force, _ := cmd.Flags().GetBool("force") lnk := core.NewLnk() - if err := lnk.InitWithRemote(remote); err != nil { + + // Show warning when force is used and there are managed files to overwrite + if force && remote != "" && lnk.HasUserContent() { + printf(cmd, "⚠️ \033[33mUsing --force flag: This will overwrite existing managed files\033[0m\n") + printf(cmd, " 💡 Only use this if you understand the risks\n\n") + } + + if err := lnk.InitWithRemoteForce(remote, force); err != nil { return err } @@ -69,5 +77,6 @@ func newInitCmd() *cobra.Command { cmd.Flags().StringP("remote", "r", "", "Clone from remote URL instead of creating empty repository") cmd.Flags().Bool("no-bootstrap", false, "Skip automatic execution of bootstrap script after cloning") + cmd.Flags().Bool("force", false, "Force initialization even if directory contains managed files (WARNING: This will overwrite existing content)") return cmd } diff --git a/cmd/root_test.go b/cmd/root_test.go index 0c7b728..bda7f2e 100644 --- a/cmd/root_test.go +++ b/cmd/root_test.go @@ -6,7 +6,9 @@ import ( "os" "os/exec" "path/filepath" + "strings" "testing" + "time" "github.com/stretchr/testify/suite" ) @@ -1338,6 +1340,352 @@ func (suite *CLITestSuite) TestUpdatedHelpText() { suite.Contains(addHelpOutput, "without making changes", "Should explain dry-run flag") } +// Task 3.1: Tests for force flag functionality +func (suite *CLITestSuite) TestInitCmd_ForceFlag_BypassesSafetyCheck() { + // Setup: Create .lnk file to simulate existing content + lnkDir := filepath.Join(suite.tempDir, ".config", "lnk") + err := os.MkdirAll(lnkDir, 0755) + suite.Require().NoError(err) + + // Initialize git repo first + cmd := exec.Command("git", "init") + cmd.Dir = lnkDir + err = cmd.Run() + suite.Require().NoError(err) + + lnkFile := filepath.Join(lnkDir, ".lnk") + err = os.WriteFile(lnkFile, []byte(".bashrc\n"), 0644) + suite.Require().NoError(err) + + // Create a dummy remote directory for testing + remoteDir := filepath.Join(suite.tempDir, "remote") + err = os.MkdirAll(remoteDir, 0755) + suite.Require().NoError(err) + cmd = exec.Command("git", "init", "--bare") + cmd.Dir = remoteDir + err = cmd.Run() + suite.Require().NoError(err) + + // Execute init command with --force flag + err = suite.runCommand("init", "-r", remoteDir, "--force") + suite.NoError(err, "Force flag should bypass safety check") + + // Verify output shows warning + output := suite.stdout.String() + suite.Contains(output, "force", "Should show force warning") +} + +func (suite *CLITestSuite) TestInitCmd_NoForceFlag_RespectsSafetyCheck() { + // Setup: Create .lnk file to simulate existing content + lnkDir := filepath.Join(suite.tempDir, ".config", "lnk") + err := os.MkdirAll(lnkDir, 0755) + suite.Require().NoError(err) + + // Initialize git repo first + cmd := exec.Command("git", "init") + cmd.Dir = lnkDir + err = cmd.Run() + suite.Require().NoError(err) + + lnkFile := filepath.Join(lnkDir, ".lnk") + err = os.WriteFile(lnkFile, []byte(".bashrc\n"), 0644) + suite.Require().NoError(err) + + // Create a dummy remote directory for testing + remoteDir := filepath.Join(suite.tempDir, "remote") + err = os.MkdirAll(remoteDir, 0755) + suite.Require().NoError(err) + cmd = exec.Command("git", "init", "--bare") + cmd.Dir = remoteDir + err = cmd.Run() + suite.Require().NoError(err) + + // Execute init command without --force flag - should fail + err = suite.runCommand("init", "-r", remoteDir) + suite.Error(err, "Should respect safety check without force flag") + suite.Contains(err.Error(), "already contains managed files") +} + +func (suite *CLITestSuite) TestInitCmd_ForceFlag_ShowsWarning() { + // Setup: Create .lnk file to simulate existing content + lnkDir := filepath.Join(suite.tempDir, ".config", "lnk") + err := os.MkdirAll(lnkDir, 0755) + suite.Require().NoError(err) + + // Initialize git repo first + cmd := exec.Command("git", "init") + cmd.Dir = lnkDir + err = cmd.Run() + suite.Require().NoError(err) + + lnkFile := filepath.Join(lnkDir, ".lnk") + err = os.WriteFile(lnkFile, []byte(".bashrc\n"), 0644) + suite.Require().NoError(err) + + // Create a dummy remote directory for testing + remoteDir := filepath.Join(suite.tempDir, "remote") + err = os.MkdirAll(remoteDir, 0755) + suite.Require().NoError(err) + cmd = exec.Command("git", "init", "--bare") + cmd.Dir = remoteDir + err = cmd.Run() + suite.Require().NoError(err) + + // Execute init command with --force flag + err = suite.runCommand("init", "-r", remoteDir, "--force") + suite.NoError(err, "Force flag should bypass safety check") + + // Verify output shows appropriate warning + output := suite.stdout.String() + suite.Contains(output, "⚠️", "Should show warning emoji") + suite.Contains(output, "overwrite", "Should warn about overwriting") +} + +// Task 4.1: Integration tests for end-to-end workflows +func (suite *CLITestSuite) TestE2E_InitAddInit_PreventDataLoss() { + // Run: lnk init + err := suite.runCommand("init") + suite.Require().NoError(err) + suite.stdout.Reset() + + // Create and add test file + testFile := filepath.Join(suite.tempDir, ".testfile") + err = os.WriteFile(testFile, []byte("important content"), 0644) + suite.Require().NoError(err) + err = suite.runCommand("add", testFile) + suite.Require().NoError(err) + + // Create dummy remote for testing + remoteDir := filepath.Join(suite.tempDir, "remote") + err = os.MkdirAll(remoteDir, 0755) + suite.Require().NoError(err) + cmd := exec.Command("git", "init", "--bare") + cmd.Dir = remoteDir + err = cmd.Run() + suite.Require().NoError(err) + + // Run: lnk init -r → should FAIL + err = suite.runCommand("init", "-r", remoteDir) + suite.Error(err, "Should prevent data loss") + suite.Contains(err.Error(), "already contains managed files") + + // Verify testfile still exists and is managed + suite.FileExists(testFile) + info, err := os.Lstat(testFile) + suite.NoError(err) + suite.Equal(os.ModeSymlink, info.Mode()&os.ModeSymlink, "File should still be symlink") +} + +func (suite *CLITestSuite) TestE2E_FreshInit_Success() { + // Create dummy remote for testing + remoteDir := filepath.Join(suite.tempDir, "remote") + err := os.MkdirAll(remoteDir, 0755) + suite.Require().NoError(err) + cmd := exec.Command("git", "init", "--bare") + cmd.Dir = remoteDir + err = cmd.Run() + suite.Require().NoError(err) + + // Fresh init with remote should succeed + err = suite.runCommand("init", "-r", remoteDir) + suite.NoError(err, "Fresh init should succeed") + + // Verify repository was created + lnkDir := filepath.Join(suite.tempDir, ".config", "lnk") + suite.DirExists(lnkDir) + gitDir := filepath.Join(lnkDir, ".git") + suite.DirExists(gitDir) + + // Verify success message + output := suite.stdout.String() + suite.Contains(output, "Initialized lnk repository") + suite.Contains(output, "Cloned from:") +} + +func (suite *CLITestSuite) TestE2E_ForceInit_OverwritesContent() { + // Setup: init and add content first + err := suite.runCommand("init") + suite.Require().NoError(err) + + testFile := filepath.Join(suite.tempDir, ".testfile") + err = os.WriteFile(testFile, []byte("original content"), 0644) + suite.Require().NoError(err) + err = suite.runCommand("add", testFile) + suite.Require().NoError(err) + suite.stdout.Reset() + + // Create dummy remote for testing + remoteDir := filepath.Join(suite.tempDir, "remote") + err = os.MkdirAll(remoteDir, 0755) + suite.Require().NoError(err) + cmd := exec.Command("git", "init", "--bare") + cmd.Dir = remoteDir + err = cmd.Run() + suite.Require().NoError(err) + + // Force init should succeed and show warning + err = suite.runCommand("init", "-r", remoteDir, "--force") + suite.NoError(err, "Force init should succeed") + + // Verify warning was shown + output := suite.stdout.String() + suite.Contains(output, "⚠️", "Should show warning") + suite.Contains(output, "overwrite", "Should warn about overwriting") + suite.Contains(output, "Initialized lnk repository") +} + +func (suite *CLITestSuite) TestE2E_ErrorMessage_SuggestsCorrectCommand() { + // Setup: init and add content first + err := suite.runCommand("init") + suite.Require().NoError(err) + + testFile := filepath.Join(suite.tempDir, ".testfile") + err = os.WriteFile(testFile, []byte("important content"), 0644) + suite.Require().NoError(err) + err = suite.runCommand("add", testFile) + suite.Require().NoError(err) + + // Try init with remote - should fail with helpful message + err = suite.runCommand("init", "-r", "https://github.com/test/dotfiles.git") + suite.Error(err, "Should fail with helpful error") + + // Verify error message suggests correct alternative + suite.Contains(err.Error(), "already contains managed files", "Should explain the problem") + suite.Contains(err.Error(), "lnk pull", "Should suggest pull command") + suite.Contains(err.Error(), "instead of", "Should explain the alternative") + suite.Contains(err.Error(), "lnk init -r", "Should show the problematic command") +} + +// Task 6.1: Regression tests to ensure existing functionality unchanged +func (suite *CLITestSuite) TestRegression_FreshInit_UnchangedBehavior() { + // Test that fresh init (no existing content) works exactly as before + err := suite.runCommand("init") + suite.NoError(err, "Fresh init should work unchanged") + + // Verify same output format and behavior + output := suite.stdout.String() + suite.Contains(output, "Initialized empty lnk repository") + suite.Contains(output, "Location:") + + // Verify repository structure is created correctly + lnkDir := filepath.Join(suite.tempDir, ".config", "lnk") + suite.DirExists(lnkDir) + gitDir := filepath.Join(lnkDir, ".git") + suite.DirExists(gitDir) +} + +func (suite *CLITestSuite) TestRegression_ExistingWorkflows_StillWork() { + // Test that all existing workflows continue to function + + // 1. Normal init → add → list → remove workflow + err := suite.runCommand("init") + suite.NoError(err, "Init should work") + suite.stdout.Reset() + + // Create and add a file + testFile := filepath.Join(suite.tempDir, ".bashrc") + err = os.WriteFile(testFile, []byte("export PATH=/usr/local/bin:$PATH"), 0644) + suite.Require().NoError(err) + + err = suite.runCommand("add", testFile) + suite.NoError(err, "Add should work") + suite.stdout.Reset() + + // List files + err = suite.runCommand("list") + suite.NoError(err, "List should work") + output := suite.stdout.String() + suite.Contains(output, ".bashrc", "Should list added file") + suite.stdout.Reset() + + // Remove file + err = suite.runCommand("rm", testFile) + suite.NoError(err, "Remove should work") + + // Verify file is restored as regular file + info, err := os.Lstat(testFile) + suite.NoError(err) + suite.Equal(os.FileMode(0), info.Mode()&os.ModeSymlink, "File should be regular after remove") +} + +func (suite *CLITestSuite) TestRegression_GitOperations_Unaffected() { + // Test that Git operations continue to work normally + err := suite.runCommand("init") + suite.NoError(err) + + // Add a file to create commits + testFile := filepath.Join(suite.tempDir, ".vimrc") + err = os.WriteFile(testFile, []byte("set number"), 0644) + suite.Require().NoError(err) + + err = suite.runCommand("add", testFile) + suite.NoError(err) + + // Verify Git repository structure and commits are normal + lnkDir := filepath.Join(suite.tempDir, ".config", "lnk") + + // Check that commits are created normally + cmd := exec.Command("git", "log", "--oneline", "--format=%s") + cmd.Dir = lnkDir + output, err := cmd.Output() + suite.NoError(err, "Git log should work") + + commits := string(output) + suite.Contains(commits, "lnk: added .vimrc", "Should have normal commit message") + + // Check that git status works + cmd = exec.Command("git", "status", "--porcelain") + cmd.Dir = lnkDir + statusOutput, err := cmd.Output() + suite.NoError(err, "Git status should work") + suite.Empty(strings.TrimSpace(string(statusOutput)), "Working directory should be clean") +} + +func (suite *CLITestSuite) TestRegression_PerformanceImpact_Minimal() { + // Test that the new safety checks don't significantly impact performance + + // Simple performance check: ensure a single init completes quickly + start := time.Now() + err := suite.runCommand("init") + elapsed := time.Since(start) + + suite.NoError(err, "Init should succeed") + suite.Less(elapsed, 2*time.Second, "Init should complete quickly") + + // Test safety check performance on existing repository + suite.stdout.Reset() + start = time.Now() + err = suite.runCommand("init", "-r", "dummy-url") + elapsed = time.Since(start) + + // Should fail quickly due to safety check (not hang) + suite.Error(err, "Should fail due to safety check") + suite.Less(elapsed, 1*time.Second, "Safety check should be fast") +} + +// Task 7.1: Tests for help documentation +func (suite *CLITestSuite) TestInitCommand_HelpText_MentionsForceFlag() { + err := suite.runCommand("init", "--help") + suite.NoError(err) + output := suite.stdout.String() + suite.Contains(output, "--force", "Help should mention force flag") + suite.Contains(output, "overwrite", "Help should explain force behavior") +} + +func (suite *CLITestSuite) TestInitCommand_HelpText_ExplainsDataProtection() { + err := suite.runCommand("init", "--help") + suite.NoError(err) + output := suite.stdout.String() + + // Should explain what the command does + suite.Contains(output, "Creates", "Should explain what init does") + suite.Contains(output, "lnk directory", "Should mention lnk directory") + + // Should warn about the force flag risks + suite.Contains(output, "WARNING", "Should warn about force flag risks") + suite.Contains(output, "overwrite existing content", "Should mention overwrite risk") +} + func TestCLISuite(t *testing.T) { suite.Run(t, new(CLITestSuite)) } diff --git a/internal/core/lnk.go b/internal/core/lnk.go index 4b0b475..90bdbe3 100644 --- a/internal/core/lnk.go +++ b/internal/core/lnk.go @@ -46,6 +46,34 @@ func NewLnk(opts ...Option) *Lnk { return lnk } +// HasUserContent checks if the repository contains managed files +// by looking for .lnk tracker files (common or host-specific) +func (l *Lnk) HasUserContent() bool { + // Check for common tracker file + commonTracker := filepath.Join(l.repoPath, ".lnk") + if _, err := os.Stat(commonTracker); err == nil { + return true + } + + // Check for host-specific tracker files if host is set + if l.host != "" { + hostTracker := filepath.Join(l.repoPath, fmt.Sprintf(".lnk.%s", l.host)) + if _, err := os.Stat(hostTracker); err == nil { + return true + } + } else { + // If no specific host is set, check for any host-specific tracker files + // This handles cases where we want to detect any managed content + pattern := filepath.Join(l.repoPath, ".lnk.*") + matches, err := filepath.Glob(pattern) + if err == nil && len(matches) > 0 { + return true + } + } + + return false +} + // GetCurrentHostname returns the current system hostname func GetCurrentHostname() (string, error) { hostname, err := os.Hostname() @@ -119,7 +147,18 @@ func (l *Lnk) Init() error { // InitWithRemote initializes the lnk repository, optionally cloning from a remote func (l *Lnk) InitWithRemote(remoteURL string) error { + return l.InitWithRemoteForce(remoteURL, false) +} + +// InitWithRemoteForce initializes the lnk repository with optional force override +func (l *Lnk) InitWithRemoteForce(remoteURL string, force bool) error { if remoteURL != "" { + // Safety check: prevent data loss by checking for existing managed files + if l.HasUserContent() { + if !force { + return fmt.Errorf("❌ Directory \033[31m%s\033[0m already contains managed files\n 💡 Use 'lnk pull' to update from remote instead of 'lnk init -r'", l.repoPath) + } + } // Clone from remote return l.Clone(remoteURL) } diff --git a/internal/core/lnk_test.go b/internal/core/lnk_test.go index f08691a..c87f230 100644 --- a/internal/core/lnk_test.go +++ b/internal/core/lnk_test.go @@ -3,6 +3,7 @@ package core import ( "fmt" "os" + "os/exec" "path/filepath" "strings" "testing" @@ -1432,6 +1433,170 @@ func (suite *CoreTestSuite) TestPreviewAddValidation() { suite.Contains(err.Error(), "already managed", "Error should mention already managed") } +// Task 1.1: Tests for HasUserContent() method +func (suite *CoreTestSuite) TestHasUserContent_WithCommonTracker_ReturnsTrue() { + // Initialize lnk repository + err := suite.lnk.Init() + suite.Require().NoError(err) + + // Create .lnk file to simulate existing content + lnkFile := filepath.Join(suite.tempDir, "lnk", ".lnk") + err = os.WriteFile(lnkFile, []byte(".bashrc\n"), 0644) + suite.Require().NoError(err) + + // Call HasUserContent() + hasContent := suite.lnk.HasUserContent() + suite.True(hasContent, "Should detect common tracker file") +} + +func (suite *CoreTestSuite) TestHasUserContent_WithHostTracker_ReturnsTrue() { + // Initialize lnk repository + err := suite.lnk.Init() + suite.Require().NoError(err) + + // Create host-specific lnk instance + hostLnk := NewLnk(WithHost("testhost")) + + // Create .lnk.hostname file to simulate host-specific content + lnkFile := filepath.Join(suite.tempDir, "lnk", ".lnk.testhost") + err = os.WriteFile(lnkFile, []byte(".vimrc\n"), 0644) + suite.Require().NoError(err) + + // Call HasUserContent() + hasContent := hostLnk.HasUserContent() + suite.True(hasContent, "Should detect host-specific tracker file") +} + +func (suite *CoreTestSuite) TestHasUserContent_WithBothTrackers_ReturnsTrue() { + // Initialize lnk repository + err := suite.lnk.Init() + suite.Require().NoError(err) + + // Create both common and host-specific tracker files + commonLnkFile := filepath.Join(suite.tempDir, "lnk", ".lnk") + err = os.WriteFile(commonLnkFile, []byte(".bashrc\n"), 0644) + suite.Require().NoError(err) + + hostLnkFile := filepath.Join(suite.tempDir, "lnk", ".lnk.testhost") + err = os.WriteFile(hostLnkFile, []byte(".vimrc\n"), 0644) + suite.Require().NoError(err) + + // Test with common instance + hasContent := suite.lnk.HasUserContent() + suite.True(hasContent, "Should detect common tracker file") + + // Test with host-specific instance + hostLnk := NewLnk(WithHost("testhost")) + hasContent = hostLnk.HasUserContent() + suite.True(hasContent, "Should detect host-specific tracker file") +} + +func (suite *CoreTestSuite) TestHasUserContent_EmptyDirectory_ReturnsFalse() { + // Initialize lnk repository + err := suite.lnk.Init() + suite.Require().NoError(err) + + // Call HasUserContent() on empty repository + hasContent := suite.lnk.HasUserContent() + suite.False(hasContent, "Should return false for empty repository") +} + +func (suite *CoreTestSuite) TestHasUserContent_NonTrackerFiles_ReturnsFalse() { + // Initialize lnk repository + err := suite.lnk.Init() + suite.Require().NoError(err) + + // Create non-tracker files + randomFile := filepath.Join(suite.tempDir, "lnk", "random.txt") + err = os.WriteFile(randomFile, []byte("some content"), 0644) + suite.Require().NoError(err) + + configFile := filepath.Join(suite.tempDir, "lnk", ".gitignore") + err = os.WriteFile(configFile, []byte("*.log"), 0644) + suite.Require().NoError(err) + + // Call HasUserContent() + hasContent := suite.lnk.HasUserContent() + suite.False(hasContent, "Should return false when only non-tracker files exist") +} + +// Task 2.1: Tests for enhanced InitWithRemote() safety check +func (suite *CoreTestSuite) TestInitWithRemote_HasUserContent_ReturnsError() { + // Initialize and add content first + err := suite.lnk.Init() + suite.Require().NoError(err) + + // Create .lnk file to simulate existing content + lnkFile := filepath.Join(suite.tempDir, "lnk", ".lnk") + err = os.WriteFile(lnkFile, []byte(".bashrc\n"), 0644) + suite.Require().NoError(err) + + // Try InitWithRemote - should fail + err = suite.lnk.InitWithRemote("https://github.com/test/dotfiles.git") + suite.Error(err, "Should fail when user content exists") + suite.Contains(err.Error(), "already contains managed files") + suite.Contains(err.Error(), "lnk pull") + + // Verify .lnk file still exists (no deletion occurred) + suite.FileExists(lnkFile) +} + +func (suite *CoreTestSuite) TestInitWithRemote_EmptyDirectory_Success() { + // Create a dummy remote directory for testing + remoteDir := filepath.Join(suite.tempDir, "remote") + err := os.MkdirAll(remoteDir, 0755) + suite.Require().NoError(err) + + // Initialize a bare git repository as remote + cmd := exec.Command("git", "init", "--bare") + cmd.Dir = remoteDir + err = cmd.Run() + suite.Require().NoError(err) + + // InitWithRemote should succeed on empty directory + err = suite.lnk.InitWithRemote(remoteDir) + suite.NoError(err, "Should succeed when no user content exists") + + // Verify repository was cloned + lnkDir := filepath.Join(suite.tempDir, "lnk") + suite.DirExists(lnkDir) + gitDir := filepath.Join(lnkDir, ".git") + suite.DirExists(gitDir) +} + +func (suite *CoreTestSuite) TestInitWithRemote_NoRemoteURL_BypassesSafetyCheck() { + // Initialize and add content first + err := suite.lnk.Init() + suite.Require().NoError(err) + + // Create .lnk file to simulate existing content + lnkFile := filepath.Join(suite.tempDir, "lnk", ".lnk") + err = os.WriteFile(lnkFile, []byte(".bashrc\n"), 0644) + suite.Require().NoError(err) + + // InitWithRemote with empty URL should bypass safety check (this is local init) + err = suite.lnk.InitWithRemote("") + suite.NoError(err, "Should bypass safety check when no remote URL provided") +} + +func (suite *CoreTestSuite) TestInitWithRemote_ErrorMessage_ContainsSuggestedCommand() { + // Initialize and add content first + err := suite.lnk.Init() + suite.Require().NoError(err) + + // Create host-specific content + hostLnk := NewLnk(WithHost("testhost")) + hostLnkFile := filepath.Join(suite.tempDir, "lnk", ".lnk.testhost") + err = os.WriteFile(hostLnkFile, []byte(".vimrc\n"), 0644) + suite.Require().NoError(err) + + // Try InitWithRemote - should fail with helpful message + err = hostLnk.InitWithRemote("https://github.com/test/dotfiles.git") + suite.Error(err, "Should fail when user content exists") + suite.Contains(err.Error(), "lnk pull", "Should suggest pull command") + suite.Contains(err.Error(), "instead of", "Should explain alternative") +} + func TestCoreSuite(t *testing.T) { suite.Run(t, new(CoreTestSuite)) }