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