10 Commits

Author SHA1 Message Date
Yar Kravtsov
1e2c9704f3 refactor(errors): implement structured error handling for improved debugging 2025-06-03 07:58:21 +03:00
Yar Kravtsov
3cba309c05 refactor(core): simplify Lnk creation with functional options pattern 2025-06-03 06:50:52 +03:00
Yar Kravtsov
3e6b426a19 test(cmd): improve test coverage for file storage and .lnk tracking 2025-05-27 08:33:23 +03:00
Yar Kravtsov
02f342b02b refactor(core): simplify path handling and remove redundant generateRepoName function 2025-05-27 08:00:04 +03:00
Yar Kravtsov
92f2575090 fix: preserve directory structure for common files and fix display paths 2025-05-26 09:23:46 +03:00
Yar Kravtsov
0f74723a03 docs(README): update examples for host-specific configurations and usage patterns 2025-05-26 08:38:21 +03:00
Yar Kravtsov
093cc8ebe7 feat(multihost): add support for host-specific configurations
Implement multihost functionality allowing separate management of common and host-specific dotfiles. Add new commands and flags for handling host-specific files, update core logic for file storage and tracking, and enhance documentation to reflect new capabilities.
2025-05-26 08:26:06 +03:00
Yar Kravtsov
ff3cddc065 docs: Update README.md 2025-05-26 07:58:29 +03:00
Yar Kravtsov
4a275ce4ca feat(cmd): add 'list' command to display managed files
Implements a new 'list' command that shows all files and directories managed by lnk, improving visibility and user experience.

fixes #4
2025-05-26 05:59:33 +03:00
Yar Kravtsov
69c1038f3e Merge pull request #6 from yarlson/alert-autofix-4
Potential fix for code scanning alert no. 4: Workflow does not contain permissions
2025-05-26 05:46:31 +03:00
16 changed files with 1503 additions and 343 deletions

135
README.md
View File

@@ -2,11 +2,12 @@
**Git-native dotfiles management that doesn't suck.**
Move your dotfiles to `~/.config/lnk`, symlink them back, and use Git like normal. That's it.
Move your dotfiles to `~/.config/lnk`, symlink them back, and use Git like normal. Supports both common configurations and host-specific setups.
```bash
lnk init
lnk add ~/.vimrc ~/.bashrc
lnk add ~/.vimrc ~/.bashrc # Common config
lnk add --host work ~/.ssh/config # Host-specific config
lnk push "setup"
```
@@ -49,25 +50,83 @@ lnk init -r git@github.com:user/dotfiles.git
### Daily workflow
```bash
# Add files/directories
# Add files/directories (common config)
lnk add ~/.vimrc ~/.config/nvim ~/.gitconfig
# Add host-specific files
lnk add --host laptop ~/.ssh/config
lnk add --host work ~/.gitconfig
# List managed files
lnk list # Common config only
lnk list --host laptop # Laptop-specific config
lnk list --all # All configurations
# Check status
lnk status
# Sync changes
lnk push "updated vim config"
lnk pull
lnk pull # Pull common config
lnk pull --host laptop # Pull laptop-specific config
```
## How it works
```
Common files:
Before: ~/.vimrc (file)
After: ~/.vimrc -> ~/.config/lnk/.vimrc (symlink)
Host-specific files:
Before: ~/.ssh/config (file)
After: ~/.ssh/config -> ~/.config/lnk/laptop.lnk/.ssh/config (symlink)
```
Your files live in `~/.config/lnk` (a Git repo). 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.
## Multihost Support
Lnk supports both **common configurations** (shared across all machines) and **host-specific configurations** (unique per machine).
### File Organization
```
~/.config/lnk/
├── .lnk # Tracks common files
├── .lnk.laptop # Tracks laptop-specific files
├── .lnk.work # Tracks work-specific files
├── .vimrc # Common file
├── .gitconfig # Common file
├── laptop.lnk/ # Laptop-specific storage
│ ├── .ssh/
│ │ └── config
│ └── .tmux.conf
└── work.lnk/ # Work-specific storage
├── .ssh/
│ └── config
└── .gitconfig
```
### Usage Patterns
```bash
# Common config (shared everywhere)
lnk add ~/.vimrc ~/.bashrc ~/.gitconfig
# Host-specific config (unique per machine)
lnk add --host $(hostname) ~/.ssh/config
lnk add --host work ~/.gitconfig
# List configurations
lnk list # Common only
lnk list --host work # Work host only
lnk list --all # Everything
# Pull configurations
lnk pull # Common config
lnk pull --host work # Work-specific config
```
## Why not just Git?
@@ -84,7 +143,13 @@ You could `git init ~/.config/lnk` and manually symlink everything. Lnk just aut
```bash
lnk init -r git@github.com:you/dotfiles.git
# Add common config (shared across all machines)
lnk add ~/.bashrc ~/.vimrc ~/.gitconfig
# Add host-specific config
lnk add --host $(hostname) ~/.ssh/config ~/.tmux.conf
lnk push "initial setup"
```
@@ -92,56 +157,76 @@ lnk push "initial setup"
```bash
lnk init -r git@github.com:you/dotfiles.git
lnk pull # auto-creates symlinks
# Pull common config
lnk pull
# Pull host-specific config (if it exists)
lnk pull --host $(hostname)
```
### Daily edits
```bash
vim ~/.vimrc # edit normally
lnk list # see common config
lnk list --host $(hostname) # see host-specific config
lnk list --all # see everything
lnk status # check what changed
lnk push "new plugins" # commit & push
```
### Multi-machine workflow
```bash
# On your laptop
lnk add --host laptop ~/.ssh/config
lnk add ~/.vimrc # Common config
lnk push "laptop ssh config"
# On your work machine
lnk pull # Get common config
lnk add --host work ~/.gitconfig
lnk push "work git config"
# Back on laptop
lnk pull # Get updates (work config won't affect laptop)
```
## Commands
- `lnk init [-r remote]` - Create repo
- `lnk add <files>` - Move files to repo, create symlinks
- `lnk rm <files>` - Move files back, remove symlinks
- `lnk add [--host HOST] <files>` - Move files to repo, create symlinks
- `lnk rm [--host HOST] <files>` - Move files back, remove symlinks
- `lnk list [--host HOST] [--all]` - List files managed by lnk
- `lnk status` - Git status + sync info
- `lnk push [msg]` - Stage all, commit, push
- `lnk pull` - Pull + restore missing symlinks
- `lnk pull [--host HOST]` - Pull + restore missing symlinks
### Command Options
- `--host HOST` - Manage files for specific host (default: common configuration)
- `--all` - Show all configurations (common + all hosts) when listing
- `-r, --remote URL` - Clone from remote URL when initializing
## Technical bits
- **Single binary** (~8MB, no deps)
- **Relative symlinks** (portable)
- **XDG compliant** (`~/.config/lnk`)
- **Multihost support** (common + host-specific configs)
- **Git-native** (standard Git repo, no special formats)
## Alternatives
| Tool | Complexity | Why choose it |
| ------- | ---------- | ------------------------------------- |
| **lnk** | Minimal | Just works, no config, Git-native |
| ------- | ---------- | -------------------------------------------- |
| **lnk** | Minimal | Just works, no config, Git-native, multihost |
| chezmoi | High | Templates, encryption, cross-platform |
| yadm | Medium | Git power user, encryption |
| dotbot | Low | YAML config, basic features |
| stow | Low | Perl, symlink only |
## FAQ
**Q: What if I already have dotfiles in Git?**
A: `git clone your-repo ~/.config/lnk && lnk add ~/.vimrc` (adopts existing files)
**Q: How do I handle machine-specific configs?**
A: Git branches, or just don't manage machine-specific files with lnk
**Q: Windows support?**
A: Symlinks work on Windows 10+, but untested
**Q: Production ready?**
A: I use it daily. It won't break your files. API might change (pre-1.0).
## Contributing
```bash

View File

@@ -1,7 +1,6 @@
package cmd
import (
"fmt"
"path/filepath"
"github.com/spf13/cobra"
@@ -9,25 +8,36 @@ import (
)
func newAddCmd() *cobra.Command {
return &cobra.Command{
cmd := &cobra.Command{
Use: "add <file>",
Short: "✨ Add a file to lnk management",
Long: "Moves a file to the lnk repository and creates a symlink in its place.",
Args: cobra.ExactArgs(1),
SilenceUsage: true,
SilenceErrors: true,
RunE: func(cmd *cobra.Command, args []string) error {
filePath := args[0]
host, _ := cmd.Flags().GetString("host")
lnk := core.NewLnk(core.WithHost(host))
lnk := core.NewLnk()
if err := lnk.Add(filePath); err != nil {
return fmt.Errorf("failed to add file: %w", err)
return err
}
basename := filepath.Base(filePath)
if host != "" {
printf(cmd, "✨ \033[1mAdded %s to lnk (host: %s)\033[0m\n", basename, host)
printf(cmd, " 🔗 \033[90m%s\033[0m → \033[36m~/.config/lnk/%s.lnk/%s\033[0m\n", filePath, host, filePath)
} else {
printf(cmd, "✨ \033[1mAdded %s to lnk\033[0m\n", basename)
printf(cmd, " 🔗 \033[90m%s\033[0m → \033[36m~/.config/lnk/%s\033[0m\n", filePath, basename)
printf(cmd, " 🔗 \033[90m%s\033[0m → \033[36m~/.config/lnk/%s\033[0m\n", filePath, filePath)
}
printf(cmd, " 📝 Use \033[1mlnk push\033[0m to sync to remote\n")
return nil
},
}
cmd.Flags().StringP("host", "H", "", "Manage file for specific host (default: common configuration)")
return cmd
}

View File

@@ -1,8 +1,6 @@
package cmd
import (
"fmt"
"github.com/spf13/cobra"
"github.com/yarlson/lnk/internal/core"
)
@@ -13,12 +11,13 @@ func newInitCmd() *cobra.Command {
Short: "🎯 Initialize a new lnk repository",
Long: "Creates the lnk directory and initializes a Git repository for managing dotfiles.",
SilenceUsage: true,
SilenceErrors: true,
RunE: func(cmd *cobra.Command, args []string) error {
remote, _ := cmd.Flags().GetString("remote")
lnk := core.NewLnk()
if err := lnk.InitWithRemote(remote); err != nil {
return fmt.Errorf("failed to initialize lnk: %w", err)
return err
}
if remote != "" {

193
cmd/list.go Normal file
View File

@@ -0,0 +1,193 @@
package cmd
import (
"os"
"path/filepath"
"strings"
"github.com/spf13/cobra"
"github.com/yarlson/lnk/internal/core"
)
func newListCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "list",
Short: "📋 List files managed by lnk",
Long: "Display all files and directories currently managed by lnk.",
SilenceUsage: true,
SilenceErrors: true,
RunE: func(cmd *cobra.Command, args []string) error {
host, _ := cmd.Flags().GetString("host")
all, _ := cmd.Flags().GetBool("all")
if host != "" {
// Show specific host configuration
return listHostConfig(cmd, host)
}
if all {
// Show all configurations (common + all hosts)
return listAllConfigs(cmd)
}
// Default: show common configuration
return listCommonConfig(cmd)
},
}
cmd.Flags().StringP("host", "H", "", "List files for specific host")
cmd.Flags().BoolP("all", "a", false, "List files for all hosts and common configuration")
return cmd
}
func listCommonConfig(cmd *cobra.Command) error {
lnk := core.NewLnk()
managedItems, err := lnk.List()
if err != nil {
return err
}
if len(managedItems) == 0 {
printf(cmd, "📋 \033[1mNo files currently managed by lnk (common)\033[0m\n")
printf(cmd, " 💡 Use \033[1mlnk add <file>\033[0m to start managing files\n")
return nil
}
printf(cmd, "📋 \033[1mFiles managed by lnk (common)\033[0m (\033[36m%d item", len(managedItems))
if len(managedItems) > 1 {
printf(cmd, "s")
}
printf(cmd, "\033[0m):\n\n")
for _, item := range managedItems {
printf(cmd, " 🔗 \033[36m%s\033[0m\n", item)
}
printf(cmd, "\n💡 Use \033[1mlnk status\033[0m to check sync status\n")
return nil
}
func listHostConfig(cmd *cobra.Command, host string) error {
lnk := core.NewLnk(core.WithHost(host))
managedItems, err := lnk.List()
if err != nil {
return err
}
if len(managedItems) == 0 {
printf(cmd, "📋 \033[1mNo files currently managed by lnk (host: %s)\033[0m\n", host)
printf(cmd, " 💡 Use \033[1mlnk add --host %s <file>\033[0m to start managing files\n", host)
return nil
}
printf(cmd, "📋 \033[1mFiles managed by lnk (host: %s)\033[0m (\033[36m%d item", host, len(managedItems))
if len(managedItems) > 1 {
printf(cmd, "s")
}
printf(cmd, "\033[0m):\n\n")
for _, item := range managedItems {
printf(cmd, " 🔗 \033[36m%s\033[0m\n", item)
}
printf(cmd, "\n💡 Use \033[1mlnk status\033[0m to check sync status\n")
return nil
}
func listAllConfigs(cmd *cobra.Command) error {
// List common configuration
printf(cmd, "📋 \033[1mAll configurations managed by lnk\033[0m\n\n")
lnk := core.NewLnk()
commonItems, err := lnk.List()
if err != nil {
return err
}
printf(cmd, "🌐 \033[1mCommon configuration\033[0m (\033[36m%d item", len(commonItems))
if len(commonItems) > 1 {
printf(cmd, "s")
}
printf(cmd, "\033[0m):\n")
if len(commonItems) == 0 {
printf(cmd, " \033[90m(no files)\033[0m\n")
} else {
for _, item := range commonItems {
printf(cmd, " 🔗 \033[36m%s\033[0m\n", item)
}
}
// Find all host-specific configurations
hosts, err := findHostConfigs()
if err != nil {
return err
}
for _, host := range hosts {
printf(cmd, "\n🖥 \033[1mHost: %s\033[0m", host)
hostLnk := core.NewLnk(core.WithHost(host))
hostItems, err := hostLnk.List()
if err != nil {
printf(cmd, " \033[31m(error: %v)\033[0m\n", err)
continue
}
printf(cmd, " (\033[36m%d item", len(hostItems))
if len(hostItems) > 1 {
printf(cmd, "s")
}
printf(cmd, "\033[0m):\n")
if len(hostItems) == 0 {
printf(cmd, " \033[90m(no files)\033[0m\n")
} else {
for _, item := range hostItems {
printf(cmd, " 🔗 \033[36m%s\033[0m\n", item)
}
}
}
printf(cmd, "\n💡 Use \033[1mlnk list --host <hostname>\033[0m to see specific host configuration\n")
return nil
}
func findHostConfigs() ([]string, error) {
repoPath := getRepoPath()
// Check if repo exists
if _, err := os.Stat(repoPath); os.IsNotExist(err) {
return []string{}, nil
}
entries, err := os.ReadDir(repoPath)
if err != nil {
return nil, err
}
var hosts []string
for _, entry := range entries {
name := entry.Name()
// Look for .lnk.<hostname> files
if strings.HasPrefix(name, ".lnk.") && name != ".lnk" {
host := strings.TrimPrefix(name, ".lnk.")
hosts = append(hosts, host)
}
}
return hosts, nil
}
func getRepoPath() string {
xdgConfig := os.Getenv("XDG_CONFIG_HOME")
if xdgConfig == "" {
homeDir, err := os.UserHomeDir()
if err != nil {
xdgConfig = "."
} else {
xdgConfig = filepath.Join(homeDir, ".config")
}
}
return filepath.Join(xdgConfig, "lnk")
}

View File

@@ -1,27 +1,33 @@
package cmd
import (
"fmt"
"github.com/spf13/cobra"
"github.com/yarlson/lnk/internal/core"
)
func newPullCmd() *cobra.Command {
return &cobra.Command{
cmd := &cobra.Command{
Use: "pull",
Short: "⬇️ Pull changes from remote and restore symlinks",
Long: "Fetches changes from remote repository and automatically restores symlinks for all managed files.",
SilenceUsage: true,
SilenceErrors: true,
RunE: func(cmd *cobra.Command, args []string) error {
lnk := core.NewLnk()
host, _ := cmd.Flags().GetString("host")
lnk := core.NewLnk(core.WithHost(host))
restored, err := lnk.Pull()
if err != nil {
return fmt.Errorf("failed to pull changes: %w", err)
return err
}
if len(restored) > 0 {
if host != "" {
printf(cmd, "⬇️ \033[1;32mSuccessfully pulled changes (host: %s)\033[0m\n", host)
} else {
printf(cmd, "⬇️ \033[1;32mSuccessfully pulled changes\033[0m\n")
}
printf(cmd, " 🔗 Restored \033[1m%d symlink", len(restored))
if len(restored) > 1 {
printf(cmd, "s")
@@ -31,8 +37,12 @@ func newPullCmd() *cobra.Command {
printf(cmd, " ✨ \033[36m%s\033[0m\n", file)
}
printf(cmd, "\n 🎉 Your dotfiles are synced and ready!\n")
} else {
if host != "" {
printf(cmd, "⬇️ \033[1;32mSuccessfully pulled changes (host: %s)\033[0m\n", host)
} else {
printf(cmd, "⬇️ \033[1;32mSuccessfully pulled changes\033[0m\n")
}
printf(cmd, " ✅ All symlinks already in place\n")
printf(cmd, " 🎉 Everything is up to date!\n")
}
@@ -40,4 +50,7 @@ func newPullCmd() *cobra.Command {
return nil
},
}
cmd.Flags().StringP("host", "H", "", "Pull and restore symlinks for specific host (default: common configuration)")
return cmd
}

View File

@@ -1,8 +1,6 @@
package cmd
import (
"fmt"
"github.com/spf13/cobra"
"github.com/yarlson/lnk/internal/core"
)
@@ -14,6 +12,7 @@ func newPushCmd() *cobra.Command {
Long: "Stages all changes, creates a sync commit with the provided message, and pushes to remote.",
Args: cobra.MaximumNArgs(1),
SilenceUsage: true,
SilenceErrors: true,
RunE: func(cmd *cobra.Command, args []string) error {
message := "lnk: sync configuration files"
if len(args) > 0 {
@@ -22,7 +21,7 @@ func newPushCmd() *cobra.Command {
lnk := core.NewLnk()
if err := lnk.Push(message); err != nil {
return fmt.Errorf("failed to push changes: %w", err)
return err
}
printf(cmd, "🚀 \033[1;32mSuccessfully pushed changes\033[0m\n")

View File

@@ -1,7 +1,6 @@
package cmd
import (
"fmt"
"path/filepath"
"github.com/spf13/cobra"
@@ -9,25 +8,36 @@ import (
)
func newRemoveCmd() *cobra.Command {
return &cobra.Command{
cmd := &cobra.Command{
Use: "rm <file>",
Short: "🗑️ Remove a file from lnk management",
Long: "Removes a symlink and restores the original file from the lnk repository.",
Args: cobra.ExactArgs(1),
SilenceUsage: true,
SilenceErrors: true,
RunE: func(cmd *cobra.Command, args []string) error {
filePath := args[0]
host, _ := cmd.Flags().GetString("host")
lnk := core.NewLnk(core.WithHost(host))
lnk := core.NewLnk()
if err := lnk.Remove(filePath); err != nil {
return fmt.Errorf("failed to remove file: %w", err)
return err
}
basename := filepath.Base(filePath)
if host != "" {
printf(cmd, "🗑️ \033[1mRemoved %s from lnk (host: %s)\033[0m\n", basename, host)
printf(cmd, " ↩️ \033[90m~/.config/lnk/%s.lnk/%s\033[0m → \033[36m%s\033[0m\n", host, basename, filePath)
} else {
printf(cmd, "🗑️ \033[1mRemoved %s from lnk\033[0m\n", basename)
printf(cmd, " ↩️ \033[90m~/.config/lnk/%s\033[0m → \033[36m%s\033[0m\n", basename, filePath)
}
printf(cmd, " 📄 Original file restored\n")
return nil
},
}
cmd.Flags().StringP("host", "H", "", "Remove file from specific host configuration (default: common configuration)")
return cmd
}

View File

@@ -20,17 +20,20 @@ func NewRootCommand() *cobra.Command {
Long: `🔗 Lnk - Git-native dotfiles management that doesn't suck.
Move your dotfiles to ~/.config/lnk, symlink them back, and use Git like normal.
That's it.
Supports both common configurations and host-specific setups.
✨ Examples:
lnk init # Fresh start
lnk init -r <repo-url> # Clone existing dotfiles
lnk add ~/.vimrc ~/.bashrc # Start managing files
lnk add ~/.vimrc ~/.bashrc # Start managing common files
lnk add --host work ~/.ssh/config # Manage host-specific files
lnk list --all # Show all configurations
lnk pull --host work # Pull host-specific changes
lnk push "setup complete" # Sync to remote
lnk pull # Get latest changes
🎯 Simple, fast, and Git-native.`,
🎯 Simple, fast, Git-native, and multi-host ready.`,
SilenceUsage: true,
SilenceErrors: true,
Version: fmt.Sprintf("%s (built %s)", version, buildTime),
}
@@ -38,6 +41,7 @@ That's it.
rootCmd.AddCommand(newInitCmd())
rootCmd.AddCommand(newAddCmd())
rootCmd.AddCommand(newRemoveCmd())
rootCmd.AddCommand(newListCmd())
rootCmd.AddCommand(newStatusCmd())
rootCmd.AddCommand(newPushCmd())
rootCmd.AddCommand(newPullCmd())
@@ -54,7 +58,7 @@ func SetVersion(v, bt string) {
func Execute() {
rootCmd := NewRootCommand()
if err := rootCmd.Execute(); err != nil {
fmt.Fprintln(os.Stderr, err)
_, _ = fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}

View File

@@ -5,7 +5,6 @@ import (
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"github.com/stretchr/testify/suite"
@@ -32,8 +31,11 @@ func (suite *CLITestSuite) SetupTest() {
err = os.Chdir(tempDir)
suite.Require().NoError(err)
// Set XDG_CONFIG_HOME to temp directory
suite.T().Setenv("XDG_CONFIG_HOME", tempDir)
// Set HOME to temp directory for consistent relative path calculation
suite.T().Setenv("HOME", tempDir)
// Set XDG_CONFIG_HOME to tempDir/.config for config files
suite.T().Setenv("XDG_CONFIG_HOME", filepath.Join(tempDir, ".config"))
// Capture output
suite.stdout = &bytes.Buffer{}
@@ -67,20 +69,13 @@ func (suite *CLITestSuite) TestInitCommand() {
suite.Contains(output, "lnk add <file>")
// Verify actual effect
lnkDir := filepath.Join(suite.tempDir, "lnk")
lnkDir := filepath.Join(suite.tempDir, ".config", "lnk")
suite.DirExists(lnkDir)
gitDir := filepath.Join(lnkDir, ".git")
suite.DirExists(gitDir)
}
func (suite *CLITestSuite) TestInitWithRemote() {
err := suite.runCommand("init", "-r", "https://github.com/user/dotfiles.git")
// This will fail because we don't have a real remote, but that's expected
suite.Error(err)
suite.Contains(err.Error(), "git clone failed")
}
func (suite *CLITestSuite) TestAddCommand() {
// Initialize first
err := suite.runCommand("init")
@@ -107,19 +102,21 @@ func (suite *CLITestSuite) TestAddCommand() {
suite.NoError(err)
suite.Equal(os.ModeSymlink, info.Mode()&os.ModeSymlink)
// Verify some file exists in repo with .bashrc in the name
lnkDir := filepath.Join(suite.tempDir, "lnk")
entries, err := os.ReadDir(lnkDir)
suite.NoError(err)
// Verify the file exists in repo with preserved directory structure
lnkDir := filepath.Join(suite.tempDir, ".config", "lnk")
repoFile := filepath.Join(lnkDir, ".bashrc")
suite.FileExists(repoFile)
found := false
for _, entry := range entries {
if strings.Contains(entry.Name(), ".bashrc") && entry.Name() != ".lnk" {
found = true
break
}
}
suite.True(found, "Repository should contain a file with .bashrc in the name")
// Verify content is preserved in storage
storedContent, err := os.ReadFile(repoFile)
suite.NoError(err)
suite.Equal("export PATH=/usr/local/bin:$PATH", string(storedContent))
// Verify .lnk file contains the correct entry
lnkFile := filepath.Join(lnkDir, ".lnk")
lnkContent, err := os.ReadFile(lnkFile)
suite.NoError(err)
suite.Equal(".bashrc\n", string(lnkContent))
}
func (suite *CLITestSuite) TestRemoveCommand() {
@@ -160,7 +157,82 @@ func (suite *CLITestSuite) TestStatusCommand() {
// Test status without remote - should fail
err = suite.runCommand("status")
suite.Error(err)
suite.Contains(err.Error(), "no remote configured")
suite.Contains(err.Error(), "No remote repository is configured")
}
func (suite *CLITestSuite) TestListCommand() {
// Test list without init - should fail
err := suite.runCommand("list")
suite.Error(err)
suite.Contains(err.Error(), "Lnk repository not initialized")
// Initialize first
err = suite.runCommand("init")
suite.Require().NoError(err)
suite.stdout.Reset()
// Test list with no managed files
err = suite.runCommand("list")
suite.NoError(err)
output := suite.stdout.String()
suite.Contains(output, "No files currently managed by lnk")
suite.Contains(output, "lnk add <file>")
suite.stdout.Reset()
// 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.Require().NoError(err)
suite.stdout.Reset()
// Test list with one managed file
err = suite.runCommand("list")
suite.NoError(err)
output = suite.stdout.String()
suite.Contains(output, "Files managed by lnk")
suite.Contains(output, "1 item")
suite.Contains(output, ".bashrc")
suite.stdout.Reset()
// Add another file
testFile2 := filepath.Join(suite.tempDir, ".vimrc")
err = os.WriteFile(testFile2, []byte("set number"), 0644)
suite.Require().NoError(err)
err = suite.runCommand("add", testFile2)
suite.Require().NoError(err)
suite.stdout.Reset()
// Test list with multiple managed files
err = suite.runCommand("list")
suite.NoError(err)
output = suite.stdout.String()
suite.Contains(output, "Files managed by lnk")
suite.Contains(output, "2 items")
suite.Contains(output, ".bashrc")
suite.Contains(output, ".vimrc")
// Verify both files exist in storage with correct content
lnkDir := filepath.Join(suite.tempDir, ".config", "lnk")
bashrcStorage := filepath.Join(lnkDir, ".bashrc")
suite.FileExists(bashrcStorage)
bashrcContent, err := os.ReadFile(bashrcStorage)
suite.NoError(err)
suite.Equal("export PATH=/usr/local/bin:$PATH", string(bashrcContent))
vimrcStorage := filepath.Join(lnkDir, ".vimrc")
suite.FileExists(vimrcStorage)
vimrcContent, err := os.ReadFile(vimrcStorage)
suite.NoError(err)
suite.Equal("set number", string(vimrcContent))
// Verify .lnk file contains both entries (sorted)
lnkFile := filepath.Join(lnkDir, ".lnk")
lnkContent, err := os.ReadFile(lnkFile)
suite.NoError(err)
suite.Equal(".bashrc\n.vimrc\n", string(lnkContent))
}
func (suite *CLITestSuite) TestErrorHandling() {
@@ -175,7 +247,7 @@ func (suite *CLITestSuite) TestErrorHandling() {
name: "add nonexistent file",
args: []string{"add", "/nonexistent/file"},
wantErr: true,
errContains: "File does not exist",
errContains: "File or directory not found",
},
{
name: "status without init",
@@ -207,6 +279,12 @@ func (suite *CLITestSuite) TestErrorHandling() {
wantErr: false,
outContains: "Moves a file to the lnk repository",
},
{
name: "list help",
args: []string{"list", "--help"},
wantErr: false,
outContains: "Display all files and directories",
},
}
for _, tt := range tests {
@@ -250,29 +328,57 @@ func (suite *CLITestSuite) TestCompleteWorkflow() {
},
{
name: "add config file",
args: []string{"add", ".bashrc"},
args: []string{"add", filepath.Join(suite.tempDir, ".bashrc")},
setup: func() {
testFile := filepath.Join(suite.tempDir, ".bashrc")
_ = os.WriteFile(testFile, []byte("export PATH=/usr/local/bin:$PATH"), 0644)
},
verify: func(output string) {
suite.Contains(output, "Added .bashrc to lnk")
// Verify storage and .lnk file
lnkDir := filepath.Join(suite.tempDir, ".config", "lnk")
bashrcStorage := filepath.Join(lnkDir, ".bashrc")
suite.FileExists(bashrcStorage)
storedContent, err := os.ReadFile(bashrcStorage)
suite.NoError(err)
suite.Equal("export PATH=/usr/local/bin:$PATH", string(storedContent))
lnkFile := filepath.Join(lnkDir, ".lnk")
lnkContent, err := os.ReadFile(lnkFile)
suite.NoError(err)
suite.Equal(".bashrc\n", string(lnkContent))
},
},
{
name: "add another file",
args: []string{"add", ".vimrc"},
args: []string{"add", filepath.Join(suite.tempDir, ".vimrc")},
setup: func() {
testFile := filepath.Join(suite.tempDir, ".vimrc")
_ = os.WriteFile(testFile, []byte("set number"), 0644)
},
verify: func(output string) {
suite.Contains(output, "Added .vimrc to lnk")
// Verify storage and .lnk file now contains both files
lnkDir := filepath.Join(suite.tempDir, ".config", "lnk")
vimrcStorage := filepath.Join(lnkDir, ".vimrc")
suite.FileExists(vimrcStorage)
storedContent, err := os.ReadFile(vimrcStorage)
suite.NoError(err)
suite.Equal("set number", string(storedContent))
lnkFile := filepath.Join(lnkDir, ".lnk")
lnkContent, err := os.ReadFile(lnkFile)
suite.NoError(err)
suite.Equal(".bashrc\n.vimrc\n", string(lnkContent))
},
},
{
name: "remove file",
args: []string{"rm", ".vimrc"},
args: []string{"rm", filepath.Join(suite.tempDir, ".vimrc")},
verify: func(output string) {
suite.Contains(output, "Removed .vimrc from lnk")
},
@@ -319,10 +425,10 @@ func (suite *CLITestSuite) TestAddDirectory() {
suite.stdout.Reset()
// Create a directory with files
testDir := filepath.Join(suite.tempDir, ".config")
testDir := filepath.Join(suite.tempDir, ".ssh")
_ = os.MkdirAll(testDir, 0755)
configFile := filepath.Join(testDir, "app.conf")
_ = os.WriteFile(configFile, []byte("setting=value"), 0644)
configFile := filepath.Join(testDir, "config")
_ = os.WriteFile(configFile, []byte("Host example.com"), 0644)
// Add the directory
err := suite.runCommand("add", testDir)
@@ -330,26 +436,30 @@ func (suite *CLITestSuite) TestAddDirectory() {
// Check output
output := suite.stdout.String()
suite.Contains(output, "Added .config to lnk")
suite.Contains(output, "Added .ssh to lnk")
// Verify directory is now a symlink
info, err := os.Lstat(testDir)
suite.NoError(err)
suite.Equal(os.ModeSymlink, info.Mode()&os.ModeSymlink)
// Verify some directory exists in repo with .config in the name
lnkDir := filepath.Join(suite.tempDir, "lnk")
entries, err := os.ReadDir(lnkDir)
suite.NoError(err)
// Verify the directory exists in repo with preserved directory structure
lnkDir := filepath.Join(suite.tempDir, ".config", "lnk")
repoDir := filepath.Join(lnkDir, ".ssh")
suite.DirExists(repoDir)
found := false
for _, entry := range entries {
if strings.Contains(entry.Name(), ".config") && entry.Name() != ".lnk" {
found = true
break
}
}
suite.True(found, "Repository should contain a directory with .config in the name")
// Verify directory content is preserved
repoConfigFile := filepath.Join(repoDir, "config")
suite.FileExists(repoConfigFile)
storedContent, err := os.ReadFile(repoConfigFile)
suite.NoError(err)
suite.Equal("Host example.com", string(storedContent))
// Verify .lnk file contains the directory entry
lnkFile := filepath.Join(lnkDir, ".lnk")
lnkContent, err := os.ReadFile(lnkFile)
suite.NoError(err)
suite.Equal(".ssh\n", string(lnkContent))
}
func (suite *CLITestSuite) TestSameBasenameFilesBug() {
@@ -400,6 +510,27 @@ func (suite *CLITestSuite) TestSameBasenameFilesBug() {
suite.Equal(contentA, string(contentAfterAddA), "First file should keep its original content")
suite.Equal(contentB, string(contentAfterAddB), "Second file should keep its original content")
// Verify both files exist in storage with correct paths and content
lnkDir := filepath.Join(suite.tempDir, ".config", "lnk")
storageFileA := filepath.Join(lnkDir, "a", "config.json")
suite.FileExists(storageFileA)
storedContentA, err := os.ReadFile(storageFileA)
suite.NoError(err)
suite.Equal(contentA, string(storedContentA))
storageFileB := filepath.Join(lnkDir, "b", "config.json")
suite.FileExists(storageFileB)
storedContentB, err := os.ReadFile(storageFileB)
suite.NoError(err)
suite.Equal(contentB, string(storedContentB))
// Verify .lnk file contains both entries with correct relative paths
lnkFile := filepath.Join(lnkDir, ".lnk")
lnkContent, err := os.ReadFile(lnkFile)
suite.NoError(err)
suite.Equal("a/config.json\nb/config.json\n", string(lnkContent))
// Both files should be removable independently
suite.stdout.Reset()
err = suite.runCommand("rm", fileA)
@@ -440,8 +571,21 @@ func (suite *CLITestSuite) TestStatusDirtyRepo() {
suite.Require().NoError(err)
suite.stdout.Reset()
// Verify file is stored correctly
lnkDir := filepath.Join(suite.tempDir, ".config", "lnk")
storageFile := filepath.Join(lnkDir, "a")
suite.FileExists(storageFile)
storedContent, err := os.ReadFile(storageFile)
suite.NoError(err)
suite.Equal("abc", string(storedContent))
// Verify .lnk file contains the entry
lnkFile := filepath.Join(lnkDir, ".lnk")
lnkContent, err := os.ReadFile(lnkFile)
suite.NoError(err)
suite.Equal("a\n", string(lnkContent))
// Add a remote so status works
lnkDir := filepath.Join(suite.tempDir, "lnk")
cmd := exec.Command("git", "remote", "add", "origin", "https://github.com/test/dotfiles.git")
cmd.Dir = lnkDir
err = cmd.Run()
@@ -468,6 +612,140 @@ func (suite *CLITestSuite) TestStatusDirtyRepo() {
suite.Contains(output, "lnk push")
}
func (suite *CLITestSuite) TestMultihostCommands() {
// Initialize repository
err := suite.runCommand("init")
suite.Require().NoError(err)
suite.stdout.Reset()
// Create test files
testFile1 := filepath.Join(suite.tempDir, ".bashrc")
err = os.WriteFile(testFile1, []byte("export PATH=/usr/local/bin:$PATH"), 0644)
suite.Require().NoError(err)
testFile2 := filepath.Join(suite.tempDir, ".vimrc")
err = os.WriteFile(testFile2, []byte("set number"), 0644)
suite.Require().NoError(err)
// Add file to common configuration
err = suite.runCommand("add", testFile1)
suite.NoError(err)
output := suite.stdout.String()
suite.Contains(output, "Added .bashrc to lnk")
suite.NotContains(output, "host:")
suite.stdout.Reset()
// Add file to host-specific configuration
err = suite.runCommand("add", "--host", "workstation", testFile2)
suite.NoError(err)
output = suite.stdout.String()
suite.Contains(output, "Added .vimrc to lnk (host: workstation)")
suite.Contains(output, "workstation.lnk")
suite.stdout.Reset()
// Verify storage paths and .lnk files for both common and host-specific
lnkDir := filepath.Join(suite.tempDir, ".config", "lnk")
// Verify common file storage and tracking
commonStorage := filepath.Join(lnkDir, ".bashrc")
suite.FileExists(commonStorage)
commonContent, err := os.ReadFile(commonStorage)
suite.NoError(err)
suite.Equal("export PATH=/usr/local/bin:$PATH", string(commonContent))
commonLnkFile := filepath.Join(lnkDir, ".lnk")
commonLnkContent, err := os.ReadFile(commonLnkFile)
suite.NoError(err)
suite.Equal(".bashrc\n", string(commonLnkContent))
// Verify host-specific file storage and tracking
hostStorage := filepath.Join(lnkDir, "workstation.lnk", ".vimrc")
suite.FileExists(hostStorage)
hostContent, err := os.ReadFile(hostStorage)
suite.NoError(err)
suite.Equal("set number", string(hostContent))
hostLnkFile := filepath.Join(lnkDir, ".lnk.workstation")
hostLnkContent, err := os.ReadFile(hostLnkFile)
suite.NoError(err)
suite.Equal(".vimrc\n", string(hostLnkContent))
// Test list command - common only
err = suite.runCommand("list")
suite.NoError(err)
output = suite.stdout.String()
suite.Contains(output, "Files managed by lnk (common)")
suite.Contains(output, ".bashrc")
suite.NotContains(output, ".vimrc")
suite.stdout.Reset()
// Test list command - specific host
err = suite.runCommand("list", "--host", "workstation")
suite.NoError(err)
output = suite.stdout.String()
suite.Contains(output, "Files managed by lnk (host: workstation)")
suite.Contains(output, ".vimrc")
suite.NotContains(output, ".bashrc")
suite.stdout.Reset()
// Test list command - all configurations
err = suite.runCommand("list", "--all")
suite.NoError(err)
output = suite.stdout.String()
suite.Contains(output, "All configurations managed by lnk")
suite.Contains(output, "Common configuration")
suite.Contains(output, "Host: workstation")
suite.Contains(output, ".bashrc")
suite.Contains(output, ".vimrc")
suite.stdout.Reset()
// Test remove from host-specific
err = suite.runCommand("rm", "--host", "workstation", testFile2)
suite.NoError(err)
output = suite.stdout.String()
suite.Contains(output, "Removed .vimrc from lnk (host: workstation)")
suite.stdout.Reset()
// Test remove from common
err = suite.runCommand("rm", testFile1)
suite.NoError(err)
output = suite.stdout.String()
suite.Contains(output, "Removed .bashrc from lnk")
suite.NotContains(output, "host:")
suite.stdout.Reset()
// Verify files are restored
info1, err := os.Lstat(testFile1)
suite.NoError(err)
suite.Equal(os.FileMode(0), info1.Mode()&os.ModeSymlink)
info2, err := os.Lstat(testFile2)
suite.NoError(err)
suite.Equal(os.FileMode(0), info2.Mode()&os.ModeSymlink)
}
func (suite *CLITestSuite) TestMultihostErrorHandling() {
// Initialize repository
err := suite.runCommand("init")
suite.Require().NoError(err)
suite.stdout.Reset()
// Try to remove from non-existent host config
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("rm", "--host", "nonexistent", testFile)
suite.Error(err)
suite.Contains(err.Error(), "File is not managed by lnk")
// Try to list non-existent host config
err = suite.runCommand("list", "--host", "nonexistent")
suite.NoError(err) // Should not error, just show empty
output := suite.stdout.String()
suite.Contains(output, "No files currently managed by lnk (host: nonexistent)")
}
func TestCLISuite(t *testing.T) {
suite.Run(t, new(CLITestSuite))
}

View File

@@ -1,8 +1,6 @@
package cmd
import (
"fmt"
"github.com/spf13/cobra"
"github.com/yarlson/lnk/internal/core"
)
@@ -13,11 +11,12 @@ func newStatusCmd() *cobra.Command {
Short: "📊 Show repository sync status",
Long: "Display how many commits ahead/behind the local repository is relative to the remote and check for uncommitted changes.",
SilenceUsage: true,
SilenceErrors: true,
RunE: func(cmd *cobra.Command, args []string) error {
lnk := core.NewLnk()
status, err := lnk.Status()
if err != nil {
return fmt.Errorf("failed to get status: %w", err)
return err
}
if status.Dirty {

View File

@@ -14,18 +14,44 @@ import (
// Lnk represents the main application logic
type Lnk struct {
repoPath string
host string // Host-specific configuration
git *git.Git
fs *fs.FileSystem
}
// NewLnk creates a new Lnk instance
func NewLnk() *Lnk {
type Option func(*Lnk)
// WithHost sets the host for host-specific configuration
func WithHost(host string) Option {
return func(l *Lnk) {
l.host = host
}
}
// NewLnk creates a new Lnk instance with optional configuration
func NewLnk(opts ...Option) *Lnk {
repoPath := getRepoPath()
return &Lnk{
lnk := &Lnk{
repoPath: repoPath,
host: "",
git: git.New(repoPath),
fs: fs.New(),
}
for _, opt := range opts {
opt(lnk)
}
return lnk
}
// GetCurrentHostname returns the current system hostname
func GetCurrentHostname() (string, error) {
hostname, err := os.Hostname()
if err != nil {
return "", fmt.Errorf("failed to get hostname: %w", err)
}
return hostname, nil
}
// getRepoPath returns the path to the lnk repository directory
@@ -43,12 +69,22 @@ func getRepoPath() string {
return filepath.Join(xdgConfig, "lnk")
}
// generateRepoName creates a unique repository filename from a relative path
func generateRepoName(relativePath string) string {
// Replace slashes and backslashes with underscores to create valid filename
repoName := strings.ReplaceAll(relativePath, "/", "_")
repoName = strings.ReplaceAll(repoName, "\\", "_")
return repoName
// getHostStoragePath returns the storage path for host-specific or common files
func (l *Lnk) getHostStoragePath() string {
if l.host == "" {
// Common configuration - store in root of repo
return l.repoPath
}
// Host-specific configuration - store in host subdirectory
return filepath.Join(l.repoPath, l.host+".lnk")
}
// getLnkFileName returns the appropriate .lnk tracking file name
func (l *Lnk) getLnkFileName() string {
if l.host == "" {
return ".lnk"
}
return ".lnk." + l.host
}
// getRelativePath converts an absolute path to a relative path from home directory
@@ -105,27 +141,17 @@ func (l *Lnk) InitWithRemote(remoteURL string) error {
}
// No existing repository, initialize Git repository
if err := l.git.Init(); err != nil {
return fmt.Errorf("failed to initialize git repository: %w", err)
}
return nil
return l.git.Init()
}
// Clone clones a repository from the given URL
func (l *Lnk) Clone(url string) error {
if err := l.git.Clone(url); err != nil {
return fmt.Errorf("failed to clone repository: %w", err)
}
return nil
return l.git.Clone(url)
}
// AddRemote adds a remote to the repository
func (l *Lnk) AddRemote(name, url string) error {
if err := l.git.AddRemote(name, url); err != nil {
return fmt.Errorf("failed to add remote %s: %w", name, err)
}
return nil
return l.git.AddRemote(name, url)
}
// Add moves a file or directory to the repository and creates a symlink
@@ -147,9 +173,15 @@ func (l *Lnk) Add(filePath string) error {
return fmt.Errorf("failed to get relative path: %w", err)
}
// Generate unique repository name from relative path
repoName := generateRepoName(relativePath)
destPath := filepath.Join(l.repoPath, repoName)
// Generate repository path from relative path
storagePath := l.getHostStoragePath()
destPath := filepath.Join(storagePath, relativePath)
// Ensure destination directory exists (including parent directories for host-specific files)
destDir := filepath.Dir(destPath)
if err := os.MkdirAll(destDir, 0755); err != nil {
return fmt.Errorf("failed to create destination directory: %w", err)
}
// Check if this relative path is already managed
managedItems, err := l.getManagedItems()
@@ -169,77 +201,56 @@ func (l *Lnk) Add(filePath string) error {
}
// Move to repository (handles both files and directories)
if info.IsDir() {
if err := l.fs.MoveDirectory(absPath, destPath); err != nil {
return fmt.Errorf("failed to move directory to repository: %w", err)
}
} else {
if err := l.fs.MoveFile(absPath, destPath); err != nil {
return fmt.Errorf("failed to move file to repository: %w", err)
}
if err := l.fs.Move(absPath, destPath, info); err != nil {
return err
}
// Create symlink
if err := l.fs.CreateSymlink(destPath, absPath); err != nil {
// Try to restore the original if symlink creation fails
if info.IsDir() {
_ = l.fs.MoveDirectory(destPath, absPath) // Ignore error in cleanup
} else {
_ = l.fs.MoveFile(destPath, absPath) // Ignore error in cleanup
}
return fmt.Errorf("failed to create symlink: %w", err)
_ = l.fs.Move(destPath, absPath, info)
return err
}
// Add to .lnk tracking file using relative path
if err := l.addManagedItem(relativePath); err != nil {
// Try to restore the original state if tracking fails
_ = os.Remove(absPath) // Ignore error in cleanup
if info.IsDir() {
_ = l.fs.MoveDirectory(destPath, absPath) // Ignore error in cleanup
} else {
_ = l.fs.MoveFile(destPath, absPath) // Ignore error in cleanup
}
_ = os.Remove(absPath)
_ = l.fs.Move(destPath, absPath, info)
return fmt.Errorf("failed to update tracking file: %w", err)
}
// Add both the item and .lnk file to git in a single commit
if err := l.git.Add(repoName); err != nil {
// Try to restore the original state if git add fails
_ = os.Remove(absPath) // Ignore error in cleanup
_ = l.removeManagedItem(relativePath) // Ignore error in cleanup
if info.IsDir() {
_ = l.fs.MoveDirectory(destPath, absPath) // Ignore error in cleanup
} else {
_ = l.fs.MoveFile(destPath, absPath) // Ignore error in cleanup
// For host-specific files, we need to add the relative path from repo root
gitPath := relativePath
if l.host != "" {
gitPath = filepath.Join(l.host+".lnk", relativePath)
}
return fmt.Errorf("failed to add item to git: %w", err)
if err := l.git.Add(gitPath); err != nil {
// Try to restore the original state if git add fails
_ = os.Remove(absPath)
_ = l.removeManagedItem(relativePath)
_ = l.fs.Move(destPath, absPath, info)
return err
}
// Add .lnk file to the same commit
if err := l.git.Add(".lnk"); err != nil {
if err := l.git.Add(l.getLnkFileName()); err != nil {
// Try to restore the original state if git add fails
_ = os.Remove(absPath) // Ignore error in cleanup
_ = l.removeManagedItem(relativePath) // Ignore error in cleanup
if info.IsDir() {
_ = l.fs.MoveDirectory(destPath, absPath) // Ignore error in cleanup
} else {
_ = l.fs.MoveFile(destPath, absPath) // Ignore error in cleanup
}
return fmt.Errorf("failed to add .lnk file to git: %w", err)
_ = os.Remove(absPath)
_ = l.removeManagedItem(relativePath)
_ = l.fs.Move(destPath, absPath, info)
return err
}
// Commit both changes together
basename := filepath.Base(relativePath)
if err := l.git.Commit(fmt.Sprintf("lnk: added %s", basename)); err != nil {
// Try to restore the original state if commit fails
_ = os.Remove(absPath) // Ignore error in cleanup
_ = l.removeManagedItem(relativePath) // Ignore error in cleanup
if info.IsDir() {
_ = l.fs.MoveDirectory(destPath, absPath) // Ignore error in cleanup
} else {
_ = l.fs.MoveFile(destPath, absPath) // Ignore error in cleanup
}
return fmt.Errorf("failed to commit changes: %w", err)
_ = os.Remove(absPath)
_ = l.removeManagedItem(relativePath)
_ = l.fs.Move(destPath, absPath, info)
return err
}
return nil
@@ -292,8 +303,6 @@ func (l *Lnk) Remove(filePath string) error {
target = filepath.Join(filepath.Dir(absPath), target)
}
repoName := filepath.Base(target)
// Check if target is a directory or file
info, err := os.Stat(target)
if err != nil {
@@ -310,31 +319,29 @@ func (l *Lnk) Remove(filePath string) error {
return fmt.Errorf("failed to update tracking file: %w", err)
}
// Remove from Git first (while the item is still in the repository)
if err := l.git.Remove(repoName); err != nil {
return fmt.Errorf("failed to remove from git: %w", err)
// Generate the correct git path for removal
gitPath := relativePath
if l.host != "" {
gitPath = filepath.Join(l.host+".lnk", relativePath)
}
if err := l.git.Remove(gitPath); err != nil {
return err
}
// Add .lnk file to the same commit
if err := l.git.Add(".lnk"); err != nil {
return fmt.Errorf("failed to add .lnk file to git: %w", err)
if err := l.git.Add(l.getLnkFileName()); err != nil {
return err
}
// Commit both changes together
basename := filepath.Base(relativePath)
if err := l.git.Commit(fmt.Sprintf("lnk: removed %s", basename)); err != nil {
return fmt.Errorf("failed to commit changes: %w", err)
return err
}
// Move back from repository (handles both files and directories)
if info.IsDir() {
if err := l.fs.MoveDirectory(target, absPath); err != nil {
return fmt.Errorf("failed to restore directory: %w", err)
}
} else {
if err := l.fs.MoveFile(target, absPath); err != nil {
return fmt.Errorf("failed to restore file: %w", err)
}
if err := l.fs.Move(target, absPath, info); err != nil {
return err
}
return nil
@@ -362,7 +369,7 @@ func (l *Lnk) Status() (*StatusInfo, error) {
gitStatus, err := l.git.GetStatus()
if err != nil {
return nil, fmt.Errorf("failed to get repository status: %w", err)
return nil, err
}
return &StatusInfo{
@@ -383,28 +390,24 @@ func (l *Lnk) Push(message string) error {
// Check if there are any changes
hasChanges, err := l.git.HasChanges()
if err != nil {
return fmt.Errorf("failed to check for changes: %w", err)
return err
}
if hasChanges {
// Stage all changes
if err := l.git.AddAll(); err != nil {
return fmt.Errorf("failed to stage changes: %w", err)
return err
}
// Create a sync commit
if err := l.git.Commit(message); err != nil {
return fmt.Errorf("failed to commit changes: %w", err)
return err
}
}
// Push to remote (this will be a no-op in tests since we don't have real remotes)
// In real usage, this would push to the actual remote repository
if err := l.git.Push(); err != nil {
return fmt.Errorf("failed to push to remote: %w", err)
}
return nil
return l.git.Push()
}
// Pull fetches changes from remote and restores symlinks as needed
@@ -416,7 +419,7 @@ func (l *Lnk) Pull() ([]string, error) {
// Pull changes from remote (this will be a no-op in tests since we don't have real remotes)
if err := l.git.Pull(); err != nil {
return nil, fmt.Errorf("failed to pull from remote: %w", err)
return nil, err
}
// Find all managed files in the repository and restore symlinks
@@ -428,6 +431,22 @@ func (l *Lnk) Pull() ([]string, error) {
return restored, nil
}
// List returns the list of files and directories currently managed by lnk
func (l *Lnk) List() ([]string, error) {
// Check if repository is initialized
if !l.git.IsGitRepository() {
return nil, fmt.Errorf("❌ Lnk repository not initialized\n 💡 Run \033[1mlnk init\033[0m first")
}
// Get managed items from .lnk file
managedItems, err := l.getManagedItems()
if err != nil {
return nil, fmt.Errorf("failed to get managed items: %w", err)
}
return managedItems, nil
}
// RestoreSymlinks finds all managed items from .lnk file and ensures they have proper symlinks
func (l *Lnk) RestoreSymlinks() ([]string, error) {
var restored []string
@@ -445,8 +464,8 @@ func (l *Lnk) RestoreSymlinks() ([]string, error) {
for _, relativePath := range managedItems {
// Generate repository name from relative path
repoName := generateRepoName(relativePath)
repoItem := filepath.Join(l.repoPath, repoName)
storagePath := l.getHostStoragePath()
repoItem := filepath.Join(storagePath, relativePath)
// Check if item exists in repository
if _, err := os.Stat(repoItem); os.IsNotExist(err) {
@@ -476,7 +495,7 @@ func (l *Lnk) RestoreSymlinks() ([]string, error) {
// Create symlink
if err := l.fs.CreateSymlink(repoItem, symlinkPath); err != nil {
return nil, fmt.Errorf("failed to create symlink for %s: %w", relativePath, err)
return nil, err
}
restored = append(restored, relativePath)
@@ -524,7 +543,7 @@ func (l *Lnk) isValidSymlink(symlinkPath, expectedTarget string) bool {
// getManagedItems returns the list of managed files and directories from .lnk file
func (l *Lnk) getManagedItems() ([]string, error) {
lnkFile := filepath.Join(l.repoPath, ".lnk")
lnkFile := filepath.Join(l.repoPath, l.getLnkFileName())
// If .lnk file doesn't exist, return empty list
if _, err := os.Stat(lnkFile); os.IsNotExist(err) {
@@ -597,7 +616,7 @@ func (l *Lnk) removeManagedItem(relativePath string) error {
// writeManagedItems writes the list of managed items to .lnk file
func (l *Lnk) writeManagedItems(items []string) error {
lnkFile := filepath.Join(l.repoPath, ".lnk")
lnkFile := filepath.Join(l.repoPath, l.getLnkFileName())
content := strings.Join(items, "\n")
if len(items) > 0 {

View File

@@ -82,19 +82,11 @@ func (suite *CoreTestSuite) TestCoreFileOperations() {
suite.Require().NoError(err)
suite.Equal(os.ModeSymlink, info.Mode()&os.ModeSymlink)
// The repository file will have a generated name based on the relative path
// The repository file will preserve the directory structure
lnkDir := filepath.Join(suite.tempDir, "lnk")
entries, err := os.ReadDir(lnkDir)
suite.Require().NoError(err)
var repoFile string
for _, entry := range entries {
if strings.Contains(entry.Name(), ".bashrc") && entry.Name() != ".lnk" {
repoFile = filepath.Join(lnkDir, entry.Name())
break
}
}
suite.NotEmpty(repoFile, "Repository should contain a file with .bashrc in the name")
// Find the .bashrc file in the repository (it should be at the relative path)
repoFile := filepath.Join(lnkDir, suite.tempDir, ".bashrc")
suite.FileExists(repoFile)
// Verify content is preserved
@@ -141,19 +133,11 @@ func (suite *CoreTestSuite) TestCoreDirectoryOperations() {
suite.Require().NoError(err)
suite.Equal(os.ModeSymlink, info.Mode()&os.ModeSymlink)
// Check that some repository directory exists with testdir in the name
// Check that the repository directory preserves the structure
lnkDir := filepath.Join(suite.tempDir, "lnk")
entries, err := os.ReadDir(lnkDir)
suite.Require().NoError(err)
var repoDir string
for _, entry := range entries {
if strings.Contains(entry.Name(), "testdir") && entry.Name() != ".lnk" {
repoDir = filepath.Join(lnkDir, entry.Name())
break
}
}
suite.NotEmpty(repoDir, "Repository should contain a directory with testdir in the name")
// The directory should be at the relative path
repoDir := filepath.Join(lnkDir, suite.tempDir, "testdir")
suite.DirExists(repoDir)
// Remove the directory
@@ -291,7 +275,7 @@ func (suite *CoreTestSuite) TestErrorConditions() {
err = suite.lnk.Add("/nonexistent/file")
suite.Error(err)
suite.Contains(err.Error(), "File does not exist")
suite.Contains(err.Error(), "File or directory not found")
// Test remove unmanaged file
testFile := filepath.Join(suite.tempDir, ".regularfile")
@@ -305,7 +289,7 @@ func (suite *CoreTestSuite) TestErrorConditions() {
// Test status without remote
_, err = suite.lnk.Status()
suite.Error(err)
suite.Contains(err.Error(), "no remote configured")
suite.Contains(err.Error(), "No remote repository is configured")
}
// Test git operations
@@ -516,6 +500,255 @@ func (suite *CoreTestSuite) TestStatusDetectsDirtyRepo() {
suite.True(status.Dirty, "Repository should be dirty after editing managed file")
}
// Test list functionality
func (suite *CoreTestSuite) TestListManagedItems() {
// Test list without init - should fail
_, err := suite.lnk.List()
suite.Error(err)
suite.Contains(err.Error(), "Lnk repository not initialized")
// Initialize repository
err = suite.lnk.Init()
suite.Require().NoError(err)
// Test list with no managed files
items, err := suite.lnk.List()
suite.Require().NoError(err)
suite.Empty(items)
// Add a file
testFile := filepath.Join(suite.tempDir, ".bashrc")
content := "export PATH=$PATH:/usr/local/bin"
err = os.WriteFile(testFile, []byte(content), 0644)
suite.Require().NoError(err)
err = suite.lnk.Add(testFile)
suite.Require().NoError(err)
// Test list with one managed file
items, err = suite.lnk.List()
suite.Require().NoError(err)
suite.Len(items, 1)
suite.Contains(items[0], ".bashrc")
// Add a directory
testDir := filepath.Join(suite.tempDir, ".config")
err = os.MkdirAll(testDir, 0755)
suite.Require().NoError(err)
configFile := filepath.Join(testDir, "app.conf")
err = os.WriteFile(configFile, []byte("setting=value"), 0644)
suite.Require().NoError(err)
err = suite.lnk.Add(testDir)
suite.Require().NoError(err)
// Test list with multiple managed items
items, err = suite.lnk.List()
suite.Require().NoError(err)
suite.Len(items, 2)
// Check that both items are present
found := make(map[string]bool)
for _, item := range items {
if strings.Contains(item, ".bashrc") {
found[".bashrc"] = true
}
if strings.Contains(item, ".config") {
found[".config"] = true
}
}
suite.True(found[".bashrc"], "Should contain .bashrc")
suite.True(found[".config"], "Should contain .config")
// Remove one item and verify list is updated
err = suite.lnk.Remove(testFile)
suite.Require().NoError(err)
items, err = suite.lnk.List()
suite.Require().NoError(err)
suite.Len(items, 1)
suite.Contains(items[0], ".config")
}
// Test multihost functionality
func (suite *CoreTestSuite) TestMultihostFileOperations() {
err := suite.lnk.Init()
suite.Require().NoError(err)
// Create test files for different hosts
testFile1 := filepath.Join(suite.tempDir, ".bashrc")
content1 := "export PATH=$PATH:/usr/local/bin"
err = os.WriteFile(testFile1, []byte(content1), 0644)
suite.Require().NoError(err)
testFile2 := filepath.Join(suite.tempDir, ".vimrc")
content2 := "set number"
err = os.WriteFile(testFile2, []byte(content2), 0644)
suite.Require().NoError(err)
// Add file to common configuration
commonLnk := NewLnk()
err = commonLnk.Add(testFile1)
suite.Require().NoError(err)
// Add file to host-specific configuration
hostLnk := NewLnk(WithHost("workstation"))
err = hostLnk.Add(testFile2)
suite.Require().NoError(err)
// Verify both files are symlinks
info1, err := os.Lstat(testFile1)
suite.Require().NoError(err)
suite.Equal(os.ModeSymlink, info1.Mode()&os.ModeSymlink)
info2, err := os.Lstat(testFile2)
suite.Require().NoError(err)
suite.Equal(os.ModeSymlink, info2.Mode()&os.ModeSymlink)
// Verify common configuration tracking
commonItems, err := commonLnk.List()
suite.Require().NoError(err)
suite.Len(commonItems, 1)
suite.Contains(commonItems[0], ".bashrc")
// Verify host-specific configuration tracking
hostItems, err := hostLnk.List()
suite.Require().NoError(err)
suite.Len(hostItems, 1)
suite.Contains(hostItems[0], ".vimrc")
// Verify files are stored in correct locations
lnkDir := filepath.Join(suite.tempDir, "lnk")
// Common file should be in root
commonFile := filepath.Join(lnkDir, ".lnk")
suite.FileExists(commonFile)
// Host-specific file should be in host subdirectory
hostDir := filepath.Join(lnkDir, "workstation.lnk")
suite.DirExists(hostDir)
hostTrackingFile := filepath.Join(lnkDir, ".lnk.workstation")
suite.FileExists(hostTrackingFile)
// Test removal
err = commonLnk.Remove(testFile1)
suite.Require().NoError(err)
err = hostLnk.Remove(testFile2)
suite.Require().NoError(err)
// Verify files are restored
info1, err = os.Lstat(testFile1)
suite.Require().NoError(err)
suite.Equal(os.FileMode(0), info1.Mode()&os.ModeSymlink)
info2, err = os.Lstat(testFile2)
suite.Require().NoError(err)
suite.Equal(os.FileMode(0), info2.Mode()&os.ModeSymlink)
}
// Test hostname detection
func (suite *CoreTestSuite) TestHostnameDetection() {
hostname, err := GetCurrentHostname()
suite.NoError(err)
suite.NotEmpty(hostname)
}
// Test host-specific symlink restoration
func (suite *CoreTestSuite) TestMultihostSymlinkRestoration() {
err := suite.lnk.Init()
suite.Require().NoError(err)
// Create files directly in host-specific storage (simulating a pull)
hostLnk := NewLnk(WithHost("testhost"))
// Ensure host storage directory exists
hostStoragePath := hostLnk.getHostStoragePath()
err = os.MkdirAll(hostStoragePath, 0755)
suite.Require().NoError(err)
// Create a file in host storage
repoFile := filepath.Join(hostStoragePath, ".bashrc")
content := "export HOST=testhost"
err = os.WriteFile(repoFile, []byte(content), 0644)
suite.Require().NoError(err)
// Create host tracking file
trackingFile := filepath.Join(suite.tempDir, "lnk", ".lnk.testhost")
err = os.WriteFile(trackingFile, []byte(".bashrc\n"), 0644)
suite.Require().NoError(err)
// Get home directory for the test
homeDir, err := os.UserHomeDir()
suite.Require().NoError(err)
targetFile := filepath.Join(homeDir, ".bashrc")
// Clean up the test file after the test
defer func() {
_ = os.Remove(targetFile)
}()
// Test symlink restoration
restored, err := hostLnk.RestoreSymlinks()
suite.Require().NoError(err)
// Should have restored the symlink
suite.Len(restored, 1)
suite.Equal(".bashrc", restored[0])
// Check that file is now a symlink
info, err := os.Lstat(targetFile)
suite.NoError(err)
suite.Equal(os.ModeSymlink, info.Mode()&os.ModeSymlink)
}
// Test that common and host-specific configurations don't interfere
func (suite *CoreTestSuite) TestMultihostIsolation() {
err := suite.lnk.Init()
suite.Require().NoError(err)
// Create same file for common and host-specific
testFile := filepath.Join(suite.tempDir, ".gitconfig")
commonContent := "[user]\n\tname = Common User"
err = os.WriteFile(testFile, []byte(commonContent), 0644)
suite.Require().NoError(err)
// Add to common
commonLnk := NewLnk()
err = commonLnk.Add(testFile)
suite.Require().NoError(err)
// Remove and recreate with different content
err = commonLnk.Remove(testFile)
suite.Require().NoError(err)
hostContent := "[user]\n\tname = Work User"
err = os.WriteFile(testFile, []byte(hostContent), 0644)
suite.Require().NoError(err)
// Add to host-specific
hostLnk := NewLnk(WithHost("work"))
err = hostLnk.Add(testFile)
suite.Require().NoError(err)
// Verify tracking files are separate
commonItems, err := commonLnk.List()
suite.Require().NoError(err)
suite.Len(commonItems, 0) // Should be empty after removal
hostItems, err := hostLnk.List()
suite.Require().NoError(err)
suite.Len(hostItems, 1)
suite.Contains(hostItems[0], ".gitconfig")
// Verify content is correct
symlinkContent, err := os.ReadFile(testFile)
suite.Require().NoError(err)
suite.Equal(hostContent, string(symlinkContent))
}
func TestCoreSuite(t *testing.T) {
suite.Run(t, new(CoreTestSuite))
}

119
internal/fs/errors.go Normal file
View File

@@ -0,0 +1,119 @@
package fs
import "fmt"
// ANSI color codes for consistent formatting
const (
colorReset = "\033[0m"
colorRed = "\033[31m"
colorBold = "\033[1m"
)
// formatError creates a consistently formatted error message with ❌ prefix
func formatError(message string, args ...interface{}) string {
return fmt.Sprintf("❌ "+message, args...)
}
// formatPath formats a file path with red color
func formatPath(path string) string {
return fmt.Sprintf("%s%s%s", colorRed, path, colorReset)
}
// formatCommand formats a command with bold styling
func formatCommand(command string) string {
return fmt.Sprintf("%s%s%s", colorBold, command, colorReset)
}
// FileNotExistsError represents an error when a file does not exist
type FileNotExistsError struct {
Path string
Err error
}
func (e *FileNotExistsError) Error() string {
return formatError("File or directory not found: %s", formatPath(e.Path))
}
func (e *FileNotExistsError) Unwrap() error {
return e.Err
}
// FileCheckError represents an error when failing to check a file
type FileCheckError struct {
Err error
}
func (e *FileCheckError) Error() string {
return formatError("Unable to access file. Please check file permissions and try again.")
}
func (e *FileCheckError) Unwrap() error {
return e.Err
}
// UnsupportedFileTypeError represents an error when a file type is not supported
type UnsupportedFileTypeError struct {
Path string
}
func (e *UnsupportedFileTypeError) Error() string {
return formatError("Cannot manage this type of file: %s\n 💡 lnk can only manage regular files and directories", formatPath(e.Path))
}
func (e *UnsupportedFileTypeError) Unwrap() error {
return nil
}
// NotManagedByLnkError represents an error when a file is not managed by lnk
type NotManagedByLnkError struct {
Path string
}
func (e *NotManagedByLnkError) Error() string {
return formatError("File is not managed by lnk: %s\n 💡 Use %s to manage this file first",
formatPath(e.Path), formatCommand("lnk add"))
}
func (e *NotManagedByLnkError) Unwrap() error {
return nil
}
// SymlinkReadError represents an error when failing to read a symlink
type SymlinkReadError struct {
Err error
}
func (e *SymlinkReadError) Error() string {
return formatError("Unable to read symlink. The file may be corrupted or have invalid permissions.")
}
func (e *SymlinkReadError) Unwrap() error {
return e.Err
}
// DirectoryCreationError represents an error when failing to create a directory
type DirectoryCreationError struct {
Operation string
Err error
}
func (e *DirectoryCreationError) Error() string {
return formatError("Failed to create directory. Please check permissions and available disk space.")
}
func (e *DirectoryCreationError) Unwrap() error {
return e.Err
}
// RelativePathCalculationError represents an error when failing to calculate relative path
type RelativePathCalculationError struct {
Err error
}
func (e *RelativePathCalculationError) Error() string {
return formatError("Unable to create symlink due to path configuration issues. Please check file locations.")
}
func (e *RelativePathCalculationError) Unwrap() error {
return e.Err
}

View File

@@ -1,7 +1,6 @@
package fs
import (
"fmt"
"os"
"path/filepath"
"strings"
@@ -17,18 +16,19 @@ func New() *FileSystem {
// ValidateFileForAdd validates that a file or directory can be added to lnk
func (fs *FileSystem) ValidateFileForAdd(filePath string) error {
// Check if file exists
// Check if file exists and get its info
info, err := os.Stat(filePath)
if err != nil {
if os.IsNotExist(err) {
return fmt.Errorf("❌ File does not exist: \033[31m%s\033[0m", filePath)
return &FileNotExistsError{Path: filePath, Err: err}
}
return fmt.Errorf("❌ Failed to check file: %w", err)
return &FileCheckError{Err: err}
}
// Allow both regular files and directories
if !info.Mode().IsRegular() && !info.IsDir() {
return fmt.Errorf("❌ Only regular files and directories are supported: \033[31m%s\033[0m", filePath)
return &UnsupportedFileTypeError{Path: filePath}
}
return nil
@@ -36,98 +36,79 @@ func (fs *FileSystem) ValidateFileForAdd(filePath string) error {
// ValidateSymlinkForRemove validates that a symlink can be removed from lnk
func (fs *FileSystem) ValidateSymlinkForRemove(filePath, repoPath string) error {
// Check if file exists
// Check if file exists and is a symlink
info, err := os.Lstat(filePath) // Use Lstat to not follow symlinks
if err != nil {
if os.IsNotExist(err) {
return fmt.Errorf("❌ File does not exist: \033[31m%s\033[0m", filePath)
}
return fmt.Errorf("❌ Failed to check file: %w", err)
return &FileNotExistsError{Path: filePath, Err: err}
}
return &FileCheckError{Err: err}
}
// Check if it's a symlink
if info.Mode()&os.ModeSymlink == 0 {
return fmt.Errorf("❌ File is not managed by lnk: \033[31m%s\033[0m\n 💡 Use \033[1mlnk add\033[0m to manage this file first", filePath)
return &NotManagedByLnkError{Path: filePath}
}
// Check if symlink points to the repository
// Get symlink target and resolve to absolute path
target, err := os.Readlink(filePath)
if err != nil {
return fmt.Errorf("failed to read symlink: %w", err)
return &SymlinkReadError{Err: err}
}
// Convert relative path to absolute if needed
if !filepath.IsAbs(target) {
target = filepath.Join(filepath.Dir(filePath), target)
}
// Clean the path to resolve any .. or . components
// Clean paths and check if target is inside the repository
target = filepath.Clean(target)
repoPath = filepath.Clean(repoPath)
// Check if target is inside the repository
if !strings.HasPrefix(target, repoPath+string(filepath.Separator)) && target != repoPath {
return fmt.Errorf("❌ File is not managed by lnk: \033[31m%s\033[0m", filePath)
return &NotManagedByLnkError{Path: filePath}
}
return nil
}
// Move moves a file or directory from source to destination based on the file info
func (fs *FileSystem) Move(src, dst string, info os.FileInfo) error {
if info.IsDir() {
return fs.MoveDirectory(src, dst)
}
return fs.MoveFile(src, dst)
}
// MoveFile moves a file from source to destination
func (fs *FileSystem) MoveFile(src, dst string) error {
// Ensure destination directory exists
dstDir := filepath.Dir(dst)
if err := os.MkdirAll(dstDir, 0755); err != nil {
return fmt.Errorf("failed to create destination directory: %w", err)
if err := os.MkdirAll(filepath.Dir(dst), 0755); err != nil {
return &DirectoryCreationError{Operation: "destination directory", Err: err}
}
// Move the file
if err := os.Rename(src, dst); err != nil {
return fmt.Errorf("failed to move file from %s to %s: %w", src, dst, err)
}
return nil
return os.Rename(src, dst)
}
// CreateSymlink creates a relative symlink from target to linkPath
func (fs *FileSystem) CreateSymlink(target, linkPath string) error {
// Calculate relative path from linkPath to target
linkDir := filepath.Dir(linkPath)
relTarget, err := filepath.Rel(linkDir, target)
relTarget, err := filepath.Rel(filepath.Dir(linkPath), target)
if err != nil {
return fmt.Errorf("failed to calculate relative path: %w", err)
return &RelativePathCalculationError{Err: err}
}
// Create the symlink
if err := os.Symlink(relTarget, linkPath); err != nil {
return fmt.Errorf("failed to create symlink: %w", err)
}
return nil
return os.Symlink(relTarget, linkPath)
}
// MoveDirectory moves a directory from source to destination recursively
func (fs *FileSystem) MoveDirectory(src, dst string) error {
// Check if source is a directory
info, err := os.Stat(src)
if err != nil {
return fmt.Errorf("failed to stat source: %w", err)
}
if !info.IsDir() {
return fmt.Errorf("source is not a directory: %s", src)
}
// Ensure destination parent directory exists
dstParent := filepath.Dir(dst)
if err := os.MkdirAll(dstParent, 0755); err != nil {
return fmt.Errorf("failed to create destination parent directory: %w", err)
if err := os.MkdirAll(filepath.Dir(dst), 0755); err != nil {
return &DirectoryCreationError{Operation: "destination parent directory", Err: err}
}
// Use os.Rename which works for directories
if err := os.Rename(src, dst); err != nil {
return fmt.Errorf("failed to move directory from %s to %s: %w", src, dst, err)
}
return nil
// Move the directory
return os.Rename(src, dst)
}

218
internal/git/errors.go Normal file
View File

@@ -0,0 +1,218 @@
package git
import "fmt"
// ANSI color codes for consistent formatting
const (
colorReset = "\033[0m"
colorBold = "\033[1m"
colorGreen = "\033[32m"
colorYellow = "\033[33m"
)
// formatError creates a consistently formatted error message with ❌ prefix
func formatError(message string, args ...interface{}) string {
return fmt.Sprintf("❌ "+message, args...)
}
// formatURL formats a URL with styling
func formatURL(url string) string {
return fmt.Sprintf("%s%s%s", colorBold, url, colorReset)
}
// formatRemote formats a remote name with styling
func formatRemote(remote string) string {
return fmt.Sprintf("%s%s%s", colorGreen, remote, colorReset)
}
// GitInitError represents an error during git initialization
type GitInitError struct {
Output string
Err error
}
func (e *GitInitError) Error() string {
return formatError("Failed to initialize git repository. Please ensure git is installed and try again.")
}
func (e *GitInitError) Unwrap() error {
return e.Err
}
// BranchSetupError represents an error setting up the default branch
type BranchSetupError struct {
Err error
}
func (e *BranchSetupError) Error() string {
return formatError("Failed to set up the default branch. Please check your git installation.")
}
func (e *BranchSetupError) Unwrap() error {
return e.Err
}
// RemoteExistsError represents an error when a remote already exists with different URL
type RemoteExistsError struct {
Remote string
ExistingURL string
NewURL string
}
func (e *RemoteExistsError) Error() string {
return formatError("Remote %s is already configured with a different repository (%s). Cannot add %s.",
formatRemote(e.Remote), formatURL(e.ExistingURL), formatURL(e.NewURL))
}
func (e *RemoteExistsError) Unwrap() error {
return nil
}
// GitCommandError represents a generic git command execution error
type GitCommandError struct {
Command string
Output string
Err error
}
func (e *GitCommandError) Error() string {
// Provide user-friendly messages based on common command types
switch e.Command {
case "add":
return formatError("Failed to stage files for commit. Please check file permissions and try again.")
case "commit":
return formatError("Failed to create commit. Please ensure you have staged changes and try again.")
case "remote add":
return formatError("Failed to add remote repository. Please check the repository URL and try again.")
case "rm":
return formatError("Failed to remove file from git tracking. Please check if the file exists and try again.")
case "log":
return formatError("Failed to retrieve commit history.")
case "remote":
return formatError("Failed to retrieve remote repository information.")
case "clone":
return formatError("Failed to clone repository. Please check the repository URL and your network connection.")
default:
return formatError("Git operation failed. Please check your repository state and try again.")
}
}
func (e *GitCommandError) Unwrap() error {
return e.Err
}
// NoRemoteError represents an error when no remote is configured
type NoRemoteError struct{}
func (e *NoRemoteError) Error() string {
return formatError("No remote repository is configured. Please add a remote repository first.")
}
func (e *NoRemoteError) Unwrap() error {
return nil
}
// RemoteNotFoundError represents an error when a specific remote is not found
type RemoteNotFoundError struct {
Remote string
Err error
}
func (e *RemoteNotFoundError) Error() string {
return formatError("Remote repository %s is not configured.", formatRemote(e.Remote))
}
func (e *RemoteNotFoundError) Unwrap() error {
return e.Err
}
// GitConfigError represents an error with git configuration
type GitConfigError struct {
Setting string
Err error
}
func (e *GitConfigError) Error() string {
return formatError("Failed to configure git settings. Please check your git installation.")
}
func (e *GitConfigError) Unwrap() error {
return e.Err
}
// UncommittedChangesError represents an error checking for uncommitted changes
type UncommittedChangesError struct {
Err error
}
func (e *UncommittedChangesError) Error() string {
return formatError("Failed to check repository status. Please verify your git repository is valid.")
}
func (e *UncommittedChangesError) Unwrap() error {
return e.Err
}
// DirectoryRemovalError represents an error removing a directory
type DirectoryRemovalError struct {
Path string
Err error
}
func (e *DirectoryRemovalError) Error() string {
return formatError("Failed to prepare directory for operation. Please check directory permissions.")
}
func (e *DirectoryRemovalError) Unwrap() error {
return e.Err
}
// DirectoryCreationError represents an error creating a directory
type DirectoryCreationError struct {
Path string
Err error
}
func (e *DirectoryCreationError) Error() string {
return formatError("Failed to create directory. Please check permissions and available disk space.")
}
func (e *DirectoryCreationError) Unwrap() error {
return e.Err
}
// PushError represents an error during git push operation
type PushError struct {
Reason string
Output string
Err error
}
func (e *PushError) Error() string {
if e.Reason != "" {
return formatError("Cannot push changes: %s", e.Reason)
}
return formatError("Failed to push changes to remote repository. Please check your network connection and repository permissions.")
}
func (e *PushError) Unwrap() error {
return e.Err
}
// PullError represents an error during git pull operation
type PullError struct {
Reason string
Output string
Err error
}
func (e *PullError) Error() string {
if e.Reason != "" {
return formatError("Cannot pull changes: %s", e.Reason)
}
return formatError("Failed to pull changes from remote repository. Please check your network connection and resolve any conflicts.")
}
func (e *PullError) Unwrap() error {
return e.Err
}

View File

@@ -34,7 +34,7 @@ func (g *Git) Init() error {
output, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("git init failed: %w\nOutput: %s", err, string(output))
return &GitInitError{Output: string(output), Err: err}
}
// Set the default branch to main
@@ -42,7 +42,7 @@ func (g *Git) Init() error {
cmd.Dir = g.repoPath
if err := cmd.Run(); err != nil {
return fmt.Errorf("failed to set default branch to main: %w", err)
return &BranchSetupError{Err: err}
}
}
@@ -60,7 +60,7 @@ func (g *Git) AddRemote(name, url string) error {
return nil
}
// Different URL, error
return fmt.Errorf("remote %s already exists with different URL: %s (trying to add: %s)", name, existingURL, url)
return &RemoteExistsError{Remote: name, ExistingURL: existingURL, NewURL: url}
}
// Remote doesn't exist, add it
@@ -69,7 +69,7 @@ func (g *Git) AddRemote(name, url string) error {
output, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("git remote add failed: %w\nOutput: %s", err, string(output))
return &GitCommandError{Command: "remote add", Output: string(output), Err: err}
}
return nil
@@ -164,7 +164,7 @@ func (g *Git) Add(filename string) error {
output, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("git add failed: %w\nOutput: %s", err, string(output))
return &GitCommandError{Command: "add", Output: string(output), Err: err}
}
return nil
@@ -189,7 +189,7 @@ func (g *Git) Remove(filename string) error {
output, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("git rm failed: %w\nOutput: %s", err, string(output))
return &GitCommandError{Command: "rm", Output: string(output), Err: err}
}
return nil
@@ -207,7 +207,7 @@ func (g *Git) Commit(message string) error {
output, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("git commit failed: %w\nOutput: %s", err, string(output))
return &GitCommandError{Command: "commit", Output: string(output), Err: err}
}
return nil
@@ -223,7 +223,7 @@ func (g *Git) ensureGitConfig() error {
cmd = exec.Command("git", "config", "user.name", "Lnk User")
cmd.Dir = g.repoPath
if err := cmd.Run(); err != nil {
return fmt.Errorf("failed to set git user.name: %w", err)
return &GitConfigError{Setting: "user.name", Err: err}
}
}
@@ -235,7 +235,7 @@ func (g *Git) ensureGitConfig() error {
cmd = exec.Command("git", "config", "user.email", "lnk@localhost")
cmd.Dir = g.repoPath
if err := cmd.Run(); err != nil {
return fmt.Errorf("failed to set git user.email: %w", err)
return &GitConfigError{Setting: "user.email", Err: err}
}
}
@@ -260,7 +260,7 @@ func (g *Git) GetCommits() ([]string, error) {
if strings.Contains(outputStr, "does not have any commits yet") {
return []string{}, nil
}
return nil, fmt.Errorf("git log failed: %w", err)
return nil, &GitCommandError{Command: "log", Output: outputStr, Err: err}
}
commits := strings.Split(strings.TrimSpace(string(output)), "\n")
@@ -282,18 +282,18 @@ func (g *Git) GetRemoteInfo() (string, error) {
output, err := cmd.Output()
if err != nil {
return "", fmt.Errorf("failed to list remotes: %w", err)
return "", &GitCommandError{Command: "remote", Output: string(output), Err: err}
}
remotes := strings.Split(strings.TrimSpace(string(output)), "\n")
if len(remotes) == 0 || remotes[0] == "" {
return "", fmt.Errorf("no remote configured")
return "", &NoRemoteError{}
}
// Use the first remote
url, err = g.getRemoteURL(remotes[0])
if err != nil {
return "", fmt.Errorf("failed to get remote URL: %w", err)
return "", &RemoteNotFoundError{Remote: remotes[0], Err: err}
}
}
@@ -319,7 +319,7 @@ func (g *Git) GetStatus() (*StatusInfo, error) {
// Check for uncommitted changes
dirty, err := g.HasChanges()
if err != nil {
return nil, fmt.Errorf("failed to check for uncommitted changes: %w", err)
return nil, &UncommittedChangesError{Err: err}
}
// Get the remote tracking branch
@@ -410,7 +410,7 @@ func (g *Git) HasChanges() (bool, error) {
output, err := cmd.Output()
if err != nil {
return false, fmt.Errorf("git status failed: %w", err)
return false, &GitCommandError{Command: "status", Output: string(output), Err: err}
}
return len(strings.TrimSpace(string(output))) > 0, nil
@@ -423,7 +423,7 @@ func (g *Git) AddAll() error {
output, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("git add failed: %w\nOutput: %s", err, string(output))
return &GitCommandError{Command: "add", Output: string(output), Err: err}
}
return nil
@@ -434,7 +434,7 @@ func (g *Git) Push() error {
// First ensure we have a remote configured
_, err := g.GetRemoteInfo()
if err != nil {
return fmt.Errorf("cannot push: %w", err)
return &PushError{Reason: err.Error(), Err: err}
}
cmd := exec.Command("git", "push", "-u", "origin", "main")
@@ -442,7 +442,7 @@ func (g *Git) Push() error {
output, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("git push failed: %w\nOutput: %s", err, string(output))
return &PushError{Output: string(output), Err: err}
}
return nil
@@ -453,7 +453,7 @@ func (g *Git) Pull() error {
// First ensure we have a remote configured
_, err := g.GetRemoteInfo()
if err != nil {
return fmt.Errorf("cannot pull: %w", err)
return &PullError{Reason: err.Error(), Err: err}
}
cmd := exec.Command("git", "pull", "origin", "main")
@@ -461,7 +461,7 @@ func (g *Git) Pull() error {
output, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("git pull failed: %w\nOutput: %s", err, string(output))
return &PullError{Output: string(output), Err: err}
}
return nil
@@ -471,20 +471,20 @@ func (g *Git) Pull() error {
func (g *Git) Clone(url string) error {
// Remove the directory if it exists to ensure clean clone
if err := os.RemoveAll(g.repoPath); err != nil {
return fmt.Errorf("failed to remove existing directory: %w", err)
return &DirectoryRemovalError{Path: g.repoPath, Err: err}
}
// Create parent directory
parentDir := filepath.Dir(g.repoPath)
if err := os.MkdirAll(parentDir, 0755); err != nil {
return fmt.Errorf("failed to create parent directory: %w", err)
return &DirectoryCreationError{Path: parentDir, Err: err}
}
// Clone the repository
cmd := exec.Command("git", "clone", url, g.repoPath)
output, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("git clone failed: %w\nOutput: %s", err, string(output))
return &GitCommandError{Command: "clone", Output: string(output), Err: err}
}
// Set up upstream tracking for main branch