mirror of
https://github.com/yarlson/lnk.git
synced 2025-09-24 21:11:27 +02:00
Compare commits
11 Commits
alert-auto
...
v0.3.0
Author | SHA1 | Date | |
---|---|---|---|
|
ae9cc175ce | ||
|
1e2c9704f3 | ||
|
3cba309c05 | ||
|
3e6b426a19 | ||
|
02f342b02b | ||
|
92f2575090 | ||
|
0f74723a03 | ||
|
093cc8ebe7 | ||
|
ff3cddc065 | ||
|
4a275ce4ca | ||
|
69c1038f3e |
211
README.md
211
README.md
@@ -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. Automatically runs bootstrap scripts to set up your environment.
|
||||
|
||||
```bash
|
||||
lnk init
|
||||
lnk add ~/.vimrc ~/.bashrc
|
||||
lnk init -r git@github.com:user/dotfiles.git # Clones & runs bootstrap automatically
|
||||
lnk add ~/.vimrc ~/.bashrc # Common config
|
||||
lnk add --host work ~/.ssh/config # Host-specific config
|
||||
lnk push "setup"
|
||||
```
|
||||
|
||||
@@ -42,32 +43,134 @@ git clone https://github.com/yarlson/lnk.git && cd lnk && go build . && sudo mv
|
||||
# Fresh start
|
||||
lnk init
|
||||
|
||||
# With existing repo
|
||||
# With existing repo (runs bootstrap automatically)
|
||||
lnk init -r git@github.com:user/dotfiles.git
|
||||
|
||||
# Skip automatic bootstrap
|
||||
lnk init -r git@github.com:user/dotfiles.git --no-bootstrap
|
||||
|
||||
# Run bootstrap script manually
|
||||
lnk bootstrap
|
||||
```
|
||||
|
||||
### 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.
|
||||
|
||||
## Bootstrap Support
|
||||
|
||||
Lnk automatically runs bootstrap scripts when cloning dotfiles repositories, making it easy to set up your development environment. Just add a `bootstrap.sh` file to your dotfiles repo.
|
||||
|
||||
### Examples
|
||||
|
||||
**Simple bootstrap script:**
|
||||
|
||||
```bash
|
||||
#!/bin/bash
|
||||
# bootstrap.sh
|
||||
echo "Setting up development environment..."
|
||||
|
||||
# Install Homebrew (macOS)
|
||||
if ! command -v brew &> /dev/null; then
|
||||
/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"
|
||||
fi
|
||||
|
||||
# Install packages
|
||||
brew install git vim tmux
|
||||
|
||||
echo "✅ Setup complete!"
|
||||
```
|
||||
|
||||
**Usage:**
|
||||
|
||||
```bash
|
||||
# Automatic bootstrap on clone
|
||||
lnk init -r git@github.com:you/dotfiles.git
|
||||
# → Clones repo and runs bootstrap script automatically
|
||||
|
||||
# Skip bootstrap if needed
|
||||
lnk init -r git@github.com:you/dotfiles.git --no-bootstrap
|
||||
|
||||
# Run bootstrap manually later
|
||||
lnk bootstrap
|
||||
```
|
||||
|
||||
## 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?
|
||||
|
||||
@@ -83,64 +186,100 @@ You could `git init ~/.config/lnk` and manually symlink everything. Lnk just aut
|
||||
### First time setup
|
||||
|
||||
```bash
|
||||
# Clone dotfiles and run bootstrap automatically
|
||||
lnk init -r git@github.com:you/dotfiles.git
|
||||
# → Downloads dependencies, installs packages, configures environment
|
||||
|
||||
# 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"
|
||||
```
|
||||
|
||||
### On a new machine
|
||||
|
||||
```bash
|
||||
# Bootstrap runs automatically
|
||||
lnk init -r git@github.com:you/dotfiles.git
|
||||
lnk pull # auto-creates symlinks
|
||||
# → Sets up environment, installs dependencies
|
||||
|
||||
# Pull common config
|
||||
lnk pull
|
||||
|
||||
# Pull host-specific config (if it exists)
|
||||
lnk pull --host $(hostname)
|
||||
|
||||
# Or run bootstrap manually if needed
|
||||
lnk bootstrap
|
||||
```
|
||||
|
||||
### Daily edits
|
||||
|
||||
```bash
|
||||
vim ~/.vimrc # edit normally
|
||||
lnk status # check what changed
|
||||
lnk push "new plugins" # commit & push
|
||||
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 init [-r remote] [--no-bootstrap]` - Create repo (runs bootstrap automatically)
|
||||
- `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
|
||||
- `lnk bootstrap` - Run bootstrap script manually
|
||||
|
||||
### 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
|
||||
- `--no-bootstrap` - Skip automatic execution of bootstrap script after cloning
|
||||
|
||||
## Technical bits
|
||||
|
||||
- **Single binary** (~8MB, no deps)
|
||||
- **Relative symlinks** (portable)
|
||||
- **XDG compliant** (`~/.config/lnk`)
|
||||
- **Multihost support** (common + host-specific configs)
|
||||
- **Bootstrap support** (automatic environment setup)
|
||||
- **Git-native** (standard Git repo, no special formats)
|
||||
|
||||
## Alternatives
|
||||
|
||||
| Tool | Complexity | Why choose it |
|
||||
| ------- | ---------- | ------------------------------------- |
|
||||
| **lnk** | Minimal | Just works, no config, Git-native |
|
||||
| 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).
|
||||
| Tool | Complexity | Why choose it |
|
||||
| ------- | ---------- | ------------------------------------------------------- |
|
||||
| **lnk** | Minimal | Just works, no config, Git-native, multihost, bootstrap |
|
||||
| chezmoi | High | Templates, encryption, cross-platform |
|
||||
| yadm | Medium | Git power user, encryption |
|
||||
| dotbot | Low | YAML config, basic features |
|
||||
| stow | Low | Perl, symlink only |
|
||||
|
||||
## Contributing
|
||||
|
||||
|
32
cmd/add.go
32
cmd/add.go
@@ -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{
|
||||
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,
|
||||
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)
|
||||
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)
|
||||
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, 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
|
||||
}
|
||||
|
45
cmd/bootstrap.go
Normal file
45
cmd/bootstrap.go
Normal file
@@ -0,0 +1,45 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/yarlson/lnk/internal/core"
|
||||
)
|
||||
|
||||
func newBootstrapCmd() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "bootstrap",
|
||||
Short: "🚀 Run the bootstrap script to set up your environment",
|
||||
Long: "Executes the bootstrap script from your dotfiles repository to install dependencies and configure your system.",
|
||||
SilenceUsage: true,
|
||||
SilenceErrors: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
lnk := core.NewLnk()
|
||||
|
||||
scriptPath, err := lnk.FindBootstrapScript()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if scriptPath == "" {
|
||||
printf(cmd, "💡 \033[33mNo bootstrap script found\033[0m\n")
|
||||
printf(cmd, " 📝 Create a \033[1mbootstrap.sh\033[0m file in your dotfiles repository:\n")
|
||||
printf(cmd, " \033[90m#!/bin/bash\033[0m\n")
|
||||
printf(cmd, " \033[90mecho \"Setting up environment...\"\033[0m\n")
|
||||
printf(cmd, " \033[90m# Your setup commands here\033[0m\n")
|
||||
return nil
|
||||
}
|
||||
|
||||
printf(cmd, "🚀 \033[1mRunning bootstrap script\033[0m\n")
|
||||
printf(cmd, " 📄 Script: \033[36m%s\033[0m\n", scriptPath)
|
||||
printf(cmd, "\n")
|
||||
|
||||
if err := lnk.RunBootstrapScript(scriptPath); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
printf(cmd, "\n✅ \033[1;32mBootstrap completed successfully!\033[0m\n")
|
||||
printf(cmd, " 🎉 Your environment is ready to use\n")
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
42
cmd/init.go
42
cmd/init.go
@@ -1,30 +1,57 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/yarlson/lnk/internal/core"
|
||||
)
|
||||
|
||||
func newInitCmd() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "init",
|
||||
Short: "🎯 Initialize a new lnk repository",
|
||||
Long: "Creates the lnk directory and initializes a Git repository for managing dotfiles.",
|
||||
SilenceUsage: true,
|
||||
Use: "init",
|
||||
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")
|
||||
noBootstrap, _ := cmd.Flags().GetBool("no-bootstrap")
|
||||
|
||||
lnk := core.NewLnk()
|
||||
if err := lnk.InitWithRemote(remote); err != nil {
|
||||
return fmt.Errorf("failed to initialize lnk: %w", err)
|
||||
return err
|
||||
}
|
||||
|
||||
if remote != "" {
|
||||
printf(cmd, "🎯 \033[1mInitialized lnk repository\033[0m\n")
|
||||
printf(cmd, " 📦 Cloned from: \033[36m%s\033[0m\n", remote)
|
||||
printf(cmd, " 📁 Location: \033[90m~/.config/lnk\033[0m\n")
|
||||
|
||||
// Try to run bootstrap script if not disabled
|
||||
if !noBootstrap {
|
||||
printf(cmd, "\n🔍 \033[1mLooking for bootstrap script...\033[0m\n")
|
||||
|
||||
scriptPath, err := lnk.FindBootstrapScript()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if scriptPath != "" {
|
||||
printf(cmd, " ✅ Found bootstrap script: \033[36m%s\033[0m\n", scriptPath)
|
||||
printf(cmd, "\n🚀 \033[1mRunning bootstrap script...\033[0m\n")
|
||||
printf(cmd, "\n")
|
||||
|
||||
if err := lnk.RunBootstrapScript(scriptPath); err != nil {
|
||||
printf(cmd, "\n⚠️ \033[33mBootstrap script failed, but repository was initialized successfully\033[0m\n")
|
||||
printf(cmd, " 💡 You can run it manually with: \033[1mlnk bootstrap\033[0m\n")
|
||||
printf(cmd, " 🔧 Error: %v\n", err)
|
||||
} else {
|
||||
printf(cmd, "\n✅ \033[1;32mBootstrap completed successfully!\033[0m\n")
|
||||
}
|
||||
} else {
|
||||
printf(cmd, " 💡 No bootstrap script found\n")
|
||||
}
|
||||
}
|
||||
|
||||
printf(cmd, "\n💡 \033[33mNext steps:\033[0m\n")
|
||||
printf(cmd, " • Run \033[1mlnk pull\033[0m to restore symlinks\n")
|
||||
printf(cmd, " • Use \033[1mlnk add <file>\033[0m to manage new files\n")
|
||||
@@ -41,5 +68,6 @@ func newInitCmd() *cobra.Command {
|
||||
}
|
||||
|
||||
cmd.Flags().StringP("remote", "r", "", "Clone from remote URL instead of creating empty repository")
|
||||
cmd.Flags().Bool("no-bootstrap", false, "Skip automatic execution of bootstrap script after cloning")
|
||||
return cmd
|
||||
}
|
||||
|
193
cmd/list.go
Normal file
193
cmd/list.go
Normal 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")
|
||||
}
|
35
cmd/pull.go
35
cmd/pull.go
@@ -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{
|
||||
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,
|
||||
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 {
|
||||
printf(cmd, "⬇️ \033[1;32mSuccessfully pulled changes\033[0m\n")
|
||||
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")
|
||||
@@ -32,7 +38,11 @@ func newPullCmd() *cobra.Command {
|
||||
}
|
||||
printf(cmd, "\n 🎉 Your dotfiles are synced and ready!\n")
|
||||
} else {
|
||||
printf(cmd, "⬇️ \033[1;32mSuccessfully pulled changes\033[0m\n")
|
||||
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
|
||||
}
|
||||
|
15
cmd/push.go
15
cmd/push.go
@@ -1,19 +1,18 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/yarlson/lnk/internal/core"
|
||||
)
|
||||
|
||||
func newPushCmd() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "push [message]",
|
||||
Short: "🚀 Push local changes to remote repository",
|
||||
Long: "Stages all changes, creates a sync commit with the provided message, and pushes to remote.",
|
||||
Args: cobra.MaximumNArgs(1),
|
||||
SilenceUsage: true,
|
||||
Use: "push [message]",
|
||||
Short: "🚀 Push local changes to remote repository",
|
||||
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")
|
||||
|
32
cmd/rm.go
32
cmd/rm.go
@@ -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{
|
||||
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,
|
||||
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)
|
||||
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)
|
||||
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
|
||||
}
|
||||
|
30
cmd/root.go
30
cmd/root.go
@@ -20,27 +20,37 @@ 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 push "setup complete" # Sync to remote
|
||||
lnk pull # Get latest changes
|
||||
lnk init # Fresh start
|
||||
lnk init -r <repo-url> # Clone existing dotfiles (runs bootstrap automatically)
|
||||
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 bootstrap # Run bootstrap script manually
|
||||
|
||||
🎯 Simple, fast, and Git-native.`,
|
||||
SilenceUsage: true,
|
||||
Version: fmt.Sprintf("%s (built %s)", version, buildTime),
|
||||
🚀 Bootstrap Support:
|
||||
Automatically runs bootstrap.sh when cloning a repository.
|
||||
Use --no-bootstrap to disable.
|
||||
|
||||
🎯 Simple, fast, Git-native, and multi-host ready.`,
|
||||
SilenceUsage: true,
|
||||
SilenceErrors: true,
|
||||
Version: fmt.Sprintf("%s (built %s)", version, buildTime),
|
||||
}
|
||||
|
||||
// Add subcommands
|
||||
rootCmd.AddCommand(newInitCmd())
|
||||
rootCmd.AddCommand(newAddCmd())
|
||||
rootCmd.AddCommand(newRemoveCmd())
|
||||
rootCmd.AddCommand(newListCmd())
|
||||
rootCmd.AddCommand(newStatusCmd())
|
||||
rootCmd.AddCommand(newPushCmd())
|
||||
rootCmd.AddCommand(newPullCmd())
|
||||
rootCmd.AddCommand(newBootstrapCmd())
|
||||
|
||||
return rootCmd
|
||||
}
|
||||
@@ -54,7 +64,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)
|
||||
}
|
||||
}
|
||||
|
539
cmd/root_test.go
539
cmd/root_test.go
@@ -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,311 @@ 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 (suite *CLITestSuite) TestBootstrapCommand() {
|
||||
// Initialize repository
|
||||
err := suite.runCommand("init")
|
||||
suite.Require().NoError(err)
|
||||
suite.stdout.Reset()
|
||||
|
||||
// Test bootstrap command with no script
|
||||
err = suite.runCommand("bootstrap")
|
||||
suite.NoError(err)
|
||||
output := suite.stdout.String()
|
||||
suite.Contains(output, "No bootstrap script found")
|
||||
suite.Contains(output, "bootstrap.sh")
|
||||
suite.stdout.Reset()
|
||||
|
||||
// Create a bootstrap script
|
||||
lnkDir := filepath.Join(suite.tempDir, ".config", "lnk")
|
||||
bootstrapScript := filepath.Join(lnkDir, "bootstrap.sh")
|
||||
scriptContent := `#!/bin/bash
|
||||
echo "Bootstrap script executed!"
|
||||
echo "Working directory: $(pwd)"
|
||||
touch bootstrap-ran.txt
|
||||
`
|
||||
err = os.WriteFile(bootstrapScript, []byte(scriptContent), 0755)
|
||||
suite.Require().NoError(err)
|
||||
|
||||
// Test bootstrap command with script
|
||||
err = suite.runCommand("bootstrap")
|
||||
suite.NoError(err)
|
||||
output = suite.stdout.String()
|
||||
suite.Contains(output, "Running bootstrap script")
|
||||
suite.Contains(output, "bootstrap.sh")
|
||||
suite.Contains(output, "Bootstrap completed successfully")
|
||||
|
||||
// Verify script actually ran
|
||||
markerFile := filepath.Join(lnkDir, "bootstrap-ran.txt")
|
||||
suite.FileExists(markerFile)
|
||||
}
|
||||
|
||||
func (suite *CLITestSuite) TestInitWithBootstrap() {
|
||||
// Create a temporary remote repository with bootstrap script
|
||||
remoteDir := filepath.Join(suite.tempDir, "remote")
|
||||
err := os.MkdirAll(remoteDir, 0755)
|
||||
suite.Require().NoError(err)
|
||||
|
||||
// Initialize git repo in remote
|
||||
cmd := exec.Command("git", "init", "--bare")
|
||||
cmd.Dir = remoteDir
|
||||
err = cmd.Run()
|
||||
suite.Require().NoError(err)
|
||||
|
||||
// Create a working repo to populate the remote
|
||||
workingDir := filepath.Join(suite.tempDir, "working")
|
||||
err = os.MkdirAll(workingDir, 0755)
|
||||
suite.Require().NoError(err)
|
||||
|
||||
cmd = exec.Command("git", "clone", remoteDir, workingDir)
|
||||
err = cmd.Run()
|
||||
suite.Require().NoError(err)
|
||||
|
||||
// Add a bootstrap script to the working repo
|
||||
bootstrapScript := filepath.Join(workingDir, "bootstrap.sh")
|
||||
scriptContent := `#!/bin/bash
|
||||
echo "Remote bootstrap script executed!"
|
||||
touch remote-bootstrap-ran.txt
|
||||
`
|
||||
err = os.WriteFile(bootstrapScript, []byte(scriptContent), 0755)
|
||||
suite.Require().NoError(err)
|
||||
|
||||
// Add a dummy config file
|
||||
configFile := filepath.Join(workingDir, ".bashrc")
|
||||
err = os.WriteFile(configFile, []byte("echo 'Hello from remote!'"), 0644)
|
||||
suite.Require().NoError(err)
|
||||
|
||||
// Add .lnk file to track the config
|
||||
lnkFile := filepath.Join(workingDir, ".lnk")
|
||||
err = os.WriteFile(lnkFile, []byte(".bashrc\n"), 0644)
|
||||
suite.Require().NoError(err)
|
||||
|
||||
// Commit and push to remote
|
||||
cmd = exec.Command("git", "add", ".")
|
||||
cmd.Dir = workingDir
|
||||
err = cmd.Run()
|
||||
suite.Require().NoError(err)
|
||||
|
||||
cmd = exec.Command("git", "-c", "user.email=test@example.com", "-c", "user.name=Test User", "commit", "-m", "Add bootstrap and config")
|
||||
cmd.Dir = workingDir
|
||||
err = cmd.Run()
|
||||
suite.Require().NoError(err)
|
||||
|
||||
cmd = exec.Command("git", "push", "origin", "master")
|
||||
cmd.Dir = workingDir
|
||||
err = cmd.Run()
|
||||
suite.Require().NoError(err)
|
||||
|
||||
// Now test init with remote and automatic bootstrap
|
||||
err = suite.runCommand("init", "-r", remoteDir)
|
||||
suite.NoError(err)
|
||||
output := suite.stdout.String()
|
||||
suite.Contains(output, "Cloned from:")
|
||||
suite.Contains(output, "Looking for bootstrap script")
|
||||
suite.Contains(output, "Found bootstrap script:")
|
||||
suite.Contains(output, "bootstrap.sh")
|
||||
suite.Contains(output, "Running bootstrap script")
|
||||
suite.Contains(output, "Bootstrap completed successfully")
|
||||
|
||||
// Verify bootstrap actually ran
|
||||
lnkDir := filepath.Join(suite.tempDir, ".config", "lnk")
|
||||
markerFile := filepath.Join(lnkDir, "remote-bootstrap-ran.txt")
|
||||
suite.FileExists(markerFile)
|
||||
}
|
||||
|
||||
func (suite *CLITestSuite) TestInitWithBootstrapDisabled() {
|
||||
// Create a temporary remote repository with bootstrap script
|
||||
remoteDir := filepath.Join(suite.tempDir, "remote")
|
||||
err := os.MkdirAll(remoteDir, 0755)
|
||||
suite.Require().NoError(err)
|
||||
|
||||
// Initialize git repo in remote
|
||||
cmd := exec.Command("git", "init", "--bare")
|
||||
cmd.Dir = remoteDir
|
||||
err = cmd.Run()
|
||||
suite.Require().NoError(err)
|
||||
|
||||
// Create a working repo to populate the remote
|
||||
workingDir := filepath.Join(suite.tempDir, "working")
|
||||
err = os.MkdirAll(workingDir, 0755)
|
||||
suite.Require().NoError(err)
|
||||
|
||||
cmd = exec.Command("git", "clone", remoteDir, workingDir)
|
||||
err = cmd.Run()
|
||||
suite.Require().NoError(err)
|
||||
|
||||
// Add a bootstrap script
|
||||
bootstrapScript := filepath.Join(workingDir, "bootstrap.sh")
|
||||
scriptContent := `#!/bin/bash
|
||||
echo "This should not run!"
|
||||
touch should-not-exist.txt
|
||||
`
|
||||
err = os.WriteFile(bootstrapScript, []byte(scriptContent), 0755)
|
||||
suite.Require().NoError(err)
|
||||
|
||||
// Commit and push
|
||||
cmd = exec.Command("git", "add", ".")
|
||||
cmd.Dir = workingDir
|
||||
err = cmd.Run()
|
||||
suite.Require().NoError(err)
|
||||
|
||||
cmd = exec.Command("git", "-c", "user.email=test@example.com", "-c", "user.name=Test User", "commit", "-m", "Add bootstrap")
|
||||
cmd.Dir = workingDir
|
||||
err = cmd.Run()
|
||||
suite.Require().NoError(err)
|
||||
|
||||
cmd = exec.Command("git", "push", "origin", "master")
|
||||
cmd.Dir = workingDir
|
||||
err = cmd.Run()
|
||||
suite.Require().NoError(err)
|
||||
|
||||
// Test init with --no-bootstrap flag
|
||||
err = suite.runCommand("init", "-r", remoteDir, "--no-bootstrap")
|
||||
suite.NoError(err)
|
||||
output := suite.stdout.String()
|
||||
suite.Contains(output, "Cloned from:")
|
||||
suite.NotContains(output, "Looking for bootstrap script")
|
||||
suite.NotContains(output, "Running bootstrap script")
|
||||
|
||||
// Verify bootstrap did NOT run
|
||||
lnkDir := filepath.Join(suite.tempDir, ".config", "lnk")
|
||||
markerFile := filepath.Join(lnkDir, "should-not-exist.txt")
|
||||
suite.NoFileExists(markerFile)
|
||||
}
|
||||
|
||||
func TestCLISuite(t *testing.T) {
|
||||
suite.Run(t, new(CLITestSuite))
|
||||
}
|
||||
|
@@ -1,23 +1,22 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/yarlson/lnk/internal/core"
|
||||
)
|
||||
|
||||
func newStatusCmd() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "status",
|
||||
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,
|
||||
Use: "status",
|
||||
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 {
|
||||
|
@@ -3,6 +3,7 @@ package core
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
@@ -14,18 +15,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 +70,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 +142,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 +174,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 +202,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 {
|
||||
// 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)
|
||||
}
|
||||
if err := l.git.Add(gitPath); 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 item to git: %w", err)
|
||||
_ = 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 +304,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 +320,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 +370,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 +391,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 +420,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 +432,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 +465,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 +496,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 +544,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 +617,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 {
|
||||
@@ -611,3 +631,52 @@ func (l *Lnk) writeManagedItems(items []string) error {
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// FindBootstrapScript searches for a bootstrap script in the repository
|
||||
func (l *Lnk) FindBootstrapScript() (string, error) {
|
||||
// Check if repository is initialized
|
||||
if !l.git.IsGitRepository() {
|
||||
return "", fmt.Errorf("❌ Lnk repository not initialized\n 💡 Run \033[1mlnk init\033[0m first")
|
||||
}
|
||||
|
||||
// Look for bootstrap.sh - simple, opinionated choice
|
||||
scriptPath := filepath.Join(l.repoPath, "bootstrap.sh")
|
||||
if _, err := os.Stat(scriptPath); err == nil {
|
||||
return "bootstrap.sh", nil
|
||||
}
|
||||
|
||||
return "", nil // No bootstrap script found
|
||||
}
|
||||
|
||||
// RunBootstrapScript executes the bootstrap script
|
||||
func (l *Lnk) RunBootstrapScript(scriptName string) error {
|
||||
scriptPath := filepath.Join(l.repoPath, scriptName)
|
||||
|
||||
// Verify the script exists
|
||||
if _, err := os.Stat(scriptPath); os.IsNotExist(err) {
|
||||
return fmt.Errorf("❌ Bootstrap script not found: \033[31m%s\033[0m", scriptName)
|
||||
}
|
||||
|
||||
// Make sure it's executable
|
||||
if err := os.Chmod(scriptPath, 0755); err != nil {
|
||||
return fmt.Errorf("❌ Failed to make bootstrap script executable: %w", err)
|
||||
}
|
||||
|
||||
// Run with bash (since we only support bootstrap.sh)
|
||||
cmd := exec.Command("bash", scriptPath)
|
||||
|
||||
// Set working directory to the repository
|
||||
cmd.Dir = l.repoPath
|
||||
|
||||
// Connect to stdout/stderr for user to see output
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
cmd.Stdin = os.Stdin
|
||||
|
||||
// Run the script
|
||||
if err := cmd.Run(); err != nil {
|
||||
return fmt.Errorf("❌ Bootstrap script failed with error: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
@@ -1,6 +1,7 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
@@ -82,19 +83,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 +134,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 +276,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 +290,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 +501,325 @@ 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))
|
||||
}
|
||||
|
||||
// Test bootstrap script detection
|
||||
func (suite *CoreTestSuite) TestFindBootstrapScript() {
|
||||
err := suite.lnk.Init()
|
||||
suite.Require().NoError(err)
|
||||
|
||||
// Test with no bootstrap script
|
||||
scriptPath, err := suite.lnk.FindBootstrapScript()
|
||||
suite.NoError(err)
|
||||
suite.Empty(scriptPath)
|
||||
|
||||
// Test with bootstrap.sh
|
||||
bootstrapScript := filepath.Join(suite.tempDir, "lnk", "bootstrap.sh")
|
||||
err = os.WriteFile(bootstrapScript, []byte("#!/bin/bash\necho 'test'"), 0644)
|
||||
suite.Require().NoError(err)
|
||||
|
||||
scriptPath, err = suite.lnk.FindBootstrapScript()
|
||||
suite.NoError(err)
|
||||
suite.Equal("bootstrap.sh", scriptPath)
|
||||
}
|
||||
|
||||
// Test bootstrap script execution
|
||||
func (suite *CoreTestSuite) TestRunBootstrapScript() {
|
||||
err := suite.lnk.Init()
|
||||
suite.Require().NoError(err)
|
||||
|
||||
// Create a test script that creates a marker file
|
||||
bootstrapScript := filepath.Join(suite.tempDir, "lnk", "test.sh")
|
||||
markerFile := filepath.Join(suite.tempDir, "lnk", "bootstrap-executed.txt")
|
||||
scriptContent := fmt.Sprintf("#!/bin/bash\ntouch %s\necho 'Bootstrap executed'", markerFile)
|
||||
|
||||
err = os.WriteFile(bootstrapScript, []byte(scriptContent), 0755)
|
||||
suite.Require().NoError(err)
|
||||
|
||||
// Run the bootstrap script
|
||||
err = suite.lnk.RunBootstrapScript("test.sh")
|
||||
suite.NoError(err)
|
||||
|
||||
// Verify the marker file was created
|
||||
suite.FileExists(markerFile)
|
||||
}
|
||||
|
||||
// Test bootstrap script execution with error
|
||||
func (suite *CoreTestSuite) TestRunBootstrapScriptWithError() {
|
||||
err := suite.lnk.Init()
|
||||
suite.Require().NoError(err)
|
||||
|
||||
// Create a script that will fail
|
||||
bootstrapScript := filepath.Join(suite.tempDir, "lnk", "failing.sh")
|
||||
scriptContent := "#!/bin/bash\nexit 1"
|
||||
|
||||
err = os.WriteFile(bootstrapScript, []byte(scriptContent), 0755)
|
||||
suite.Require().NoError(err)
|
||||
|
||||
// Run the bootstrap script - should fail
|
||||
err = suite.lnk.RunBootstrapScript("failing.sh")
|
||||
suite.Error(err)
|
||||
suite.Contains(err.Error(), "Bootstrap script failed")
|
||||
}
|
||||
|
||||
// Test running bootstrap on non-existent script
|
||||
func (suite *CoreTestSuite) TestRunBootstrapScriptNotFound() {
|
||||
err := suite.lnk.Init()
|
||||
suite.Require().NoError(err)
|
||||
|
||||
// Try to run non-existent script
|
||||
err = suite.lnk.RunBootstrapScript("nonexistent.sh")
|
||||
suite.Error(err)
|
||||
suite.Contains(err.Error(), "Bootstrap script not found")
|
||||
}
|
||||
|
||||
func TestCoreSuite(t *testing.T) {
|
||||
suite.Run(t, new(CoreTestSuite))
|
||||
}
|
||||
|
119
internal/fs/errors.go
Normal file
119
internal/fs/errors.go
Normal 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
|
||||
}
|
@@ -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 &FileNotExistsError{Path: filePath, Err: err}
|
||||
}
|
||||
return fmt.Errorf("❌ Failed to check file: %w", 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
218
internal/git/errors.go
Normal 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
|
||||
}
|
@@ -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
|
||||
|
Reference in New Issue
Block a user