mirror of
https://github.com/yarlson/lnk.git
synced 2025-08-31 18:01:41 +02:00
feat(multihost): add support for host-specific configurations
Implement multihost functionality allowing separate management of common and host-specific dotfiles. Add new commands and flags for handling host-specific files, update core logic for file storage and tracking, and enhance documentation to reflect new capabilities.
This commit is contained in:
140
README.md
140
README.md
@@ -2,11 +2,12 @@
|
|||||||
|
|
||||||
**Git-native dotfiles management that doesn't suck.**
|
**Git-native dotfiles management that doesn't suck.**
|
||||||
|
|
||||||
Move your dotfiles to `~/.config/lnk`, symlink them back, and use Git like normal. That's it.
|
Move your dotfiles to `~/.config/lnk`, symlink them back, and use Git like normal. Supports both common configurations and host-specific setups.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
lnk init
|
lnk init
|
||||||
lnk add ~/.vimrc ~/.bashrc
|
lnk add ~/.vimrc ~/.bashrc # Common config
|
||||||
|
lnk add --host work ~/.ssh/config # Host-specific config
|
||||||
lnk push "setup"
|
lnk push "setup"
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -49,28 +50,85 @@ lnk init -r git@github.com:user/dotfiles.git
|
|||||||
### Daily workflow
|
### Daily workflow
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Add files/directories
|
# Add files/directories (common config)
|
||||||
lnk add ~/.vimrc ~/.config/nvim ~/.gitconfig
|
lnk add ~/.vimrc ~/.config/nvim ~/.gitconfig
|
||||||
|
|
||||||
|
# Add host-specific files
|
||||||
|
lnk add --host laptop ~/.ssh/config
|
||||||
|
lnk add --host work ~/.aws/credentials
|
||||||
|
|
||||||
# List managed files
|
# List managed files
|
||||||
lnk list
|
lnk list # Common config only
|
||||||
|
lnk list --host laptop # Laptop-specific config
|
||||||
|
lnk list --all # All configurations
|
||||||
|
|
||||||
# Check status
|
# Check status
|
||||||
lnk status
|
lnk status
|
||||||
|
|
||||||
# Sync changes
|
# Sync changes
|
||||||
lnk push "updated vim config"
|
lnk push "updated vim config"
|
||||||
lnk pull
|
lnk pull # Pull common config
|
||||||
|
lnk pull --host laptop # Pull laptop-specific config
|
||||||
```
|
```
|
||||||
|
|
||||||
## How it works
|
## How it works
|
||||||
|
|
||||||
```
|
```
|
||||||
|
Common files:
|
||||||
Before: ~/.vimrc (file)
|
Before: ~/.vimrc (file)
|
||||||
After: ~/.vimrc -> ~/.config/lnk/.vimrc (symlink)
|
After: ~/.vimrc -> ~/.config/lnk/.vimrc (symlink)
|
||||||
|
|
||||||
|
Host-specific files:
|
||||||
|
Before: ~/.ssh/config (file)
|
||||||
|
After: ~/.ssh/config -> ~/.config/lnk/laptop.lnk/.ssh/config (symlink)
|
||||||
```
|
```
|
||||||
|
|
||||||
Your files live in `~/.config/lnk` (a Git repo). Lnk creates symlinks back to original locations. Edit files normally, use Git normally.
|
Your files live in `~/.config/lnk` (a Git repo). Common files go in the root, host-specific files go in `<host>.lnk/` subdirectories. Lnk creates symlinks back to original locations. Edit files normally, use Git normally.
|
||||||
|
|
||||||
|
## Multihost Support
|
||||||
|
|
||||||
|
Lnk supports both **common configurations** (shared across all machines) and **host-specific configurations** (unique per machine).
|
||||||
|
|
||||||
|
### File Organization
|
||||||
|
|
||||||
|
```
|
||||||
|
~/.config/lnk/
|
||||||
|
├── .lnk # Tracks common files
|
||||||
|
├── .lnk.laptop # Tracks laptop-specific files
|
||||||
|
├── .lnk.work # Tracks work-specific files
|
||||||
|
├── .vimrc # Common file
|
||||||
|
├── .gitconfig # Common file
|
||||||
|
├── laptop.lnk/ # Laptop-specific storage
|
||||||
|
│ ├── .ssh/
|
||||||
|
│ │ └── config
|
||||||
|
│ └── .aws/
|
||||||
|
│ └── credentials
|
||||||
|
└── work.lnk/ # Work-specific storage
|
||||||
|
├── .ssh/
|
||||||
|
│ └── config
|
||||||
|
└── .company/
|
||||||
|
└── config
|
||||||
|
```
|
||||||
|
|
||||||
|
### 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 ~/.aws/credentials
|
||||||
|
|
||||||
|
# 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?
|
## Why not just Git?
|
||||||
|
|
||||||
@@ -87,7 +145,13 @@ You could `git init ~/.config/lnk` and manually symlink everything. Lnk just aut
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
lnk init -r git@github.com:you/dotfiles.git
|
lnk init -r git@github.com:you/dotfiles.git
|
||||||
|
|
||||||
|
# Add common config (shared across all machines)
|
||||||
lnk add ~/.bashrc ~/.vimrc ~/.gitconfig
|
lnk add ~/.bashrc ~/.vimrc ~/.gitconfig
|
||||||
|
|
||||||
|
# Add host-specific config
|
||||||
|
lnk add --host $(hostname) ~/.ssh/config ~/.aws/credentials
|
||||||
|
|
||||||
lnk push "initial setup"
|
lnk push "initial setup"
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -95,43 +159,75 @@ lnk push "initial setup"
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
lnk init -r git@github.com:you/dotfiles.git
|
lnk init -r git@github.com:you/dotfiles.git
|
||||||
lnk pull # auto-creates symlinks
|
|
||||||
|
# Pull common config
|
||||||
|
lnk pull
|
||||||
|
|
||||||
|
# Pull host-specific config (if it exists)
|
||||||
|
lnk pull --host $(hostname)
|
||||||
```
|
```
|
||||||
|
|
||||||
### Daily edits
|
### Daily edits
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
vim ~/.vimrc # edit normally
|
vim ~/.vimrc # edit normally
|
||||||
lnk list # see what's managed
|
lnk list # see common config
|
||||||
lnk status # check what changed
|
lnk list --host $(hostname) # see host-specific config
|
||||||
lnk push "new plugins" # commit & push
|
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 ~/.aws/credentials
|
||||||
|
lnk push "work aws config"
|
||||||
|
|
||||||
|
# Back on laptop
|
||||||
|
lnk pull # Get updates (work config won't affect laptop)
|
||||||
```
|
```
|
||||||
|
|
||||||
## Commands
|
## Commands
|
||||||
|
|
||||||
- `lnk init [-r remote]` - Create repo
|
- `lnk init [-r remote]` - Create repo
|
||||||
- `lnk add <files>` - Move files to repo, create symlinks
|
- `lnk add [--host HOST] <files>` - Move files to repo, create symlinks
|
||||||
- `lnk rm <files>` - Move files back, remove symlinks
|
- `lnk rm [--host HOST] <files>` - Move files back, remove symlinks
|
||||||
- `lnk list` - List files managed by lnk
|
- `lnk list [--host HOST] [--all]` - List files managed by lnk
|
||||||
- `lnk status` - Git status + sync info
|
- `lnk status` - Git status + sync info
|
||||||
- `lnk push [msg]` - Stage all, commit, push
|
- `lnk push [msg]` - Stage all, commit, push
|
||||||
- `lnk pull` - Pull + restore missing symlinks
|
- `lnk pull [--host HOST]` - Pull + restore missing symlinks
|
||||||
|
|
||||||
|
### Command Options
|
||||||
|
|
||||||
|
- `--host HOST` - Manage files for specific host (default: common configuration)
|
||||||
|
- `--all` - Show all configurations (common + all hosts) when listing
|
||||||
|
- `-r, --remote URL` - Clone from remote URL when initializing
|
||||||
|
|
||||||
## Technical bits
|
## Technical bits
|
||||||
|
|
||||||
- **Single binary** (~8MB, no deps)
|
- **Single binary** (~8MB, no deps)
|
||||||
- **Relative symlinks** (portable)
|
- **Relative symlinks** (portable)
|
||||||
- **XDG compliant** (`~/.config/lnk`)
|
- **XDG compliant** (`~/.config/lnk`)
|
||||||
|
- **Multihost support** (common + host-specific configs)
|
||||||
|
- **Git-native** (standard Git repo, no special formats)
|
||||||
|
|
||||||
## Alternatives
|
## Alternatives
|
||||||
|
|
||||||
| Tool | Complexity | Why choose it |
|
| Tool | Complexity | Why choose it |
|
||||||
| ------- | ---------- | ------------------------------------- |
|
| ------- | ---------- | -------------------------------------------- |
|
||||||
| **lnk** | Minimal | Just works, no config, Git-native |
|
| **lnk** | Minimal | Just works, no config, Git-native, multihost |
|
||||||
| chezmoi | High | Templates, encryption, cross-platform |
|
| chezmoi | High | Templates, encryption, cross-platform |
|
||||||
| yadm | Medium | Git power user, encryption |
|
| yadm | Medium | Git power user, encryption |
|
||||||
| dotbot | Low | YAML config, basic features |
|
| dotbot | Low | YAML config, basic features |
|
||||||
| stow | Low | Perl, symlink only |
|
| stow | Low | Perl, symlink only |
|
||||||
|
|
||||||
## Contributing
|
## Contributing
|
||||||
|
|
||||||
|
23
cmd/add.go
23
cmd/add.go
@@ -9,7 +9,7 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func newAddCmd() *cobra.Command {
|
func newAddCmd() *cobra.Command {
|
||||||
return &cobra.Command{
|
cmd := &cobra.Command{
|
||||||
Use: "add <file>",
|
Use: "add <file>",
|
||||||
Short: "✨ Add a file to lnk management",
|
Short: "✨ Add a file to lnk management",
|
||||||
Long: "Moves a file to the lnk repository and creates a symlink in its place.",
|
Long: "Moves a file to the lnk repository and creates a symlink in its place.",
|
||||||
@@ -17,17 +17,32 @@ func newAddCmd() *cobra.Command {
|
|||||||
SilenceUsage: true,
|
SilenceUsage: true,
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
filePath := args[0]
|
filePath := args[0]
|
||||||
|
host, _ := cmd.Flags().GetString("host")
|
||||||
|
|
||||||
|
var lnk *core.Lnk
|
||||||
|
if host != "" {
|
||||||
|
lnk = core.NewLnkWithHost(host)
|
||||||
|
} else {
|
||||||
|
lnk = core.NewLnk()
|
||||||
|
}
|
||||||
|
|
||||||
lnk := core.NewLnk()
|
|
||||||
if err := lnk.Add(filePath); err != nil {
|
if err := lnk.Add(filePath); err != nil {
|
||||||
return fmt.Errorf("failed to add file: %w", err)
|
return fmt.Errorf("failed to add file: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
basename := filepath.Base(filePath)
|
basename := filepath.Base(filePath)
|
||||||
printf(cmd, "✨ \033[1mAdded %s to lnk\033[0m\n", basename)
|
if host != "" {
|
||||||
printf(cmd, " 🔗 \033[90m%s\033[0m → \033[36m~/.config/lnk/%s\033[0m\n", filePath, basename)
|
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, basename)
|
||||||
|
} else {
|
||||||
|
printf(cmd, "✨ \033[1mAdded %s to lnk\033[0m\n", basename)
|
||||||
|
printf(cmd, " 🔗 \033[90m%s\033[0m → \033[36m~/.config/lnk/%s\033[0m\n", filePath, basename)
|
||||||
|
}
|
||||||
printf(cmd, " 📝 Use \033[1mlnk push\033[0m to sync to remote\n")
|
printf(cmd, " 📝 Use \033[1mlnk push\033[0m to sync to remote\n")
|
||||||
return nil
|
return nil
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
cmd.Flags().StringP("host", "H", "", "Manage file for specific host (default: common configuration)")
|
||||||
|
return cmd
|
||||||
}
|
}
|
||||||
|
192
cmd/list.go
192
cmd/list.go
@@ -2,42 +2,192 @@ package cmd
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
"github.com/yarlson/lnk/internal/core"
|
"github.com/yarlson/lnk/internal/core"
|
||||||
)
|
)
|
||||||
|
|
||||||
func newListCmd() *cobra.Command {
|
func newListCmd() *cobra.Command {
|
||||||
return &cobra.Command{
|
cmd := &cobra.Command{
|
||||||
Use: "list",
|
Use: "list",
|
||||||
Short: "📋 List files managed by lnk",
|
Short: "📋 List files managed by lnk",
|
||||||
Long: "Display all files and directories currently managed by lnk.",
|
Long: "Display all files and directories currently managed by lnk.",
|
||||||
SilenceUsage: true,
|
SilenceUsage: true,
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
lnk := core.NewLnk()
|
host, _ := cmd.Flags().GetString("host")
|
||||||
managedItems, err := lnk.List()
|
all, _ := cmd.Flags().GetBool("all")
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to list managed items: %w", err)
|
if host != "" {
|
||||||
|
// Show specific host configuration
|
||||||
|
return listHostConfig(cmd, host)
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(managedItems) == 0 {
|
if all {
|
||||||
printf(cmd, "📋 \033[1mNo files currently managed by lnk\033[0m\n")
|
// Show all configurations (common + all hosts)
|
||||||
printf(cmd, " 💡 Use \033[1mlnk add <file>\033[0m to start managing files\n")
|
return listAllConfigs(cmd)
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
printf(cmd, "📋 \033[1mFiles managed by lnk\033[0m (\033[36m%d item", len(managedItems))
|
// Default: show common configuration
|
||||||
if len(managedItems) > 1 {
|
return listCommonConfig(cmd)
|
||||||
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
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 fmt.Errorf("failed to list managed items: %w", 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.NewLnkWithHost(host)
|
||||||
|
managedItems, err := lnk.List()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to list managed items for host %s: %w", host, 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 fmt.Errorf("failed to list common managed items: %w", 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 fmt.Errorf("failed to find host configurations: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, host := range hosts {
|
||||||
|
printf(cmd, "\n🖥️ \033[1mHost: %s\033[0m", host)
|
||||||
|
|
||||||
|
hostLnk := core.NewLnkWithHost(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, fmt.Errorf("failed to read repository directory: %w", 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")
|
||||||
}
|
}
|
||||||
|
27
cmd/pull.go
27
cmd/pull.go
@@ -8,20 +8,32 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func newPullCmd() *cobra.Command {
|
func newPullCmd() *cobra.Command {
|
||||||
return &cobra.Command{
|
cmd := &cobra.Command{
|
||||||
Use: "pull",
|
Use: "pull",
|
||||||
Short: "⬇️ Pull changes from remote and restore symlinks",
|
Short: "⬇️ Pull changes from remote and restore symlinks",
|
||||||
Long: "Fetches changes from remote repository and automatically restores symlinks for all managed files.",
|
Long: "Fetches changes from remote repository and automatically restores symlinks for all managed files.",
|
||||||
SilenceUsage: true,
|
SilenceUsage: true,
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
lnk := core.NewLnk()
|
host, _ := cmd.Flags().GetString("host")
|
||||||
|
|
||||||
|
var lnk *core.Lnk
|
||||||
|
if host != "" {
|
||||||
|
lnk = core.NewLnkWithHost(host)
|
||||||
|
} else {
|
||||||
|
lnk = core.NewLnk()
|
||||||
|
}
|
||||||
|
|
||||||
restored, err := lnk.Pull()
|
restored, err := lnk.Pull()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to pull changes: %w", err)
|
return fmt.Errorf("failed to pull changes: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(restored) > 0 {
|
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))
|
printf(cmd, " 🔗 Restored \033[1m%d symlink", len(restored))
|
||||||
if len(restored) > 1 {
|
if len(restored) > 1 {
|
||||||
printf(cmd, "s")
|
printf(cmd, "s")
|
||||||
@@ -32,7 +44,11 @@ func newPullCmd() *cobra.Command {
|
|||||||
}
|
}
|
||||||
printf(cmd, "\n 🎉 Your dotfiles are synced and ready!\n")
|
printf(cmd, "\n 🎉 Your dotfiles are synced and ready!\n")
|
||||||
} else {
|
} 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, " ✅ All symlinks already in place\n")
|
||||||
printf(cmd, " 🎉 Everything is up to date!\n")
|
printf(cmd, " 🎉 Everything is up to date!\n")
|
||||||
}
|
}
|
||||||
@@ -40,4 +56,7 @@ func newPullCmd() *cobra.Command {
|
|||||||
return nil
|
return nil
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
cmd.Flags().StringP("host", "H", "", "Pull and restore symlinks for specific host (default: common configuration)")
|
||||||
|
return cmd
|
||||||
}
|
}
|
||||||
|
23
cmd/rm.go
23
cmd/rm.go
@@ -9,7 +9,7 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func newRemoveCmd() *cobra.Command {
|
func newRemoveCmd() *cobra.Command {
|
||||||
return &cobra.Command{
|
cmd := &cobra.Command{
|
||||||
Use: "rm <file>",
|
Use: "rm <file>",
|
||||||
Short: "🗑️ Remove a file from lnk management",
|
Short: "🗑️ Remove a file from lnk management",
|
||||||
Long: "Removes a symlink and restores the original file from the lnk repository.",
|
Long: "Removes a symlink and restores the original file from the lnk repository.",
|
||||||
@@ -17,17 +17,32 @@ func newRemoveCmd() *cobra.Command {
|
|||||||
SilenceUsage: true,
|
SilenceUsage: true,
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
filePath := args[0]
|
filePath := args[0]
|
||||||
|
host, _ := cmd.Flags().GetString("host")
|
||||||
|
|
||||||
|
var lnk *core.Lnk
|
||||||
|
if host != "" {
|
||||||
|
lnk = core.NewLnkWithHost(host)
|
||||||
|
} else {
|
||||||
|
lnk = core.NewLnk()
|
||||||
|
}
|
||||||
|
|
||||||
lnk := core.NewLnk()
|
|
||||||
if err := lnk.Remove(filePath); err != nil {
|
if err := lnk.Remove(filePath); err != nil {
|
||||||
return fmt.Errorf("failed to remove file: %w", err)
|
return fmt.Errorf("failed to remove file: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
basename := filepath.Base(filePath)
|
basename := filepath.Base(filePath)
|
||||||
printf(cmd, "🗑️ \033[1mRemoved %s from lnk\033[0m\n", basename)
|
if host != "" {
|
||||||
printf(cmd, " ↩️ \033[90m~/.config/lnk/%s\033[0m → \033[36m%s\033[0m\n", basename, filePath)
|
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")
|
printf(cmd, " 📄 Original file restored\n")
|
||||||
return nil
|
return nil
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
cmd.Flags().StringP("host", "H", "", "Remove file from specific host configuration (default: common configuration)")
|
||||||
|
return cmd
|
||||||
}
|
}
|
||||||
|
16
cmd/root.go
16
cmd/root.go
@@ -20,16 +20,18 @@ func NewRootCommand() *cobra.Command {
|
|||||||
Long: `🔗 Lnk - Git-native dotfiles management that doesn't suck.
|
Long: `🔗 Lnk - Git-native dotfiles management that doesn't suck.
|
||||||
|
|
||||||
Move your dotfiles to ~/.config/lnk, symlink them back, and use Git like normal.
|
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:
|
✨ Examples:
|
||||||
lnk init # Fresh start
|
lnk init # Fresh start
|
||||||
lnk init -r <repo-url> # Clone existing dotfiles
|
lnk init -r <repo-url> # Clone existing dotfiles
|
||||||
lnk add ~/.vimrc ~/.bashrc # Start managing files
|
lnk add ~/.vimrc ~/.bashrc # Start managing common files
|
||||||
lnk push "setup complete" # Sync to remote
|
lnk add --host work ~/.ssh/config # Manage host-specific files
|
||||||
lnk pull # Get latest changes
|
lnk list --all # Show all configurations
|
||||||
|
lnk pull --host work # Pull host-specific changes
|
||||||
|
lnk push "setup complete" # Sync to remote
|
||||||
|
|
||||||
🎯 Simple, fast, and Git-native.`,
|
🎯 Simple, fast, Git-native, and multi-host ready.`,
|
||||||
SilenceUsage: true,
|
SilenceUsage: true,
|
||||||
Version: fmt.Sprintf("%s (built %s)", version, buildTime),
|
Version: fmt.Sprintf("%s (built %s)", version, buildTime),
|
||||||
}
|
}
|
||||||
|
107
cmd/root_test.go
107
cmd/root_test.go
@@ -528,6 +528,113 @@ func (suite *CLITestSuite) TestStatusDirtyRepo() {
|
|||||||
suite.Contains(output, "lnk push")
|
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()
|
||||||
|
|
||||||
|
// Test list command - common only
|
||||||
|
err = suite.runCommand("list")
|
||||||
|
suite.NoError(err)
|
||||||
|
output = suite.stdout.String()
|
||||||
|
suite.Contains(output, "Files managed by lnk (common)")
|
||||||
|
suite.Contains(output, ".bashrc")
|
||||||
|
suite.NotContains(output, ".vimrc")
|
||||||
|
suite.stdout.Reset()
|
||||||
|
|
||||||
|
// Test list command - specific host
|
||||||
|
err = suite.runCommand("list", "--host", "workstation")
|
||||||
|
suite.NoError(err)
|
||||||
|
output = suite.stdout.String()
|
||||||
|
suite.Contains(output, "Files managed by lnk (host: workstation)")
|
||||||
|
suite.Contains(output, ".vimrc")
|
||||||
|
suite.NotContains(output, ".bashrc")
|
||||||
|
suite.stdout.Reset()
|
||||||
|
|
||||||
|
// Test list command - all configurations
|
||||||
|
err = suite.runCommand("list", "--all")
|
||||||
|
suite.NoError(err)
|
||||||
|
output = suite.stdout.String()
|
||||||
|
suite.Contains(output, "All configurations managed by lnk")
|
||||||
|
suite.Contains(output, "Common configuration")
|
||||||
|
suite.Contains(output, "Host: workstation")
|
||||||
|
suite.Contains(output, ".bashrc")
|
||||||
|
suite.Contains(output, ".vimrc")
|
||||||
|
suite.stdout.Reset()
|
||||||
|
|
||||||
|
// Test remove from host-specific
|
||||||
|
err = suite.runCommand("rm", "--host", "workstation", testFile2)
|
||||||
|
suite.NoError(err)
|
||||||
|
output = suite.stdout.String()
|
||||||
|
suite.Contains(output, "Removed .vimrc from lnk (host: workstation)")
|
||||||
|
suite.stdout.Reset()
|
||||||
|
|
||||||
|
// Test remove from common
|
||||||
|
err = suite.runCommand("rm", testFile1)
|
||||||
|
suite.NoError(err)
|
||||||
|
output = suite.stdout.String()
|
||||||
|
suite.Contains(output, "Removed .bashrc from lnk")
|
||||||
|
suite.NotContains(output, "host:")
|
||||||
|
suite.stdout.Reset()
|
||||||
|
|
||||||
|
// Verify files are restored
|
||||||
|
info1, err := os.Lstat(testFile1)
|
||||||
|
suite.NoError(err)
|
||||||
|
suite.Equal(os.FileMode(0), info1.Mode()&os.ModeSymlink)
|
||||||
|
|
||||||
|
info2, err := os.Lstat(testFile2)
|
||||||
|
suite.NoError(err)
|
||||||
|
suite.Equal(os.FileMode(0), info2.Mode()&os.ModeSymlink)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (suite *CLITestSuite) TestMultihostErrorHandling() {
|
||||||
|
// Initialize repository
|
||||||
|
err := suite.runCommand("init")
|
||||||
|
suite.Require().NoError(err)
|
||||||
|
suite.stdout.Reset()
|
||||||
|
|
||||||
|
// Try to remove from non-existent host config
|
||||||
|
testFile := filepath.Join(suite.tempDir, ".bashrc")
|
||||||
|
err = os.WriteFile(testFile, []byte("export PATH=/usr/local/bin:$PATH"), 0644)
|
||||||
|
suite.Require().NoError(err)
|
||||||
|
|
||||||
|
err = suite.runCommand("rm", "--host", "nonexistent", testFile)
|
||||||
|
suite.Error(err)
|
||||||
|
suite.Contains(err.Error(), "File is not managed by lnk")
|
||||||
|
|
||||||
|
// Try to list non-existent host config
|
||||||
|
err = suite.runCommand("list", "--host", "nonexistent")
|
||||||
|
suite.NoError(err) // Should not error, just show empty
|
||||||
|
output := suite.stdout.String()
|
||||||
|
suite.Contains(output, "No files currently managed by lnk (host: nonexistent)")
|
||||||
|
}
|
||||||
|
|
||||||
func TestCLISuite(t *testing.T) {
|
func TestCLISuite(t *testing.T) {
|
||||||
suite.Run(t, new(CLITestSuite))
|
suite.Run(t, new(CLITestSuite))
|
||||||
}
|
}
|
||||||
|
@@ -14,20 +14,42 @@ import (
|
|||||||
// Lnk represents the main application logic
|
// Lnk represents the main application logic
|
||||||
type Lnk struct {
|
type Lnk struct {
|
||||||
repoPath string
|
repoPath string
|
||||||
|
host string // Host-specific configuration
|
||||||
git *git.Git
|
git *git.Git
|
||||||
fs *fs.FileSystem
|
fs *fs.FileSystem
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewLnk creates a new Lnk instance
|
// NewLnk creates a new Lnk instance for common configuration
|
||||||
func NewLnk() *Lnk {
|
func NewLnk() *Lnk {
|
||||||
repoPath := getRepoPath()
|
repoPath := getRepoPath()
|
||||||
return &Lnk{
|
return &Lnk{
|
||||||
repoPath: repoPath,
|
repoPath: repoPath,
|
||||||
|
host: "", // Empty host means common configuration
|
||||||
git: git.New(repoPath),
|
git: git.New(repoPath),
|
||||||
fs: fs.New(),
|
fs: fs.New(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// NewLnkWithHost creates a new Lnk instance for host-specific configuration
|
||||||
|
func NewLnkWithHost(host string) *Lnk {
|
||||||
|
repoPath := getRepoPath()
|
||||||
|
return &Lnk{
|
||||||
|
repoPath: repoPath,
|
||||||
|
host: host,
|
||||||
|
git: git.New(repoPath),
|
||||||
|
fs: fs.New(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
// getRepoPath returns the path to the lnk repository directory
|
||||||
func getRepoPath() string {
|
func getRepoPath() string {
|
||||||
xdgConfig := os.Getenv("XDG_CONFIG_HOME")
|
xdgConfig := os.Getenv("XDG_CONFIG_HOME")
|
||||||
@@ -43,14 +65,38 @@ func getRepoPath() string {
|
|||||||
return filepath.Join(xdgConfig, "lnk")
|
return filepath.Join(xdgConfig, "lnk")
|
||||||
}
|
}
|
||||||
|
|
||||||
// generateRepoName creates a unique repository filename from a relative path
|
// generateRepoName creates a repository path from a relative path
|
||||||
func generateRepoName(relativePath string) string {
|
func generateRepoName(relativePath string, host string) string {
|
||||||
// Replace slashes and backslashes with underscores to create valid filename
|
if host != "" {
|
||||||
|
// For host-specific files, preserve the directory structure
|
||||||
|
return relativePath
|
||||||
|
}
|
||||||
|
|
||||||
|
// For common files, replace slashes and backslashes with underscores to create valid filename
|
||||||
repoName := strings.ReplaceAll(relativePath, "/", "_")
|
repoName := strings.ReplaceAll(relativePath, "/", "_")
|
||||||
repoName = strings.ReplaceAll(repoName, "\\", "_")
|
repoName = strings.ReplaceAll(repoName, "\\", "_")
|
||||||
|
|
||||||
return 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
|
// getRelativePath converts an absolute path to a relative path from home directory
|
||||||
func getRelativePath(absPath string) (string, error) {
|
func getRelativePath(absPath string) (string, error) {
|
||||||
homeDir, err := os.UserHomeDir()
|
homeDir, err := os.UserHomeDir()
|
||||||
@@ -147,9 +193,16 @@ func (l *Lnk) Add(filePath string) error {
|
|||||||
return fmt.Errorf("failed to get relative path: %w", err)
|
return fmt.Errorf("failed to get relative path: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generate unique repository name from relative path
|
// Generate repository path from relative path
|
||||||
repoName := generateRepoName(relativePath)
|
repoName := generateRepoName(relativePath, l.host)
|
||||||
destPath := filepath.Join(l.repoPath, repoName)
|
storagePath := l.getHostStoragePath()
|
||||||
|
destPath := filepath.Join(storagePath, repoName)
|
||||||
|
|
||||||
|
// 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
|
// Check if this relative path is already managed
|
||||||
managedItems, err := l.getManagedItems()
|
managedItems, err := l.getManagedItems()
|
||||||
@@ -203,7 +256,12 @@ func (l *Lnk) Add(filePath string) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Add both the item and .lnk file to git in a single commit
|
// 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 := repoName
|
||||||
|
if l.host != "" {
|
||||||
|
gitPath = filepath.Join(l.host+".lnk", repoName)
|
||||||
|
}
|
||||||
|
if err := l.git.Add(gitPath); err != nil {
|
||||||
// Try to restore the original state if git add fails
|
// Try to restore the original state if git add fails
|
||||||
_ = os.Remove(absPath) // Ignore error in cleanup
|
_ = os.Remove(absPath) // Ignore error in cleanup
|
||||||
_ = l.removeManagedItem(relativePath) // Ignore error in cleanup
|
_ = l.removeManagedItem(relativePath) // Ignore error in cleanup
|
||||||
@@ -216,7 +274,7 @@ func (l *Lnk) Add(filePath string) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Add .lnk file to the same commit
|
// 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
|
// Try to restore the original state if git add fails
|
||||||
_ = os.Remove(absPath) // Ignore error in cleanup
|
_ = os.Remove(absPath) // Ignore error in cleanup
|
||||||
_ = l.removeManagedItem(relativePath) // Ignore error in cleanup
|
_ = l.removeManagedItem(relativePath) // Ignore error in cleanup
|
||||||
@@ -292,8 +350,6 @@ func (l *Lnk) Remove(filePath string) error {
|
|||||||
target = filepath.Join(filepath.Dir(absPath), target)
|
target = filepath.Join(filepath.Dir(absPath), target)
|
||||||
}
|
}
|
||||||
|
|
||||||
repoName := filepath.Base(target)
|
|
||||||
|
|
||||||
// Check if target is a directory or file
|
// Check if target is a directory or file
|
||||||
info, err := os.Stat(target)
|
info, err := os.Stat(target)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -310,13 +366,18 @@ func (l *Lnk) Remove(filePath string) error {
|
|||||||
return fmt.Errorf("failed to update tracking file: %w", err)
|
return fmt.Errorf("failed to update tracking file: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remove from Git first (while the item is still in the repository)
|
// Generate the correct git path for removal
|
||||||
if err := l.git.Remove(repoName); err != nil {
|
repoName := generateRepoName(relativePath, l.host)
|
||||||
|
gitPath := repoName
|
||||||
|
if l.host != "" {
|
||||||
|
gitPath = filepath.Join(l.host+".lnk", repoName)
|
||||||
|
}
|
||||||
|
if err := l.git.Remove(gitPath); err != nil {
|
||||||
return fmt.Errorf("failed to remove from git: %w", err)
|
return fmt.Errorf("failed to remove from git: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add .lnk file to the same commit
|
// Add .lnk file to the same commit
|
||||||
if err := l.git.Add(".lnk"); err != nil {
|
if err := l.git.Add(l.getLnkFileName()); err != nil {
|
||||||
return fmt.Errorf("failed to add .lnk file to git: %w", err)
|
return fmt.Errorf("failed to add .lnk file to git: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -461,8 +522,9 @@ func (l *Lnk) RestoreSymlinks() ([]string, error) {
|
|||||||
|
|
||||||
for _, relativePath := range managedItems {
|
for _, relativePath := range managedItems {
|
||||||
// Generate repository name from relative path
|
// Generate repository name from relative path
|
||||||
repoName := generateRepoName(relativePath)
|
repoName := generateRepoName(relativePath, l.host)
|
||||||
repoItem := filepath.Join(l.repoPath, repoName)
|
storagePath := l.getHostStoragePath()
|
||||||
|
repoItem := filepath.Join(storagePath, repoName)
|
||||||
|
|
||||||
// Check if item exists in repository
|
// Check if item exists in repository
|
||||||
if _, err := os.Stat(repoItem); os.IsNotExist(err) {
|
if _, err := os.Stat(repoItem); os.IsNotExist(err) {
|
||||||
@@ -540,7 +602,7 @@ func (l *Lnk) isValidSymlink(symlinkPath, expectedTarget string) bool {
|
|||||||
|
|
||||||
// getManagedItems returns the list of managed files and directories from .lnk file
|
// getManagedItems returns the list of managed files and directories from .lnk file
|
||||||
func (l *Lnk) getManagedItems() ([]string, error) {
|
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 .lnk file doesn't exist, return empty list
|
||||||
if _, err := os.Stat(lnkFile); os.IsNotExist(err) {
|
if _, err := os.Stat(lnkFile); os.IsNotExist(err) {
|
||||||
@@ -613,7 +675,7 @@ func (l *Lnk) removeManagedItem(relativePath string) error {
|
|||||||
|
|
||||||
// writeManagedItems writes the list of managed items to .lnk file
|
// writeManagedItems writes the list of managed items to .lnk file
|
||||||
func (l *Lnk) writeManagedItems(items []string) error {
|
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")
|
content := strings.Join(items, "\n")
|
||||||
if len(items) > 0 {
|
if len(items) > 0 {
|
||||||
|
@@ -586,6 +586,185 @@ func (suite *CoreTestSuite) TestListManagedItems() {
|
|||||||
suite.Contains(items[0], ".config")
|
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 := NewLnkWithHost("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 := NewLnkWithHost("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 := NewLnkWithHost("work")
|
||||||
|
err = hostLnk.Add(testFile)
|
||||||
|
suite.Require().NoError(err)
|
||||||
|
|
||||||
|
// Verify tracking files are separate
|
||||||
|
commonItems, err := commonLnk.List()
|
||||||
|
suite.Require().NoError(err)
|
||||||
|
suite.Len(commonItems, 0) // Should be empty after removal
|
||||||
|
|
||||||
|
hostItems, err := hostLnk.List()
|
||||||
|
suite.Require().NoError(err)
|
||||||
|
suite.Len(hostItems, 1)
|
||||||
|
suite.Contains(hostItems[0], ".gitconfig")
|
||||||
|
|
||||||
|
// Verify content is correct
|
||||||
|
symlinkContent, err := os.ReadFile(testFile)
|
||||||
|
suite.Require().NoError(err)
|
||||||
|
suite.Equal(hostContent, string(symlinkContent))
|
||||||
|
}
|
||||||
|
|
||||||
func TestCoreSuite(t *testing.T) {
|
func TestCoreSuite(t *testing.T) {
|
||||||
suite.Run(t, new(CoreTestSuite))
|
suite.Run(t, new(CoreTestSuite))
|
||||||
}
|
}
|
||||||
|
Reference in New Issue
Block a user