fix(init): prevent data loss when reinitializing with existing content

This commit is contained in:
Yar Kravtsov
2025-07-30 10:41:03 +03:00
parent ab97fa86dc
commit 43b68bc071
5 changed files with 602 additions and 9 deletions

View File

@@ -54,6 +54,9 @@ lnk init -r git@github.com:user/dotfiles.git
# Skip automatic bootstrap # Skip automatic bootstrap
lnk init -r git@github.com:user/dotfiles.git --no-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 # Run bootstrap script manually
lnk bootstrap 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 `<host>.lnk/` subdirectories. Lnk creates symlinks back to original locations. Edit files normally, use Git normally. Your files live in `~/.config/lnk` (a Git repo). Common files go in the root, host-specific files go in `<host>.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 ## 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. 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 ## 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] <files>...` - Move files to repo, create symlinks - `lnk add [--host HOST] [--recursive] [--dry-run] <files>...` - Move files to repo, create symlinks
- `lnk rm [--host HOST] <files>` - Move files back, remove symlinks - `lnk rm [--host HOST] <files>` - Move files back, remove symlinks
- `lnk list [--host HOST] [--all]` - List files managed by lnk - `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 - `--all` - Show all configurations (common + all hosts) when listing
- `-r, --remote URL` - Clone from remote URL when initializing - `-r, --remote URL` - Clone from remote URL when initializing
- `--no-bootstrap` - Skip automatic execution of bootstrap script after cloning - `--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 ### Add Command Examples
@@ -321,17 +352,18 @@ lnk add --host work ~/.gitconfig ~/.ssh/config ~/.npmrc
- **Bulk operations** (multiple files, atomic transactions) - **Bulk operations** (multiple files, atomic transactions)
- **Recursive processing** (directory contents individually) - **Recursive processing** (directory contents individually)
- **Preview mode** (dry-run for safety) - **Preview mode** (dry-run for safety)
- **Data loss prevention** (safety checks with contextual warnings)
- **Git-native** (standard Git repo, no special formats) - **Git-native** (standard Git repo, no special formats)
## Alternatives ## Alternatives
| Tool | Complexity | Why choose it | | Tool | Complexity | Why choose it |
| ------- | ---------- | -------------------------------------------------------------------------- | | ------- | ---------- | ----------------------------------------------------------------------------------------- |
| **lnk** | Minimal | Just works, no config, Git-native, multihost, bootstrap, bulk ops, dry-run | | **lnk** | Minimal | Just works, no config, Git-native, multihost, bootstrap, bulk ops, dry-run, safety checks |
| chezmoi | High | Templates, encryption, cross-platform | | chezmoi | High | Templates, encryption, cross-platform |
| yadm | Medium | Git power user, encryption | | yadm | Medium | Git power user, encryption |
| dotbot | Low | YAML config, basic features | | dotbot | Low | YAML config, basic features |
| stow | Low | Perl, symlink only | | stow | Low | Perl, symlink only |
## Contributing ## Contributing

View File

@@ -15,9 +15,17 @@ func newInitCmd() *cobra.Command {
RunE: func(cmd *cobra.Command, args []string) error { RunE: func(cmd *cobra.Command, args []string) error {
remote, _ := cmd.Flags().GetString("remote") remote, _ := cmd.Flags().GetString("remote")
noBootstrap, _ := cmd.Flags().GetBool("no-bootstrap") noBootstrap, _ := cmd.Flags().GetBool("no-bootstrap")
force, _ := cmd.Flags().GetBool("force")
lnk := core.NewLnk() 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 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().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("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 return cmd
} }

View File

@@ -6,7 +6,9 @@ import (
"os" "os"
"os/exec" "os/exec"
"path/filepath" "path/filepath"
"strings"
"testing" "testing"
"time"
"github.com/stretchr/testify/suite" "github.com/stretchr/testify/suite"
) )
@@ -1338,6 +1340,352 @@ func (suite *CLITestSuite) TestUpdatedHelpText() {
suite.Contains(addHelpOutput, "without making changes", "Should explain dry-run flag") 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 <remote> → 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) { func TestCLISuite(t *testing.T) {
suite.Run(t, new(CLITestSuite)) suite.Run(t, new(CLITestSuite))
} }

View File

@@ -46,6 +46,34 @@ func NewLnk(opts ...Option) *Lnk {
return 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 // GetCurrentHostname returns the current system hostname
func GetCurrentHostname() (string, error) { func GetCurrentHostname() (string, error) {
hostname, err := os.Hostname() hostname, err := os.Hostname()
@@ -119,7 +147,18 @@ func (l *Lnk) Init() error {
// InitWithRemote initializes the lnk repository, optionally cloning from a remote // InitWithRemote initializes the lnk repository, optionally cloning from a remote
func (l *Lnk) InitWithRemote(remoteURL string) error { 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 != "" { 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 // Clone from remote
return l.Clone(remoteURL) return l.Clone(remoteURL)
} }

View File

@@ -3,6 +3,7 @@ package core
import ( import (
"fmt" "fmt"
"os" "os"
"os/exec"
"path/filepath" "path/filepath"
"strings" "strings"
"testing" "testing"
@@ -1432,6 +1433,170 @@ func (suite *CoreTestSuite) TestPreviewAddValidation() {
suite.Contains(err.Error(), "already managed", "Error should mention already managed") 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) { func TestCoreSuite(t *testing.T) {
suite.Run(t, new(CoreTestSuite)) suite.Run(t, new(CoreTestSuite))
} }