5 Commits

Author SHA1 Message Date
Yar Kravtsov
dc524607fa fix: remove hardcoded branch names from push/pull operations
- Remove hardcoded "main" branch from git push and pull commands
- Let Git automatically detect and use current branch
- Add comprehensive tests for different branch names (main, master, develop)
- Fixes GitHub issue #14 where operations failed on repos using "master"
2025-08-01 06:45:56 +03:00
Yar Kravtsov
9bf2e70d13 docs: remove RELEASE.md in favor of automated process 2025-07-30 10:57:32 +03:00
Yar Kravtsov
65db5fe738 Merge pull request #13 from yarlson/force
fix(init): prevent data loss when reinitializing with existing content
2025-07-30 10:42:54 +03:00
Yar Kravtsov
43b68bc071 fix(init): prevent data loss when reinitializing with existing content 2025-07-30 10:41:03 +03:00
Yar Kravtsov
ab97fa86dc chore(brew): move lnk formula to core Homebrew repository 2025-07-29 12:29:09 +03:00
8 changed files with 723 additions and 203 deletions

1
.gitignore vendored
View File

@@ -46,4 +46,3 @@ desktop.ini
goreleaser/ goreleaser/
*.md *.md
!/README.md !/README.md
!/RELEASE.md

View File

@@ -26,7 +26,6 @@ curl -sSL https://raw.githubusercontent.com/yarlson/lnk/main/install.sh | bash
```bash ```bash
# Homebrew (macOS/Linux) # Homebrew (macOS/Linux)
brew tap yarlson/lnk
brew install lnk brew install lnk
``` ```
@@ -55,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
``` ```
@@ -104,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.
@@ -277,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
@@ -294,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
@@ -322,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

@@ -1,190 +0,0 @@
# Release Process
This document describes how to create releases for the lnk project using GoReleaser.
## Prerequisites
- Push access to the main repository
- Git tags pushed to GitHub trigger releases automatically
- GoReleaser is configured in `.goreleaser.yml`
- GitHub Actions will handle the release process
- Access to the [homebrew-lnk](https://github.com/yarlson/homebrew-lnk) tap repository
- **Personal Access Token** set up as `HOMEBREW_TAP_TOKEN` secret (see Setup section)
## Setup (One-time)
### GitHub Personal Access Token
For GoReleaser to update the Homebrew formula, you need a Personal Access Token:
1. Go to GitHub Settings → Developer settings → Personal access tokens → Tokens (classic)
2. Click "Generate new token" → "Generate new token (classic)"
3. Name: "GoReleaser Homebrew Access"
4. Scopes: Select `repo` (Full control of private repositories)
5. Generate and copy the token
6. In your `yarlson/lnk` repository:
- Go to Settings → Secrets and variables → Actions
- Add new repository secret: `HOMEBREW_TAP_TOKEN`
- Paste the token as the value
This allows GoReleaser to automatically update the Homebrew formula in [homebrew-lnk](https://github.com/yarlson/homebrew-lnk).
## Creating a Release
### 1. Ensure everything is ready
```bash
# Run all quality checks
make check
# Test GoReleaser configuration
make goreleaser-check
# Test build process
make goreleaser-snapshot
```
### 2. Create and push a version tag
```bash
# Create a new tag (replace x.y.z with actual version)
git tag -a v1.0.0 -m "Release v1.0.0"
# Push the tag to trigger the release
git push origin v1.0.0
```
### 3. Monitor the release
- GitHub Actions will automatically build and release when the tag is pushed
- Check the [Actions tab](https://github.com/yarlson/lnk/actions) for build status
- The release will appear in [GitHub Releases](https://github.com/yarlson/lnk/releases)
- The Homebrew formula will be automatically updated in [homebrew-lnk](https://github.com/yarlson/homebrew-lnk)
## What GoReleaser Does
1. **Builds binaries** for multiple platforms:
- Linux (amd64, arm64)
- macOS (amd64, arm64)
- Windows (amd64)
2. **Creates archives** with consistent naming:
- `lnk_Linux_x86_64.tar.gz`
- `lnk_Darwin_arm64.tar.gz`
- etc.
3. **Generates checksums** for verification
4. **Creates GitHub release** with:
- Automatic changelog from conventional commits
- Installation instructions
- Download links for all platforms
5. **Updates Homebrew formula** automatically in the [homebrew-lnk](https://github.com/yarlson/homebrew-lnk) tap
## Manual Release (if needed)
If you need to create a release manually:
```bash
# Export GitHub token
export GITHUB_TOKEN="your_token_here"
# Create release (requires a git tag)
goreleaser release --clean
```
## Testing Releases Locally
```bash
# Test the build process without releasing
make goreleaser-snapshot
# Built artifacts will be in dist/
ls -la dist/
# Test a binary
./dist/lnk_<platform>/lnk --version
```
## Installation Methods
After a release is published, users can install lnk using multiple methods:
### 1. Shell Script (Recommended)
```bash
curl -sSL https://raw.githubusercontent.com/yarlson/lnk/main/install.sh | bash
```
### 2. Homebrew (macOS/Linux)
```bash
brew tap yarlson/lnk
brew install lnk
```
### 3. Manual Download
```bash
# Download from GitHub releases
wget https://github.com/yarlson/lnk/releases/latest/download/lnk_Linux_x86_64.tar.gz
tar -xzf lnk_Linux_x86_64.tar.gz
sudo mv lnk /usr/local/bin/
```
## Version Numbering
We use [Semantic Versioning](https://semver.org/):
- `v1.0.0` - Major release (breaking changes)
- `v1.1.0` - Minor release (new features, backward compatible)
- `v1.1.1` - Patch release (bug fixes)
## Changelog
GoReleaser automatically generates changelogs from git commits using conventional commit format:
- `feat:` - New features
- `fix:` - Bug fixes
- `docs:` - Documentation changes (excluded from changelog)
- `test:` - Test changes (excluded from changelog)
- `ci:` - CI changes (excluded from changelog)
## Homebrew Tap
The Homebrew formula is automatically maintained in the [homebrew-lnk](https://github.com/yarlson/homebrew-lnk) repository. When a new release is created:
1. GoReleaser automatically creates/updates the formula
2. The formula is committed to the tap repository
3. Users can immediately install the new version via `brew install yarlson/lnk/lnk`
## Troubleshooting
### Release failed to create
1. Check that the tag follows the format `vX.Y.Z`
2. Ensure GitHub Actions has proper permissions
3. Check the Actions log for detailed error messages
### Missing binaries in release
1. Verify GoReleaser configuration: `make goreleaser-check`
2. Test build locally: `make goreleaser-snapshot`
3. Check the build matrix in `.goreleaser.yml`
### Changelog is empty
1. Ensure commits follow conventional commit format
2. Check that there are commits since the last tag
3. Verify changelog configuration in `.goreleaser.yml`
### Homebrew formula not updated
1. Check that the GITHUB_TOKEN has access to the homebrew-lnk repository
2. Verify the repository name and owner in `.goreleaser.yml`
3. Check the release workflow logs for Homebrew-related errors
4. Ensure the homebrew-lnk repository exists and is accessible

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,471 @@ 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")
}
// TestPushPullWithDifferentBranches tests push/pull operations with different default branch names
func (suite *CLITestSuite) TestPushPullWithDifferentBranches() {
testCases := []struct {
name string
branchName string
setupRemote func(remoteDir string) error
}{
{
name: "master branch",
branchName: "master",
setupRemote: func(remoteDir string) error {
cmd := exec.Command("git", "init", "--bare", "--initial-branch=master")
cmd.Dir = remoteDir
return cmd.Run()
},
},
{
name: "main branch",
branchName: "main",
setupRemote: func(remoteDir string) error {
cmd := exec.Command("git", "init", "--bare", "--initial-branch=main")
cmd.Dir = remoteDir
return cmd.Run()
},
},
{
name: "custom branch",
branchName: "develop",
setupRemote: func(remoteDir string) error {
cmd := exec.Command("git", "init", "--bare", "--initial-branch=develop")
cmd.Dir = remoteDir
return cmd.Run()
},
},
}
for _, tc := range testCases {
suite.Run(tc.name, func() {
// Create a separate temp directory for this test case
testDir, err := os.MkdirTemp("", "lnk-push-pull-test-*")
suite.Require().NoError(err)
defer func() { _ = os.RemoveAll(testDir) }()
// Save current dir and change to test dir
originalDir, err := os.Getwd()
suite.Require().NoError(err)
defer func() { _ = os.Chdir(originalDir) }()
err = os.Chdir(testDir)
suite.Require().NoError(err)
// Set HOME to test directory
suite.T().Setenv("HOME", testDir)
suite.T().Setenv("XDG_CONFIG_HOME", testDir)
// Create remote repository
remoteDir := filepath.Join(testDir, "remote.git")
err = os.MkdirAll(remoteDir, 0755)
suite.Require().NoError(err)
err = tc.setupRemote(remoteDir)
suite.Require().NoError(err)
// Initialize lnk with remote
err = suite.runCommand("init", "--remote", remoteDir)
suite.Require().NoError(err)
// Switch to the test branch if not main/master (since init creates main by default)
if tc.branchName != "main" {
lnkDir := filepath.Join(testDir, "lnk")
cmd := exec.Command("git", "checkout", "-b", tc.branchName)
cmd.Dir = lnkDir
_, err = cmd.CombinedOutput()
suite.Require().NoError(err)
}
// Add a test file
testFile := filepath.Join(testDir, ".testrc")
err = os.WriteFile(testFile, []byte("test config"), 0644)
suite.Require().NoError(err)
err = suite.runCommand("add", testFile)
suite.Require().NoError(err)
// Test push operation
err = suite.runCommand("push", "test push with "+tc.branchName)
suite.Require().NoError(err, "Push should work with %s branch", tc.branchName)
// Create another test directory to simulate pulling from another machine
pullTestDir, err := os.MkdirTemp("", "lnk-pull-test-*")
suite.Require().NoError(err)
defer func() { _ = os.RemoveAll(pullTestDir) }()
err = os.Chdir(pullTestDir)
suite.Require().NoError(err)
// Set HOME for pull test
suite.T().Setenv("HOME", pullTestDir)
suite.T().Setenv("XDG_CONFIG_HOME", pullTestDir)
// Clone and test pull
err = suite.runCommand("init", "--remote", remoteDir)
suite.Require().NoError(err)
err = suite.runCommand("pull")
suite.Require().NoError(err, "Pull should work with %s branch", tc.branchName)
// Verify the file was pulled correctly
lnkDir := filepath.Join(pullTestDir, "lnk")
pulledFile := filepath.Join(lnkDir, ".testrc")
suite.FileExists(pulledFile, "File should exist after pull with %s branch", tc.branchName)
content, err := os.ReadFile(pulledFile)
suite.Require().NoError(err)
suite.Equal("test config", string(content), "File content should match after pull with %s branch", tc.branchName)
})
}
}
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))
} }

View File

@@ -437,7 +437,7 @@ func (g *Git) Push() error {
return &PushError{Reason: err.Error(), Err: err} return &PushError{Reason: err.Error(), Err: err}
} }
cmd := exec.Command("git", "push", "-u", "origin", "main") cmd := exec.Command("git", "push", "-u", "origin")
cmd.Dir = g.repoPath cmd.Dir = g.repoPath
output, err := cmd.CombinedOutput() output, err := cmd.CombinedOutput()
@@ -456,7 +456,7 @@ func (g *Git) Pull() error {
return &PullError{Reason: err.Error(), Err: err} return &PullError{Reason: err.Error(), Err: err}
} }
cmd := exec.Command("git", "pull", "origin", "main") cmd := exec.Command("git", "pull", "origin")
cmd.Dir = g.repoPath cmd.Dir = g.repoPath
output, err := cmd.CombinedOutput() output, err := cmd.CombinedOutput()