mirror of
https://github.com/yarlson/lnk.git
synced 2025-09-24 21:11:27 +02:00
Compare commits
9 Commits
alert-auto
...
refactorin
Author | SHA1 | Date | |
---|---|---|---|
|
c718055f26 | ||
|
3e6b426a19 | ||
|
02f342b02b | ||
|
92f2575090 | ||
|
0f74723a03 | ||
|
093cc8ebe7 | ||
|
ff3cddc065 | ||
|
4a275ce4ca | ||
|
69c1038f3e |
135
README.md
135
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,25 +50,83 @@ 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 ~/.gitconfig
|
||||||
|
|
||||||
|
# List managed files
|
||||||
|
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
|
||||||
|
│ └── .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?
|
## Why not just Git?
|
||||||
|
|
||||||
@@ -84,7 +143,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 ~/.tmux.conf
|
||||||
|
|
||||||
lnk push "initial setup"
|
lnk push "initial setup"
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -92,56 +157,76 @@ 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 common config
|
||||||
|
lnk list --host $(hostname) # see host-specific config
|
||||||
|
lnk list --all # see everything
|
||||||
lnk status # check what changed
|
lnk status # check what changed
|
||||||
lnk push "new plugins" # commit & push
|
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
|
## 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 [--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 |
|
||||||
|
|
||||||
## FAQ
|
|
||||||
|
|
||||||
**Q: What if I already have dotfiles in Git?**
|
|
||||||
A: `git clone your-repo ~/.config/lnk && lnk add ~/.vimrc` (adopts existing files)
|
|
||||||
|
|
||||||
**Q: How do I handle machine-specific configs?**
|
|
||||||
A: Git branches, or just don't manage machine-specific files with lnk
|
|
||||||
|
|
||||||
**Q: Windows support?**
|
|
||||||
A: Symlinks work on Windows 10+, but untested
|
|
||||||
|
|
||||||
**Q: Production ready?**
|
|
||||||
A: I use it daily. It won't break your files. API might change (pre-1.0).
|
|
||||||
|
|
||||||
## Contributing
|
## Contributing
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
33
cmd/add.go
33
cmd/add.go
@@ -1,15 +1,16 @@
|
|||||||
package cmd
|
package cmd
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"context"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
"github.com/yarlson/lnk/internal/core"
|
|
||||||
|
"github.com/yarlson/lnk/internal/service"
|
||||||
)
|
)
|
||||||
|
|
||||||
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 +18,35 @@ 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")
|
||||||
|
|
||||||
lnk := core.NewLnk()
|
// Create service instance
|
||||||
if err := lnk.Add(filePath); err != nil {
|
lnkService, err := service.New()
|
||||||
return fmt.Errorf("failed to add file: %w", err)
|
if err != nil {
|
||||||
|
return wrapServiceError("initialize lnk service", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Add file using service layer
|
||||||
|
ctx := context.Background()
|
||||||
|
managedFile, err := lnkService.AddFile(ctx, filePath, host)
|
||||||
|
if err != nil {
|
||||||
|
return formatError(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Display success message
|
||||||
basename := filepath.Base(filePath)
|
basename := filepath.Base(filePath)
|
||||||
|
if host != "" {
|
||||||
|
printf(cmd, "✨ \033[1mAdded %s to lnk (host: %s)\033[0m\n", basename, host)
|
||||||
|
printf(cmd, " 🔗 \033[90m%s\033[0m → \033[36m~/.config/lnk/%s.lnk/%s\033[0m\n", managedFile.OriginalPath, host, managedFile.RelativePath)
|
||||||
|
} else {
|
||||||
printf(cmd, "✨ \033[1mAdded %s to lnk\033[0m\n", basename)
|
printf(cmd, "✨ \033[1mAdded %s to lnk\033[0m\n", basename)
|
||||||
printf(cmd, " 🔗 \033[90m%s\033[0m → \033[36m~/.config/lnk/%s\033[0m\n", filePath, basename)
|
printf(cmd, " 🔗 \033[90m%s\033[0m → \033[36m~/.config/lnk/%s\033[0m\n", managedFile.OriginalPath, managedFile.RelativePath)
|
||||||
|
}
|
||||||
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
|
||||||
}
|
}
|
||||||
|
19
cmd/init.go
19
cmd/init.go
@@ -1,10 +1,11 @@
|
|||||||
package cmd
|
package cmd
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"context"
|
||||||
|
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
"github.com/yarlson/lnk/internal/core"
|
|
||||||
|
"github.com/yarlson/lnk/internal/service"
|
||||||
)
|
)
|
||||||
|
|
||||||
func newInitCmd() *cobra.Command {
|
func newInitCmd() *cobra.Command {
|
||||||
@@ -16,11 +17,19 @@ func newInitCmd() *cobra.Command {
|
|||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
remote, _ := cmd.Flags().GetString("remote")
|
remote, _ := cmd.Flags().GetString("remote")
|
||||||
|
|
||||||
lnk := core.NewLnk()
|
// Create service instance
|
||||||
if err := lnk.InitWithRemote(remote); err != nil {
|
lnkService, err := service.New()
|
||||||
return fmt.Errorf("failed to initialize lnk: %w", err)
|
if err != nil {
|
||||||
|
return wrapServiceError("initialize lnk service", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Initialize repository using service layer
|
||||||
|
ctx := context.Background()
|
||||||
|
if err := lnkService.InitializeRepository(ctx, remote); err != nil {
|
||||||
|
return formatError(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Display success message
|
||||||
if remote != "" {
|
if remote != "" {
|
||||||
printf(cmd, "🎯 \033[1mInitialized lnk repository\033[0m\n")
|
printf(cmd, "🎯 \033[1mInitialized lnk repository\033[0m\n")
|
||||||
printf(cmd, " 📦 Cloned from: \033[36m%s\033[0m\n", remote)
|
printf(cmd, " 📦 Cloned from: \033[36m%s\033[0m\n", remote)
|
||||||
|
195
cmd/list.go
Normal file
195
cmd/list.go
Normal file
@@ -0,0 +1,195 @@
|
|||||||
|
package cmd
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
|
||||||
|
"github.com/yarlson/lnk/internal/service"
|
||||||
|
)
|
||||||
|
|
||||||
|
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,
|
||||||
|
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 {
|
||||||
|
ctx := context.Background()
|
||||||
|
lnkService, err := service.New()
|
||||||
|
if err != nil {
|
||||||
|
return wrapServiceError("initialize lnk service", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
managedFiles, err := lnkService.ListManagedFiles(ctx, "")
|
||||||
|
if err != nil {
|
||||||
|
return formatError(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(managedFiles) == 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(managedFiles))
|
||||||
|
if len(managedFiles) > 1 {
|
||||||
|
printf(cmd, "s")
|
||||||
|
}
|
||||||
|
printf(cmd, "\033[0m):\n\n")
|
||||||
|
|
||||||
|
for _, file := range managedFiles {
|
||||||
|
printf(cmd, " 🔗 \033[36m%s\033[0m\n", file.RelativePath)
|
||||||
|
}
|
||||||
|
|
||||||
|
printf(cmd, "\n💡 Use \033[1mlnk status\033[0m to check sync status\n")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func listHostConfig(cmd *cobra.Command, host string) error {
|
||||||
|
ctx := context.Background()
|
||||||
|
lnkService, err := service.New()
|
||||||
|
if err != nil {
|
||||||
|
return wrapServiceError("initialize lnk service", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
managedFiles, err := lnkService.ListManagedFiles(ctx, host)
|
||||||
|
if err != nil {
|
||||||
|
return formatError(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(managedFiles) == 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(managedFiles))
|
||||||
|
if len(managedFiles) > 1 {
|
||||||
|
printf(cmd, "s")
|
||||||
|
}
|
||||||
|
printf(cmd, "\033[0m):\n\n")
|
||||||
|
|
||||||
|
for _, file := range managedFiles {
|
||||||
|
printf(cmd, " 🔗 \033[36m%s\033[0m\n", file.RelativePath)
|
||||||
|
}
|
||||||
|
|
||||||
|
printf(cmd, "\n💡 Use \033[1mlnk status\033[0m to check sync status\n")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func listAllConfigs(cmd *cobra.Command) error {
|
||||||
|
ctx := context.Background()
|
||||||
|
lnkService, err := service.New()
|
||||||
|
if err != nil {
|
||||||
|
return wrapServiceError("initialize lnk service", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// List common configuration
|
||||||
|
printf(cmd, "📋 \033[1mAll configurations managed by lnk\033[0m\n\n")
|
||||||
|
|
||||||
|
commonFiles, err := lnkService.ListManagedFiles(ctx, "")
|
||||||
|
if err != nil {
|
||||||
|
return formatError(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
printf(cmd, "🌐 \033[1mCommon configuration\033[0m (\033[36m%d item", len(commonFiles))
|
||||||
|
if len(commonFiles) > 1 {
|
||||||
|
printf(cmd, "s")
|
||||||
|
}
|
||||||
|
printf(cmd, "\033[0m):\n")
|
||||||
|
|
||||||
|
if len(commonFiles) == 0 {
|
||||||
|
printf(cmd, " \033[90m(no files)\033[0m\n")
|
||||||
|
} else {
|
||||||
|
for _, file := range commonFiles {
|
||||||
|
printf(cmd, " 🔗 \033[36m%s\033[0m\n", file.RelativePath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find all host-specific configurations
|
||||||
|
hosts, err := findHostConfigs(lnkService)
|
||||||
|
if err != nil {
|
||||||
|
return formatError(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, host := range hosts {
|
||||||
|
printf(cmd, "\n🖥️ \033[1mHost: %s\033[0m", host)
|
||||||
|
|
||||||
|
hostFiles, err := lnkService.ListManagedFiles(ctx, host)
|
||||||
|
if err != nil {
|
||||||
|
printf(cmd, " \033[31m(error: %v)\033[0m\n", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
printf(cmd, " (\033[36m%d item", len(hostFiles))
|
||||||
|
if len(hostFiles) > 1 {
|
||||||
|
printf(cmd, "s")
|
||||||
|
}
|
||||||
|
printf(cmd, "\033[0m):\n")
|
||||||
|
|
||||||
|
if len(hostFiles) == 0 {
|
||||||
|
printf(cmd, " \033[90m(no files)\033[0m\n")
|
||||||
|
} else {
|
||||||
|
for _, file := range hostFiles {
|
||||||
|
printf(cmd, " 🔗 \033[36m%s\033[0m\n", file.RelativePath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
printf(cmd, "\n💡 Use \033[1mlnk list --host <hostname>\033[0m to see specific host configuration\n")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func findHostConfigs(service *service.Service) ([]string, error) {
|
||||||
|
repoPath := service.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
|
||||||
|
}
|
35
cmd/pull.go
35
cmd/pull.go
@@ -1,38 +1,56 @@
|
|||||||
package cmd
|
package cmd
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"context"
|
||||||
|
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
"github.com/yarlson/lnk/internal/core"
|
|
||||||
|
"github.com/yarlson/lnk/internal/service"
|
||||||
)
|
)
|
||||||
|
|
||||||
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")
|
||||||
restored, err := lnk.Pull()
|
|
||||||
|
// Create service instance
|
||||||
|
lnkService, err := service.New()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to pull changes: %w", err)
|
return wrapServiceError("initialize lnk service", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pull changes using the service
|
||||||
|
ctx := context.Background()
|
||||||
|
restored, err := lnkService.PullChanges(ctx, host)
|
||||||
|
if err != nil {
|
||||||
|
return formatError(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(restored) > 0 {
|
if len(restored) > 0 {
|
||||||
|
if host != "" {
|
||||||
|
printf(cmd, "⬇️ \033[1;32mSuccessfully pulled changes (host: %s)\033[0m\n", host)
|
||||||
|
} else {
|
||||||
printf(cmd, "⬇️ \033[1;32mSuccessfully pulled changes\033[0m\n")
|
printf(cmd, "⬇️ \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")
|
||||||
}
|
}
|
||||||
printf(cmd, "\033[0m:\n")
|
printf(cmd, "\033[0m:\n")
|
||||||
for _, file := range restored {
|
for _, file := range restored {
|
||||||
printf(cmd, " ✨ \033[36m%s\033[0m\n", file)
|
printf(cmd, " ✨ \033[36m%s\033[0m\n", file.RelativePath)
|
||||||
}
|
}
|
||||||
printf(cmd, "\n 🎉 Your dotfiles are synced and ready!\n")
|
printf(cmd, "\n 🎉 Your dotfiles are synced and ready!\n")
|
||||||
|
} else {
|
||||||
|
if host != "" {
|
||||||
|
printf(cmd, "⬇️ \033[1;32mSuccessfully pulled changes (host: %s)\033[0m\n", host)
|
||||||
} else {
|
} else {
|
||||||
printf(cmd, "⬇️ \033[1;32mSuccessfully pulled changes\033[0m\n")
|
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 +58,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
|
||||||
}
|
}
|
||||||
|
18
cmd/push.go
18
cmd/push.go
@@ -1,10 +1,11 @@
|
|||||||
package cmd
|
package cmd
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"context"
|
||||||
|
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
"github.com/yarlson/lnk/internal/core"
|
|
||||||
|
"github.com/yarlson/lnk/internal/service"
|
||||||
)
|
)
|
||||||
|
|
||||||
func newPushCmd() *cobra.Command {
|
func newPushCmd() *cobra.Command {
|
||||||
@@ -20,9 +21,16 @@ func newPushCmd() *cobra.Command {
|
|||||||
message = args[0]
|
message = args[0]
|
||||||
}
|
}
|
||||||
|
|
||||||
lnk := core.NewLnk()
|
// Create service instance
|
||||||
if err := lnk.Push(message); err != nil {
|
lnkService, err := service.New()
|
||||||
return fmt.Errorf("failed to push changes: %w", err)
|
if err != nil {
|
||||||
|
return wrapServiceError("initialize lnk service", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Push changes using the service
|
||||||
|
ctx := context.Background()
|
||||||
|
if err := lnkService.PushChanges(ctx, message); err != nil {
|
||||||
|
return formatError(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
printf(cmd, "🚀 \033[1;32mSuccessfully pushed changes\033[0m\n")
|
printf(cmd, "🚀 \033[1;32mSuccessfully pushed changes\033[0m\n")
|
||||||
|
29
cmd/rm.go
29
cmd/rm.go
@@ -1,15 +1,16 @@
|
|||||||
package cmd
|
package cmd
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"context"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
"github.com/yarlson/lnk/internal/core"
|
|
||||||
|
"github.com/yarlson/lnk/internal/service"
|
||||||
)
|
)
|
||||||
|
|
||||||
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 +18,33 @@ 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")
|
||||||
|
|
||||||
lnk := core.NewLnk()
|
// Create service instance
|
||||||
if err := lnk.Remove(filePath); err != nil {
|
lnkService, err := service.New()
|
||||||
return fmt.Errorf("failed to remove file: %w", err)
|
if err != nil {
|
||||||
|
return wrapServiceError("initialize lnk service", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove the file using the service
|
||||||
|
ctx := context.Background()
|
||||||
|
if err := lnkService.RemoveFile(ctx, filePath, host); err != nil {
|
||||||
|
return formatError(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
basename := filepath.Base(filePath)
|
basename := filepath.Base(filePath)
|
||||||
|
if host != "" {
|
||||||
|
printf(cmd, "🗑️ \033[1mRemoved %s from lnk (host: %s)\033[0m\n", basename, host)
|
||||||
|
printf(cmd, " ↩️ \033[90m~/.config/lnk/%s.lnk/%s\033[0m → \033[36m%s\033[0m\n", host, basename, filePath)
|
||||||
|
} else {
|
||||||
printf(cmd, "🗑️ \033[1mRemoved %s from lnk\033[0m\n", basename)
|
printf(cmd, "🗑️ \033[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, " ↩️ \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,17 +20,20 @@ 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 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 push "setup complete" # Sync to remote
|
||||||
lnk pull # Get latest changes
|
|
||||||
|
|
||||||
🎯 Simple, fast, and Git-native.`,
|
🎯 Simple, fast, Git-native, and multi-host ready.`,
|
||||||
SilenceUsage: true,
|
SilenceUsage: true,
|
||||||
|
SilenceErrors: true,
|
||||||
Version: fmt.Sprintf("%s (built %s)", version, buildTime),
|
Version: fmt.Sprintf("%s (built %s)", version, buildTime),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -38,6 +41,7 @@ That's it.
|
|||||||
rootCmd.AddCommand(newInitCmd())
|
rootCmd.AddCommand(newInitCmd())
|
||||||
rootCmd.AddCommand(newAddCmd())
|
rootCmd.AddCommand(newAddCmd())
|
||||||
rootCmd.AddCommand(newRemoveCmd())
|
rootCmd.AddCommand(newRemoveCmd())
|
||||||
|
rootCmd.AddCommand(newListCmd())
|
||||||
rootCmd.AddCommand(newStatusCmd())
|
rootCmd.AddCommand(newStatusCmd())
|
||||||
rootCmd.AddCommand(newPushCmd())
|
rootCmd.AddCommand(newPushCmd())
|
||||||
rootCmd.AddCommand(newPullCmd())
|
rootCmd.AddCommand(newPullCmd())
|
||||||
@@ -54,7 +58,9 @@ func SetVersion(v, bt string) {
|
|||||||
func Execute() {
|
func Execute() {
|
||||||
rootCmd := NewRootCommand()
|
rootCmd := NewRootCommand()
|
||||||
if err := rootCmd.Execute(); err != nil {
|
if err := rootCmd.Execute(); err != nil {
|
||||||
fmt.Fprintln(os.Stderr, err)
|
// Format the error nicely for the user
|
||||||
|
formattedErr := formatError(err)
|
||||||
|
fmt.Fprintln(os.Stderr, formattedErr)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
414
cmd/root_test.go
414
cmd/root_test.go
@@ -5,7 +5,6 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/stretchr/testify/suite"
|
"github.com/stretchr/testify/suite"
|
||||||
@@ -32,8 +31,11 @@ func (suite *CLITestSuite) SetupTest() {
|
|||||||
err = os.Chdir(tempDir)
|
err = os.Chdir(tempDir)
|
||||||
suite.Require().NoError(err)
|
suite.Require().NoError(err)
|
||||||
|
|
||||||
// Set XDG_CONFIG_HOME to temp directory
|
// Set HOME to temp directory for consistent relative path calculation
|
||||||
suite.T().Setenv("XDG_CONFIG_HOME", tempDir)
|
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
|
// Capture output
|
||||||
suite.stdout = &bytes.Buffer{}
|
suite.stdout = &bytes.Buffer{}
|
||||||
@@ -67,20 +69,13 @@ func (suite *CLITestSuite) TestInitCommand() {
|
|||||||
suite.Contains(output, "lnk add <file>")
|
suite.Contains(output, "lnk add <file>")
|
||||||
|
|
||||||
// Verify actual effect
|
// Verify actual effect
|
||||||
lnkDir := filepath.Join(suite.tempDir, "lnk")
|
lnkDir := filepath.Join(suite.tempDir, ".config", "lnk")
|
||||||
suite.DirExists(lnkDir)
|
suite.DirExists(lnkDir)
|
||||||
|
|
||||||
gitDir := filepath.Join(lnkDir, ".git")
|
gitDir := filepath.Join(lnkDir, ".git")
|
||||||
suite.DirExists(gitDir)
|
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() {
|
func (suite *CLITestSuite) TestAddCommand() {
|
||||||
// Initialize first
|
// Initialize first
|
||||||
err := suite.runCommand("init")
|
err := suite.runCommand("init")
|
||||||
@@ -107,19 +102,21 @@ func (suite *CLITestSuite) TestAddCommand() {
|
|||||||
suite.NoError(err)
|
suite.NoError(err)
|
||||||
suite.Equal(os.ModeSymlink, info.Mode()&os.ModeSymlink)
|
suite.Equal(os.ModeSymlink, info.Mode()&os.ModeSymlink)
|
||||||
|
|
||||||
// Verify some file exists in repo with .bashrc in the name
|
// Verify the file exists in repo with preserved directory structure
|
||||||
lnkDir := filepath.Join(suite.tempDir, "lnk")
|
lnkDir := filepath.Join(suite.tempDir, ".config", "lnk")
|
||||||
entries, err := os.ReadDir(lnkDir)
|
repoFile := filepath.Join(lnkDir, ".bashrc")
|
||||||
suite.NoError(err)
|
suite.FileExists(repoFile)
|
||||||
|
|
||||||
found := false
|
// Verify content is preserved in storage
|
||||||
for _, entry := range entries {
|
storedContent, err := os.ReadFile(repoFile)
|
||||||
if strings.Contains(entry.Name(), ".bashrc") && entry.Name() != ".lnk" {
|
suite.NoError(err)
|
||||||
found = true
|
suite.Equal("export PATH=/usr/local/bin:$PATH", string(storedContent))
|
||||||
break
|
|
||||||
}
|
// Verify .lnk file contains the correct entry
|
||||||
}
|
lnkFile := filepath.Join(lnkDir, ".lnk")
|
||||||
suite.True(found, "Repository should contain a file with .bashrc in the name")
|
lnkContent, err := os.ReadFile(lnkFile)
|
||||||
|
suite.NoError(err)
|
||||||
|
suite.Equal(".bashrc\n", string(lnkContent))
|
||||||
}
|
}
|
||||||
|
|
||||||
func (suite *CLITestSuite) TestRemoveCommand() {
|
func (suite *CLITestSuite) TestRemoveCommand() {
|
||||||
@@ -163,6 +160,81 @@ func (suite *CLITestSuite) TestStatusCommand() {
|
|||||||
suite.Contains(err.Error(), "no remote configured")
|
suite.Contains(err.Error(), "no remote 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() {
|
func (suite *CLITestSuite) TestErrorHandling() {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
@@ -207,6 +279,12 @@ func (suite *CLITestSuite) TestErrorHandling() {
|
|||||||
wantErr: false,
|
wantErr: false,
|
||||||
outContains: "Moves a file to the lnk repository",
|
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 {
|
for _, tt := range tests {
|
||||||
@@ -250,29 +328,57 @@ func (suite *CLITestSuite) TestCompleteWorkflow() {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "add config file",
|
name: "add config file",
|
||||||
args: []string{"add", ".bashrc"},
|
args: []string{"add", filepath.Join(suite.tempDir, ".bashrc")},
|
||||||
setup: func() {
|
setup: func() {
|
||||||
testFile := filepath.Join(suite.tempDir, ".bashrc")
|
testFile := filepath.Join(suite.tempDir, ".bashrc")
|
||||||
_ = os.WriteFile(testFile, []byte("export PATH=/usr/local/bin:$PATH"), 0644)
|
_ = os.WriteFile(testFile, []byte("export PATH=/usr/local/bin:$PATH"), 0644)
|
||||||
},
|
},
|
||||||
verify: func(output string) {
|
verify: func(output string) {
|
||||||
suite.Contains(output, "Added .bashrc to lnk")
|
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",
|
name: "add another file",
|
||||||
args: []string{"add", ".vimrc"},
|
args: []string{"add", filepath.Join(suite.tempDir, ".vimrc")},
|
||||||
setup: func() {
|
setup: func() {
|
||||||
testFile := filepath.Join(suite.tempDir, ".vimrc")
|
testFile := filepath.Join(suite.tempDir, ".vimrc")
|
||||||
_ = os.WriteFile(testFile, []byte("set number"), 0644)
|
_ = os.WriteFile(testFile, []byte("set number"), 0644)
|
||||||
},
|
},
|
||||||
verify: func(output string) {
|
verify: func(output string) {
|
||||||
suite.Contains(output, "Added .vimrc to lnk")
|
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",
|
name: "remove file",
|
||||||
args: []string{"rm", ".vimrc"},
|
args: []string{"rm", filepath.Join(suite.tempDir, ".vimrc")},
|
||||||
verify: func(output string) {
|
verify: func(output string) {
|
||||||
suite.Contains(output, "Removed .vimrc from lnk")
|
suite.Contains(output, "Removed .vimrc from lnk")
|
||||||
},
|
},
|
||||||
@@ -310,7 +416,7 @@ func (suite *CLITestSuite) TestRemoveUnmanagedFile() {
|
|||||||
// Try to remove it
|
// Try to remove it
|
||||||
err := suite.runCommand("rm", testFile)
|
err := suite.runCommand("rm", testFile)
|
||||||
suite.Error(err)
|
suite.Error(err)
|
||||||
suite.Contains(err.Error(), "File is not managed by lnk")
|
suite.Contains(err.Error(), "not a symlink")
|
||||||
}
|
}
|
||||||
|
|
||||||
func (suite *CLITestSuite) TestAddDirectory() {
|
func (suite *CLITestSuite) TestAddDirectory() {
|
||||||
@@ -319,10 +425,10 @@ func (suite *CLITestSuite) TestAddDirectory() {
|
|||||||
suite.stdout.Reset()
|
suite.stdout.Reset()
|
||||||
|
|
||||||
// Create a directory with files
|
// Create a directory with files
|
||||||
testDir := filepath.Join(suite.tempDir, ".config")
|
testDir := filepath.Join(suite.tempDir, ".ssh")
|
||||||
_ = os.MkdirAll(testDir, 0755)
|
_ = os.MkdirAll(testDir, 0755)
|
||||||
configFile := filepath.Join(testDir, "app.conf")
|
configFile := filepath.Join(testDir, "config")
|
||||||
_ = os.WriteFile(configFile, []byte("setting=value"), 0644)
|
_ = os.WriteFile(configFile, []byte("Host example.com"), 0644)
|
||||||
|
|
||||||
// Add the directory
|
// Add the directory
|
||||||
err := suite.runCommand("add", testDir)
|
err := suite.runCommand("add", testDir)
|
||||||
@@ -330,26 +436,78 @@ func (suite *CLITestSuite) TestAddDirectory() {
|
|||||||
|
|
||||||
// Check output
|
// Check output
|
||||||
output := suite.stdout.String()
|
output := suite.stdout.String()
|
||||||
suite.Contains(output, "Added .config to lnk")
|
suite.Contains(output, "Added .ssh to lnk")
|
||||||
|
|
||||||
// Verify directory is now a symlink
|
// Verify directory is now a symlink
|
||||||
info, err := os.Lstat(testDir)
|
info, err := os.Lstat(testDir)
|
||||||
suite.NoError(err)
|
suite.NoError(err)
|
||||||
suite.Equal(os.ModeSymlink, info.Mode()&os.ModeSymlink)
|
suite.Equal(os.ModeSymlink, info.Mode()&os.ModeSymlink)
|
||||||
|
|
||||||
// Verify some directory exists in repo with .config in the name
|
// Verify the directory exists in repo with preserved directory structure
|
||||||
lnkDir := filepath.Join(suite.tempDir, "lnk")
|
lnkDir := filepath.Join(suite.tempDir, ".config", "lnk")
|
||||||
entries, err := os.ReadDir(lnkDir)
|
repoDir := filepath.Join(lnkDir, ".ssh")
|
||||||
suite.NoError(err)
|
suite.DirExists(repoDir)
|
||||||
|
|
||||||
found := false
|
// Verify directory content is preserved
|
||||||
for _, entry := range entries {
|
repoConfigFile := filepath.Join(repoDir, "config")
|
||||||
if strings.Contains(entry.Name(), ".config") && entry.Name() != ".lnk" {
|
suite.FileExists(repoConfigFile)
|
||||||
found = true
|
storedContent, err := os.ReadFile(repoConfigFile)
|
||||||
break
|
suite.NoError(err)
|
||||||
}
|
suite.Equal("Host example.com", string(storedContent))
|
||||||
}
|
|
||||||
suite.True(found, "Repository should contain a directory with .config in the name")
|
// 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) TestRemoveDirectory() {
|
||||||
|
// Initialize repository
|
||||||
|
_ = suite.runCommand("init")
|
||||||
|
suite.stdout.Reset()
|
||||||
|
|
||||||
|
// Create a directory with files
|
||||||
|
testDir := filepath.Join(suite.tempDir, ".config", "aerospace")
|
||||||
|
_ = os.MkdirAll(testDir, 0755)
|
||||||
|
configFile := filepath.Join(testDir, "aerospace.toml")
|
||||||
|
_ = os.WriteFile(configFile, []byte("# Aerospace config"), 0644)
|
||||||
|
|
||||||
|
// Add the directory
|
||||||
|
err := suite.runCommand("add", testDir)
|
||||||
|
suite.NoError(err)
|
||||||
|
suite.stdout.Reset()
|
||||||
|
|
||||||
|
// Verify directory is now a symlink
|
||||||
|
info, err := os.Lstat(testDir)
|
||||||
|
suite.NoError(err)
|
||||||
|
suite.Equal(os.ModeSymlink, info.Mode()&os.ModeSymlink)
|
||||||
|
|
||||||
|
// Remove the directory
|
||||||
|
err = suite.runCommand("rm", testDir)
|
||||||
|
suite.NoError(err, "Should be able to remove directory without error")
|
||||||
|
|
||||||
|
// Check output
|
||||||
|
output := suite.stdout.String()
|
||||||
|
suite.Contains(output, "Removed aerospace from lnk")
|
||||||
|
suite.Contains(output, "Original file restored")
|
||||||
|
|
||||||
|
// Verify directory is no longer a symlink
|
||||||
|
info, err = os.Lstat(testDir)
|
||||||
|
suite.NoError(err)
|
||||||
|
suite.Equal(os.FileMode(0), info.Mode()&os.ModeSymlink) // Not a symlink
|
||||||
|
|
||||||
|
// Verify directory content is preserved
|
||||||
|
content, err := os.ReadFile(configFile)
|
||||||
|
suite.NoError(err)
|
||||||
|
suite.Equal("# Aerospace config", string(content))
|
||||||
|
|
||||||
|
// Verify directory is removed from tracking
|
||||||
|
lnkDir := filepath.Join(suite.tempDir, ".config", "lnk")
|
||||||
|
lnkFile := filepath.Join(lnkDir, ".lnk")
|
||||||
|
lnkContent, err := os.ReadFile(lnkFile)
|
||||||
|
suite.NoError(err)
|
||||||
|
suite.Equal("", string(lnkContent), ".lnk file should be empty after removing directory")
|
||||||
}
|
}
|
||||||
|
|
||||||
func (suite *CLITestSuite) TestSameBasenameFilesBug() {
|
func (suite *CLITestSuite) TestSameBasenameFilesBug() {
|
||||||
@@ -400,6 +558,27 @@ func (suite *CLITestSuite) TestSameBasenameFilesBug() {
|
|||||||
suite.Equal(contentA, string(contentAfterAddA), "First file should keep its original content")
|
suite.Equal(contentA, string(contentAfterAddA), "First file should keep its original content")
|
||||||
suite.Equal(contentB, string(contentAfterAddB), "Second 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
|
// Both files should be removable independently
|
||||||
suite.stdout.Reset()
|
suite.stdout.Reset()
|
||||||
err = suite.runCommand("rm", fileA)
|
err = suite.runCommand("rm", fileA)
|
||||||
@@ -440,8 +619,21 @@ func (suite *CLITestSuite) TestStatusDirtyRepo() {
|
|||||||
suite.Require().NoError(err)
|
suite.Require().NoError(err)
|
||||||
suite.stdout.Reset()
|
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
|
// 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 := exec.Command("git", "remote", "add", "origin", "https://github.com/test/dotfiles.git")
|
||||||
cmd.Dir = lnkDir
|
cmd.Dir = lnkDir
|
||||||
err = cmd.Run()
|
err = cmd.Run()
|
||||||
@@ -468,6 +660,140 @@ 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()
|
||||||
|
|
||||||
|
// 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(), "not a symlink")
|
||||||
|
|
||||||
|
// 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))
|
||||||
}
|
}
|
||||||
|
@@ -1,10 +1,12 @@
|
|||||||
package cmd
|
package cmd
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"context"
|
||||||
|
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
"github.com/yarlson/lnk/internal/core"
|
|
||||||
|
"github.com/yarlson/lnk/internal/models"
|
||||||
|
"github.com/yarlson/lnk/internal/service"
|
||||||
)
|
)
|
||||||
|
|
||||||
func newStatusCmd() *cobra.Command {
|
func newStatusCmd() *cobra.Command {
|
||||||
@@ -14,10 +16,15 @@ func newStatusCmd() *cobra.Command {
|
|||||||
Long: "Display how many commits ahead/behind the local repository is relative to the remote and check for uncommitted changes.",
|
Long: "Display how many commits ahead/behind the local repository is relative to the remote and check for uncommitted changes.",
|
||||||
SilenceUsage: true,
|
SilenceUsage: true,
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
lnk := core.NewLnk()
|
lnkService, err := service.New()
|
||||||
status, err := lnk.Status()
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to get status: %w", err)
|
return wrapServiceError("initialize lnk service", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
status, err := lnkService.GetStatus(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return formatError(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if status.Dirty {
|
if status.Dirty {
|
||||||
@@ -36,9 +43,9 @@ func newStatusCmd() *cobra.Command {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func displayDirtyStatus(cmd *cobra.Command, status *core.StatusInfo) {
|
func displayDirtyStatus(cmd *cobra.Command, status *models.SyncStatus) {
|
||||||
printf(cmd, "⚠️ \033[1;33mRepository has uncommitted changes\033[0m\n")
|
printf(cmd, "⚠️ \033[1;33mRepository has uncommitted changes\033[0m\n")
|
||||||
printf(cmd, " 📡 Remote: \033[36m%s\033[0m\n", status.Remote)
|
printf(cmd, " 📡 Remote: \033[36m%s\033[0m\n", getRemoteDisplay(status))
|
||||||
|
|
||||||
if status.Ahead == 0 && status.Behind == 0 {
|
if status.Ahead == 0 && status.Behind == 0 {
|
||||||
printf(cmd, "\n💡 Run \033[1mgit add && git commit\033[0m in \033[36m~/.config/lnk\033[0m or \033[1mlnk push\033[0m to commit changes\n")
|
printf(cmd, "\n💡 Run \033[1mgit add && git commit\033[0m in \033[36m~/.config/lnk\033[0m or \033[1mlnk push\033[0m to commit changes\n")
|
||||||
@@ -50,14 +57,14 @@ func displayDirtyStatus(cmd *cobra.Command, status *core.StatusInfo) {
|
|||||||
printf(cmd, "\n💡 Run \033[1mgit add && git commit\033[0m in \033[36m~/.config/lnk\033[0m or \033[1mlnk push\033[0m to commit changes\n")
|
printf(cmd, "\n💡 Run \033[1mgit add && git commit\033[0m in \033[36m~/.config/lnk\033[0m or \033[1mlnk push\033[0m to commit changes\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
func displayUpToDateStatus(cmd *cobra.Command, status *core.StatusInfo) {
|
func displayUpToDateStatus(cmd *cobra.Command, status *models.SyncStatus) {
|
||||||
printf(cmd, "✅ \033[1;32mRepository is up to date\033[0m\n")
|
printf(cmd, "✅ \033[1;32mRepository is up to date\033[0m\n")
|
||||||
printf(cmd, " 📡 Synced with \033[36m%s\033[0m\n", status.Remote)
|
printf(cmd, " 📡 Synced with \033[36m%s\033[0m\n", getRemoteDisplay(status))
|
||||||
}
|
}
|
||||||
|
|
||||||
func displaySyncStatus(cmd *cobra.Command, status *core.StatusInfo) {
|
func displaySyncStatus(cmd *cobra.Command, status *models.SyncStatus) {
|
||||||
printf(cmd, "📊 \033[1mRepository Status\033[0m\n")
|
printf(cmd, "📊 \033[1mRepository Status\033[0m\n")
|
||||||
printf(cmd, " 📡 Remote: \033[36m%s\033[0m\n", status.Remote)
|
printf(cmd, " 📡 Remote: \033[36m%s\033[0m\n", getRemoteDisplay(status))
|
||||||
printf(cmd, "\n")
|
printf(cmd, "\n")
|
||||||
|
|
||||||
displayAheadBehindInfo(cmd, status, false)
|
displayAheadBehindInfo(cmd, status, false)
|
||||||
@@ -69,7 +76,7 @@ func displaySyncStatus(cmd *cobra.Command, status *core.StatusInfo) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func displayAheadBehindInfo(cmd *cobra.Command, status *core.StatusInfo, isDirty bool) {
|
func displayAheadBehindInfo(cmd *cobra.Command, status *models.SyncStatus, isDirty bool) {
|
||||||
if status.Ahead > 0 {
|
if status.Ahead > 0 {
|
||||||
commitText := getCommitText(status.Ahead)
|
commitText := getCommitText(status.Ahead)
|
||||||
if isDirty {
|
if isDirty {
|
||||||
@@ -91,3 +98,13 @@ func getCommitText(count int) string {
|
|||||||
}
|
}
|
||||||
return "commits"
|
return "commits"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func getRemoteDisplay(status *models.SyncStatus) string {
|
||||||
|
if status.HasRemote && status.RemoteBranch != "" {
|
||||||
|
return status.RemoteBranch
|
||||||
|
}
|
||||||
|
if status.HasRemote && status.RemoteURL != "" {
|
||||||
|
return status.RemoteURL
|
||||||
|
}
|
||||||
|
return "no remote configured"
|
||||||
|
}
|
||||||
|
163
cmd/utils.go
163
cmd/utils.go
@@ -1,12 +1,175 @@
|
|||||||
package cmd
|
package cmd
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
stderrors "errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
|
|
||||||
|
"github.com/yarlson/lnk/internal/errors"
|
||||||
)
|
)
|
||||||
|
|
||||||
// printf is a helper function to simplify output formatting in commands
|
// printf is a helper function to simplify output formatting in commands
|
||||||
func printf(cmd *cobra.Command, format string, args ...interface{}) {
|
func printf(cmd *cobra.Command, format string, args ...interface{}) {
|
||||||
_, _ = fmt.Fprintf(cmd.OutOrStdout(), format, args...)
|
_, _ = fmt.Fprintf(cmd.OutOrStdout(), format, args...)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// formatError provides user-friendly error formatting while preserving specific error messages for tests
|
||||||
|
func formatError(err error) error {
|
||||||
|
if err == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle typed LnkError first
|
||||||
|
var lnkErr *errors.LnkError
|
||||||
|
if stderrors.As(err, &lnkErr) {
|
||||||
|
return formatLnkError(lnkErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle other error patterns with improved messages
|
||||||
|
errMsg := err.Error()
|
||||||
|
|
||||||
|
// Git-related errors
|
||||||
|
if strings.Contains(errMsg, "git") {
|
||||||
|
if strings.Contains(errMsg, "no remote configured") {
|
||||||
|
return fmt.Errorf("🚫 no remote configured\n 💡 Add a remote first: \033[1mgit remote add origin <url>\033[0m in \033[36m~/.config/lnk\033[0m")
|
||||||
|
}
|
||||||
|
if strings.Contains(errMsg, "authentication") || strings.Contains(errMsg, "permission denied") {
|
||||||
|
return fmt.Errorf("🔐 \033[31mGit authentication failed\033[0m\n 💡 Check your SSH keys or credentials: \033[36mhttps://docs.github.com/en/authentication\033[0m")
|
||||||
|
}
|
||||||
|
if strings.Contains(errMsg, "not found") && strings.Contains(errMsg, "remote") {
|
||||||
|
return fmt.Errorf("🌐 \033[31mRemote repository not found\033[0m\n 💡 Verify the repository URL is correct and you have access")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Service initialization errors
|
||||||
|
if strings.Contains(errMsg, "failed to initialize lnk service") {
|
||||||
|
return fmt.Errorf("⚠️ \033[31mFailed to initialize lnk\033[0m\n 💡 This is likely a system configuration issue. Please check permissions and try again.")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return original error for unhandled cases to maintain test compatibility
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// formatLnkError formats typed LnkError instances with user-friendly messages
|
||||||
|
func formatLnkError(lnkErr *errors.LnkError) error {
|
||||||
|
switch lnkErr.Code {
|
||||||
|
case errors.ErrorCodeFileNotFound:
|
||||||
|
// Preserve "File does not exist" for test compatibility but add consistent colors
|
||||||
|
if path, ok := lnkErr.Context["path"].(string); ok {
|
||||||
|
return fmt.Errorf("❌ \033[31mFile does not exist:\033[0m \033[36m%s\033[0m\n 💡 Check the file path and try again", path)
|
||||||
|
}
|
||||||
|
return fmt.Errorf("❌ \033[31mFile does not exist\033[0m\n 💡 Check the file path and try again")
|
||||||
|
|
||||||
|
case errors.ErrorCodeRepoNotInitialized:
|
||||||
|
// Preserve "Lnk repository not initialized" for test compatibility but add consistent colors
|
||||||
|
return fmt.Errorf("📦 \033[31mLnk repository not initialized\033[0m\n 💡 Run \033[1mlnk init\033[0m to get started")
|
||||||
|
|
||||||
|
case errors.ErrorCodeNotSymlink:
|
||||||
|
// Preserve "not a symlink" for test compatibility but add consistent colors
|
||||||
|
return fmt.Errorf("🔗 \033[31mnot a symlink\033[0m\n 💡 Only files managed by lnk can be removed. Use \033[1mlnk list\033[0m to see managed files")
|
||||||
|
|
||||||
|
case errors.ErrorCodeFileAlreadyManaged:
|
||||||
|
if path, ok := lnkErr.Context["path"].(string); ok {
|
||||||
|
return fmt.Errorf("✨ \033[33mFile is already managed by lnk:\033[0m \033[36m%s\033[0m\n 💡 Use \033[1mlnk list\033[0m to see all managed files", path)
|
||||||
|
}
|
||||||
|
return fmt.Errorf("✨ \033[33mFile is already managed by lnk\033[0m\n 💡 Use \033[1mlnk list\033[0m to see all managed files")
|
||||||
|
|
||||||
|
case errors.ErrorCodeNoRemoteConfigured:
|
||||||
|
// Preserve "no remote configured" for test compatibility but add consistent colors
|
||||||
|
return fmt.Errorf("🚫 \033[31mno remote configured\033[0m\n 💡 Add a remote first: \033[1mgit remote add origin <url>\033[0m in \033[36m~/.config/lnk\033[0m")
|
||||||
|
|
||||||
|
case errors.ErrorCodePermissionDenied:
|
||||||
|
if path, ok := lnkErr.Context["path"].(string); ok {
|
||||||
|
return fmt.Errorf("🔒 \033[31mPermission denied:\033[0m \033[36m%s\033[0m\n 💡 Check file permissions or run with appropriate privileges", path)
|
||||||
|
}
|
||||||
|
return fmt.Errorf("🔒 \033[31mPermission denied\033[0m\n 💡 Check file permissions or run with appropriate privileges")
|
||||||
|
|
||||||
|
case errors.ErrorCodeGitOperation:
|
||||||
|
// Check if this is a "no remote configured" case by examining the underlying error first
|
||||||
|
if lnkErr.Cause != nil && strings.Contains(lnkErr.Cause.Error(), "no remote configured") {
|
||||||
|
return fmt.Errorf("🚫 \033[31mno remote configured\033[0m\n 💡 Add a remote first: \033[1mgit remote add origin <url>\033[0m in \033[36m~/.config/lnk\033[0m")
|
||||||
|
}
|
||||||
|
|
||||||
|
operation := lnkErr.Context["operation"]
|
||||||
|
if op, ok := operation.(string); ok {
|
||||||
|
switch op {
|
||||||
|
case "get_status", "status":
|
||||||
|
return fmt.Errorf("🔧 \033[31mGit operation failed\033[0m\n 💡 Run \033[1mgit status\033[0m in \033[36m~/.config/lnk\033[0m for details")
|
||||||
|
case "push_to_remote", "push":
|
||||||
|
return fmt.Errorf("🚀 \033[31mGit operation failed\033[0m\n 💡 Check your internet connection and Git credentials\n 💡 Run \033[1mgit status\033[0m in \033[36m~/.config/lnk\033[0m for details")
|
||||||
|
case "pull_from_remote", "pull":
|
||||||
|
return fmt.Errorf("⬇️ \033[31mGit operation failed\033[0m\n 💡 Check your internet connection and resolve any conflicts\n 💡 Run \033[1mgit status\033[0m in \033[36m~/.config/lnk\033[0m for details")
|
||||||
|
case "clone_repository", "clone":
|
||||||
|
return fmt.Errorf("📥 \033[31mGit operation failed\033[0m\n 💡 Check the repository URL and your access permissions\n 💡 Ensure you have the correct SSH keys or credentials")
|
||||||
|
case "commit_changes", "commit":
|
||||||
|
return fmt.Errorf("💾 \033[31mGit operation failed\033[0m\n 💡 Check if you have Git user.name and user.email configured\n 💡 Run \033[1mgit config --global user.name \"Your Name\"\033[0m")
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("🔧 \033[31mGit operation failed\033[0m\n 💡 Run \033[1mgit status\033[0m in \033[36m~/.config/lnk\033[0m for details")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return fmt.Errorf("🔧 \033[31mGit operation failed\033[0m\n 💡 Run \033[1mgit status\033[0m in \033[36m~/.config/lnk\033[0m for details")
|
||||||
|
|
||||||
|
case errors.ErrorCodeFileSystemOperation:
|
||||||
|
operation := lnkErr.Context["operation"]
|
||||||
|
path := lnkErr.Context["path"]
|
||||||
|
|
||||||
|
// Determine user-friendly message based on operation and underlying cause
|
||||||
|
if op, ok := operation.(string); ok {
|
||||||
|
switch op {
|
||||||
|
case "stat_symlink", "check_file_exists":
|
||||||
|
// Use consistent "File does not exist" messaging
|
||||||
|
if pathStr, pathOk := path.(string); pathOk {
|
||||||
|
return fmt.Errorf("❌ \033[31mFile does not exist:\033[0m \033[36m%s\033[0m\n 💡 Check the file path and try again", pathStr)
|
||||||
|
}
|
||||||
|
return fmt.Errorf("❌ \033[31mFile does not exist\033[0m\n 💡 Check the file path and try again")
|
||||||
|
case "move_file":
|
||||||
|
return fmt.Errorf("📁 \033[31mFile operation failed\033[0m\n 💡 Check file permissions and available disk space")
|
||||||
|
case "create_symlink":
|
||||||
|
return fmt.Errorf("🔗 \033[31mFile operation failed\033[0m\n 💡 Check directory permissions and ensure target file exists")
|
||||||
|
case "remove_symlink", "remove_file":
|
||||||
|
return fmt.Errorf("🗑️ \033[31mFile operation failed\033[0m\n 💡 Check file permissions and ensure file exists")
|
||||||
|
case "read_symlink":
|
||||||
|
return fmt.Errorf("🔗 \033[31mFile operation failed\033[0m\n 💡 The symlink may be broken or you don't have permission to read it")
|
||||||
|
case "resolve_path", "get_relative_path":
|
||||||
|
if pathStr, pathOk := path.(string); pathOk {
|
||||||
|
return fmt.Errorf("📂 \033[31mInvalid file path:\033[0m \033[36m%s\033[0m\n 💡 Check the file path and try again", pathStr)
|
||||||
|
}
|
||||||
|
return fmt.Errorf("📂 \033[31mInvalid file path\033[0m\n 💡 Check the file path and try again")
|
||||||
|
case "create_dest_dir", "create_repo_dir":
|
||||||
|
return fmt.Errorf("📁 \033[31mFile operation failed\033[0m\n 💡 Check permissions and available disk space")
|
||||||
|
default:
|
||||||
|
// Don't expose cryptic operation names - give generic but helpful message
|
||||||
|
return fmt.Errorf("💽 \033[31mFile operation failed\033[0m\n 💡 Check file permissions, paths, and available disk space")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return fmt.Errorf("💽 \033[31mFile operation failed\033[0m\n 💡 Check file permissions and available disk space")
|
||||||
|
|
||||||
|
case errors.ErrorCodeInvalidPath:
|
||||||
|
if path, ok := lnkErr.Context["path"].(string); ok {
|
||||||
|
return fmt.Errorf("📂 \033[31mInvalid file path:\033[0m \033[36m%s\033[0m\n 💡 Check the file path and try again", path)
|
||||||
|
}
|
||||||
|
return fmt.Errorf("📂 \033[31mInvalid file path\033[0m\n 💡 Check the file path and try again")
|
||||||
|
|
||||||
|
default:
|
||||||
|
// For unknown LnkError types, preserve original message but add context
|
||||||
|
return fmt.Errorf("⚠️ \033[31m%s\033[0m", lnkErr.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// wrapServiceError wraps service errors with consistent messaging while preserving specific errors for tests
|
||||||
|
func wrapServiceError(operation string, err error) error {
|
||||||
|
if err == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// For typed errors, format them nicely
|
||||||
|
var lnkErr *errors.LnkError
|
||||||
|
if stderrors.As(err, &lnkErr) {
|
||||||
|
return formatLnkError(lnkErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
// For other errors, provide operation context but preserve original message for tests
|
||||||
|
return fmt.Errorf("failed to %s: %w", operation, err)
|
||||||
|
}
|
||||||
|
232
internal/config/config.go
Normal file
232
internal/config/config.go
Normal file
@@ -0,0 +1,232 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"os"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/yarlson/lnk/internal/errors"
|
||||||
|
"github.com/yarlson/lnk/internal/fs"
|
||||||
|
"github.com/yarlson/lnk/internal/models"
|
||||||
|
"github.com/yarlson/lnk/internal/pathresolver"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Config implements the service.ConfigManager interface
|
||||||
|
type Config struct {
|
||||||
|
fileManager *fs.FileManager
|
||||||
|
pathResolver *pathresolver.Resolver
|
||||||
|
}
|
||||||
|
|
||||||
|
// New creates a new ConfigManager instance
|
||||||
|
func New(fileManager *fs.FileManager, pathResolver *pathresolver.Resolver) *Config {
|
||||||
|
return &Config{
|
||||||
|
fileManager: fileManager,
|
||||||
|
pathResolver: pathResolver,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// LoadHostConfig loads the configuration for a specific host
|
||||||
|
func (cm *Config) LoadHostConfig(ctx context.Context, repoPath, host string) (*models.HostConfig, error) {
|
||||||
|
managedFiles, err := cm.ListManagedFiles(ctx, repoPath, host)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return &models.HostConfig{
|
||||||
|
Name: host,
|
||||||
|
ManagedFiles: managedFiles,
|
||||||
|
LastUpdate: time.Now(),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SaveHostConfig saves the configuration for a specific host
|
||||||
|
func (cm *Config) SaveHostConfig(ctx context.Context, repoPath string, config *models.HostConfig) error {
|
||||||
|
// Convert managed files to relative paths for storage
|
||||||
|
var relativePaths []string
|
||||||
|
for _, file := range config.ManagedFiles {
|
||||||
|
relativePaths = append(relativePaths, file.RelativePath)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort for consistent ordering
|
||||||
|
sort.Strings(relativePaths)
|
||||||
|
|
||||||
|
return cm.writeManagedItems(ctx, repoPath, config.Name, relativePaths)
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddManagedFileToHost adds a managed file to a host's configuration
|
||||||
|
func (cm *Config) AddManagedFileToHost(ctx context.Context, repoPath, host string, file models.ManagedFile) error {
|
||||||
|
// Get current managed files
|
||||||
|
managedFiles, err := cm.getManagedItems(ctx, repoPath, host)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if already exists
|
||||||
|
for _, item := range managedFiles {
|
||||||
|
if item == file.RelativePath {
|
||||||
|
return nil // Already managed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add new item
|
||||||
|
managedFiles = append(managedFiles, file.RelativePath)
|
||||||
|
|
||||||
|
// Sort for consistent ordering
|
||||||
|
sort.Strings(managedFiles)
|
||||||
|
|
||||||
|
return cm.writeManagedItems(ctx, repoPath, host, managedFiles)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RemoveManagedFileFromHost removes a managed file from a host's configuration
|
||||||
|
func (cm *Config) RemoveManagedFileFromHost(ctx context.Context, repoPath, host, relativePath string) error {
|
||||||
|
// Get current managed files
|
||||||
|
managedFiles, err := cm.getManagedItems(ctx, repoPath, host)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove item
|
||||||
|
var newManagedFiles []string
|
||||||
|
for _, item := range managedFiles {
|
||||||
|
if item != relativePath {
|
||||||
|
newManagedFiles = append(newManagedFiles, item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return cm.writeManagedItems(ctx, repoPath, host, newManagedFiles)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListManagedFiles returns all files managed by a specific host
|
||||||
|
func (cm *Config) ListManagedFiles(ctx context.Context, repoPath, host string) ([]models.ManagedFile, error) {
|
||||||
|
relativePaths, err := cm.getManagedItems(ctx, repoPath, host)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var managedFiles []models.ManagedFile
|
||||||
|
for _, relativePath := range relativePaths {
|
||||||
|
// Get file storage path
|
||||||
|
fileStoragePath, err := cm.pathResolver.GetFileStoragePathInRepo(repoPath, host, relativePath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.NewConfigNotFoundError(host).
|
||||||
|
WithContext("relative_path", relativePath)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get original path (where symlink should be)
|
||||||
|
originalPath, err := cm.pathResolver.GetAbsolutePathInHome(relativePath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.NewInvalidPathError(relativePath, "cannot convert to absolute path")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if file exists and get info
|
||||||
|
var isDirectory bool
|
||||||
|
var mode os.FileMode
|
||||||
|
if exists, err := cm.fileManager.Exists(ctx, fileStoragePath); err == nil && exists {
|
||||||
|
if info, err := cm.fileManager.Stat(ctx, fileStoragePath); err == nil {
|
||||||
|
isDirectory = info.IsDir()
|
||||||
|
mode = info.Mode()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
managedFile := models.ManagedFile{
|
||||||
|
OriginalPath: originalPath,
|
||||||
|
RepoPath: fileStoragePath,
|
||||||
|
RelativePath: relativePath,
|
||||||
|
Host: host,
|
||||||
|
IsDirectory: isDirectory,
|
||||||
|
Mode: mode,
|
||||||
|
}
|
||||||
|
|
||||||
|
managedFiles = append(managedFiles, managedFile)
|
||||||
|
}
|
||||||
|
|
||||||
|
return managedFiles, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetManagedFile retrieves a specific managed file by relative path
|
||||||
|
func (cm *Config) GetManagedFile(ctx context.Context, repoPath, host, relativePath string) (*models.ManagedFile, error) {
|
||||||
|
managedFiles, err := cm.ListManagedFiles(ctx, repoPath, host)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, file := range managedFiles {
|
||||||
|
if file.RelativePath == relativePath {
|
||||||
|
return &file, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil, errors.NewFileNotFoundError(relativePath)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ConfigExists checks if a configuration file exists for the host
|
||||||
|
func (cm *Config) ConfigExists(ctx context.Context, repoPath, host string) (bool, error) {
|
||||||
|
trackingFilePath, err := cm.pathResolver.GetTrackingFilePath(repoPath, host)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return cm.fileManager.Exists(ctx, trackingFilePath)
|
||||||
|
}
|
||||||
|
|
||||||
|
// getManagedItems returns the list of managed files and directories from .lnk file
|
||||||
|
// This is the core method that reads the plain text format
|
||||||
|
func (cm *Config) getManagedItems(ctx context.Context, repoPath, host string) ([]string, error) {
|
||||||
|
trackingFilePath, err := cm.pathResolver.GetTrackingFilePath(repoPath, host)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.NewConfigNotFoundError(host).
|
||||||
|
WithContext("repo_path", repoPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
// If .lnk file doesn't exist, return empty list
|
||||||
|
exists, err := cm.fileManager.Exists(ctx, trackingFilePath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.NewFileSystemOperationError("check_exists", trackingFilePath, err)
|
||||||
|
}
|
||||||
|
if !exists {
|
||||||
|
return []string{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
content, err := cm.fileManager.ReadFile(ctx, trackingFilePath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.NewFileSystemOperationError("read", trackingFilePath, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(content) == 0 {
|
||||||
|
return []string{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
lines := strings.Split(strings.TrimSpace(string(content)), "\n")
|
||||||
|
var items []string
|
||||||
|
for _, line := range lines {
|
||||||
|
line = strings.TrimSpace(line)
|
||||||
|
if line != "" {
|
||||||
|
items = append(items, line)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return items, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeManagedItems writes the list of managed items to .lnk file
|
||||||
|
// This maintains the plain text line-by-line format for compatibility
|
||||||
|
func (cm *Config) writeManagedItems(ctx context.Context, repoPath, host string, items []string) error {
|
||||||
|
trackingFilePath, err := cm.pathResolver.GetTrackingFilePath(repoPath, host)
|
||||||
|
if err != nil {
|
||||||
|
return errors.NewConfigNotFoundError(host).
|
||||||
|
WithContext("repo_path", repoPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
content := strings.Join(items, "\n")
|
||||||
|
if len(items) > 0 {
|
||||||
|
content += "\n"
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := cm.fileManager.WriteFile(ctx, trackingFilePath, []byte(content), 0644); err != nil {
|
||||||
|
return errors.NewFileSystemOperationError("write", trackingFilePath, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
278
internal/config/config_test.go
Normal file
278
internal/config/config_test.go
Normal file
@@ -0,0 +1,278 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/suite"
|
||||||
|
|
||||||
|
"github.com/yarlson/lnk/internal/errors"
|
||||||
|
"github.com/yarlson/lnk/internal/fs"
|
||||||
|
"github.com/yarlson/lnk/internal/models"
|
||||||
|
"github.com/yarlson/lnk/internal/pathresolver"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ConfigTestSuite struct {
|
||||||
|
suite.Suite
|
||||||
|
tempDir string
|
||||||
|
configManager *Config
|
||||||
|
fileManager *fs.FileManager
|
||||||
|
pathResolver *pathresolver.Resolver
|
||||||
|
ctx context.Context
|
||||||
|
}
|
||||||
|
|
||||||
|
func (suite *ConfigTestSuite) SetupTest() {
|
||||||
|
// Create temp directory for testing
|
||||||
|
tempDir, err := os.MkdirTemp("", "lnk_config_test_*")
|
||||||
|
suite.Require().NoError(err)
|
||||||
|
suite.tempDir = tempDir
|
||||||
|
|
||||||
|
// Create file manager and path resolver
|
||||||
|
suite.fileManager = fs.New()
|
||||||
|
suite.pathResolver = pathresolver.New()
|
||||||
|
|
||||||
|
// Create config manager
|
||||||
|
suite.configManager = New(suite.fileManager, suite.pathResolver)
|
||||||
|
|
||||||
|
// Create context
|
||||||
|
suite.ctx = context.Background()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (suite *ConfigTestSuite) TearDownTest() {
|
||||||
|
err := os.RemoveAll(suite.tempDir)
|
||||||
|
suite.Require().NoError(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (suite *ConfigTestSuite) TestAddAndListManagedFiles() {
|
||||||
|
repoPath := filepath.Join(suite.tempDir, "repo")
|
||||||
|
host := "testhost"
|
||||||
|
|
||||||
|
// Create a managed file
|
||||||
|
managedFile := models.ManagedFile{
|
||||||
|
RelativePath: ".vimrc",
|
||||||
|
Host: host,
|
||||||
|
IsDirectory: false,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add managed file
|
||||||
|
err := suite.configManager.AddManagedFileToHost(suite.ctx, repoPath, host, managedFile)
|
||||||
|
suite.NoError(err)
|
||||||
|
|
||||||
|
// List managed files
|
||||||
|
files, err := suite.configManager.ListManagedFiles(suite.ctx, repoPath, host)
|
||||||
|
suite.NoError(err)
|
||||||
|
|
||||||
|
suite.Len(files, 1)
|
||||||
|
suite.Equal(".vimrc", files[0].RelativePath)
|
||||||
|
suite.Equal(host, files[0].Host)
|
||||||
|
|
||||||
|
// Verify tracking file was created
|
||||||
|
trackingPath, err := suite.pathResolver.GetTrackingFilePath(repoPath, host)
|
||||||
|
suite.NoError(err)
|
||||||
|
|
||||||
|
exists, err := suite.fileManager.Exists(suite.ctx, trackingPath)
|
||||||
|
suite.NoError(err)
|
||||||
|
suite.True(exists)
|
||||||
|
|
||||||
|
// Read tracking file content
|
||||||
|
content, err := suite.fileManager.ReadFile(suite.ctx, trackingPath)
|
||||||
|
suite.NoError(err)
|
||||||
|
|
||||||
|
expectedContent := ".vimrc\n"
|
||||||
|
suite.Equal(expectedContent, string(content))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (suite *ConfigTestSuite) TestAddDuplicateFile() {
|
||||||
|
repoPath := filepath.Join(suite.tempDir, "repo")
|
||||||
|
host := "testhost"
|
||||||
|
|
||||||
|
managedFile := models.ManagedFile{
|
||||||
|
RelativePath: ".bashrc",
|
||||||
|
Host: host,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add file twice
|
||||||
|
err := suite.configManager.AddManagedFileToHost(suite.ctx, repoPath, host, managedFile)
|
||||||
|
suite.NoError(err)
|
||||||
|
|
||||||
|
err = suite.configManager.AddManagedFileToHost(suite.ctx, repoPath, host, managedFile)
|
||||||
|
suite.NoError(err)
|
||||||
|
|
||||||
|
// Should still have only one file
|
||||||
|
files, err := suite.configManager.ListManagedFiles(suite.ctx, repoPath, host)
|
||||||
|
suite.NoError(err)
|
||||||
|
suite.Len(files, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (suite *ConfigTestSuite) TestRemoveManagedFile() {
|
||||||
|
repoPath := filepath.Join(suite.tempDir, "repo")
|
||||||
|
host := "testhost"
|
||||||
|
|
||||||
|
// Add two managed files
|
||||||
|
file1 := models.ManagedFile{RelativePath: ".vimrc", Host: host}
|
||||||
|
file2 := models.ManagedFile{RelativePath: ".bashrc", Host: host}
|
||||||
|
|
||||||
|
err := suite.configManager.AddManagedFileToHost(suite.ctx, repoPath, host, file1)
|
||||||
|
suite.NoError(err)
|
||||||
|
|
||||||
|
err = suite.configManager.AddManagedFileToHost(suite.ctx, repoPath, host, file2)
|
||||||
|
suite.NoError(err)
|
||||||
|
|
||||||
|
// Remove one file
|
||||||
|
err = suite.configManager.RemoveManagedFileFromHost(suite.ctx, repoPath, host, ".vimrc")
|
||||||
|
suite.NoError(err)
|
||||||
|
|
||||||
|
// Should have only one file left
|
||||||
|
files, err := suite.configManager.ListManagedFiles(suite.ctx, repoPath, host)
|
||||||
|
suite.NoError(err)
|
||||||
|
|
||||||
|
suite.Len(files, 1)
|
||||||
|
suite.Equal(".bashrc", files[0].RelativePath)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (suite *ConfigTestSuite) TestLoadAndSaveHostConfig() {
|
||||||
|
repoPath := filepath.Join(suite.tempDir, "repo")
|
||||||
|
host := "workstation"
|
||||||
|
|
||||||
|
// Create host config with managed files
|
||||||
|
config := &models.HostConfig{
|
||||||
|
Name: host,
|
||||||
|
ManagedFiles: []models.ManagedFile{
|
||||||
|
{RelativePath: ".vimrc", Host: host},
|
||||||
|
{RelativePath: ".bashrc", Host: host},
|
||||||
|
},
|
||||||
|
LastUpdate: time.Now(),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save config
|
||||||
|
err := suite.configManager.SaveHostConfig(suite.ctx, repoPath, config)
|
||||||
|
suite.NoError(err)
|
||||||
|
|
||||||
|
// Load config
|
||||||
|
loadedConfig, err := suite.configManager.LoadHostConfig(suite.ctx, repoPath, host)
|
||||||
|
suite.NoError(err)
|
||||||
|
|
||||||
|
suite.Equal(host, loadedConfig.Name)
|
||||||
|
suite.Len(loadedConfig.ManagedFiles, 2)
|
||||||
|
|
||||||
|
// Check files are sorted
|
||||||
|
suite.Equal(".bashrc", loadedConfig.ManagedFiles[0].RelativePath)
|
||||||
|
suite.Equal(".vimrc", loadedConfig.ManagedFiles[1].RelativePath)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (suite *ConfigTestSuite) TestGetManagedFile() {
|
||||||
|
repoPath := filepath.Join(suite.tempDir, "repo")
|
||||||
|
host := "testhost"
|
||||||
|
|
||||||
|
managedFile := models.ManagedFile{
|
||||||
|
RelativePath: ".gitconfig",
|
||||||
|
Host: host,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add managed file
|
||||||
|
err := suite.configManager.AddManagedFileToHost(suite.ctx, repoPath, host, managedFile)
|
||||||
|
suite.NoError(err)
|
||||||
|
|
||||||
|
// Get specific managed file
|
||||||
|
file, err := suite.configManager.GetManagedFile(suite.ctx, repoPath, host, ".gitconfig")
|
||||||
|
suite.NoError(err)
|
||||||
|
suite.Equal(".gitconfig", file.RelativePath)
|
||||||
|
|
||||||
|
// Try to get non-existent file
|
||||||
|
_, err = suite.configManager.GetManagedFile(suite.ctx, repoPath, host, ".nonexistent")
|
||||||
|
suite.Error(err)
|
||||||
|
suite.True(errors.NewFileNotFoundError("").Is(err))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (suite *ConfigTestSuite) TestConfigExists() {
|
||||||
|
repoPath := filepath.Join(suite.tempDir, "repo")
|
||||||
|
host := "testhost"
|
||||||
|
|
||||||
|
// Initially should not exist
|
||||||
|
exists, err := suite.configManager.ConfigExists(suite.ctx, repoPath, host)
|
||||||
|
suite.NoError(err)
|
||||||
|
suite.False(exists)
|
||||||
|
|
||||||
|
// Add a managed file
|
||||||
|
managedFile := models.ManagedFile{RelativePath: ".vimrc", Host: host}
|
||||||
|
err = suite.configManager.AddManagedFileToHost(suite.ctx, repoPath, host, managedFile)
|
||||||
|
suite.NoError(err)
|
||||||
|
|
||||||
|
// Now should exist
|
||||||
|
exists, err = suite.configManager.ConfigExists(suite.ctx, repoPath, host)
|
||||||
|
suite.NoError(err)
|
||||||
|
suite.True(exists)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (suite *ConfigTestSuite) TestEmptyConfig() {
|
||||||
|
repoPath := filepath.Join(suite.tempDir, "repo")
|
||||||
|
host := "emptyhost"
|
||||||
|
|
||||||
|
// List files from non-existent config
|
||||||
|
files, err := suite.configManager.ListManagedFiles(suite.ctx, repoPath, host)
|
||||||
|
suite.NoError(err)
|
||||||
|
suite.Len(files, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (suite *ConfigTestSuite) TestCommonAndHostConfigs() {
|
||||||
|
repoPath := filepath.Join(suite.tempDir, "repo")
|
||||||
|
|
||||||
|
// Add file to common config (empty host)
|
||||||
|
commonFile := models.ManagedFile{RelativePath: ".bashrc", Host: ""}
|
||||||
|
err := suite.configManager.AddManagedFileToHost(suite.ctx, repoPath, "", commonFile)
|
||||||
|
suite.NoError(err)
|
||||||
|
|
||||||
|
// Add file to host-specific config
|
||||||
|
hostFile := models.ManagedFile{RelativePath: ".vimrc", Host: "workstation"}
|
||||||
|
err = suite.configManager.AddManagedFileToHost(suite.ctx, repoPath, "workstation", hostFile)
|
||||||
|
suite.NoError(err)
|
||||||
|
|
||||||
|
// List common files
|
||||||
|
commonFiles, err := suite.configManager.ListManagedFiles(suite.ctx, repoPath, "")
|
||||||
|
suite.NoError(err)
|
||||||
|
suite.Len(commonFiles, 1)
|
||||||
|
suite.Equal(".bashrc", commonFiles[0].RelativePath)
|
||||||
|
|
||||||
|
// List host files
|
||||||
|
hostFiles, err := suite.configManager.ListManagedFiles(suite.ctx, repoPath, "workstation")
|
||||||
|
suite.NoError(err)
|
||||||
|
suite.Len(hostFiles, 1)
|
||||||
|
suite.Equal(".vimrc", hostFiles[0].RelativePath)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (suite *ConfigTestSuite) TestFileWithMetadata() {
|
||||||
|
repoPath := filepath.Join(suite.tempDir, "repo")
|
||||||
|
host := "testhost"
|
||||||
|
|
||||||
|
// Create actual file in repository storage area
|
||||||
|
hostStoragePath := filepath.Join(repoPath, host+".lnk")
|
||||||
|
testFilePath := filepath.Join(hostStoragePath, ".vimrc")
|
||||||
|
|
||||||
|
err := suite.fileManager.WriteFile(suite.ctx, testFilePath, []byte("set number"), 0644)
|
||||||
|
suite.NoError(err)
|
||||||
|
|
||||||
|
// Add managed file
|
||||||
|
managedFile := models.ManagedFile{RelativePath: ".vimrc", Host: host}
|
||||||
|
err = suite.configManager.AddManagedFileToHost(suite.ctx, repoPath, host, managedFile)
|
||||||
|
suite.NoError(err)
|
||||||
|
|
||||||
|
// List files should include metadata
|
||||||
|
files, err := suite.configManager.ListManagedFiles(suite.ctx, repoPath, host)
|
||||||
|
suite.NoError(err)
|
||||||
|
suite.Len(files, 1)
|
||||||
|
|
||||||
|
file := files[0]
|
||||||
|
suite.False(file.IsDirectory)
|
||||||
|
suite.NotZero(file.Mode)
|
||||||
|
|
||||||
|
// Expected paths
|
||||||
|
expectedRepoPath := testFilePath
|
||||||
|
suite.Equal(expectedRepoPath, file.RepoPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConfigSuite(t *testing.T) {
|
||||||
|
suite.Run(t, new(ConfigTestSuite))
|
||||||
|
}
|
@@ -1,613 +0,0 @@
|
|||||||
package core
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"sort"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/yarlson/lnk/internal/fs"
|
|
||||||
"github.com/yarlson/lnk/internal/git"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Lnk represents the main application logic
|
|
||||||
type Lnk struct {
|
|
||||||
repoPath string
|
|
||||||
git *git.Git
|
|
||||||
fs *fs.FileSystem
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewLnk creates a new Lnk instance
|
|
||||||
func NewLnk() *Lnk {
|
|
||||||
repoPath := getRepoPath()
|
|
||||||
return &Lnk{
|
|
||||||
repoPath: repoPath,
|
|
||||||
git: git.New(repoPath),
|
|
||||||
fs: fs.New(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// getRepoPath returns the path to the lnk repository directory
|
|
||||||
func getRepoPath() string {
|
|
||||||
xdgConfig := os.Getenv("XDG_CONFIG_HOME")
|
|
||||||
if xdgConfig == "" {
|
|
||||||
homeDir, err := os.UserHomeDir()
|
|
||||||
if err != nil {
|
|
||||||
// Fallback to current directory if we can't get home
|
|
||||||
xdgConfig = "."
|
|
||||||
} else {
|
|
||||||
xdgConfig = filepath.Join(homeDir, ".config")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
// getRelativePath converts an absolute path to a relative path from home directory
|
|
||||||
func getRelativePath(absPath string) (string, error) {
|
|
||||||
homeDir, err := os.UserHomeDir()
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("failed to get home directory: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if the file is under home directory
|
|
||||||
relPath, err := filepath.Rel(homeDir, absPath)
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("failed to get relative path: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// If the relative path starts with "..", the file is outside home directory
|
|
||||||
// In this case, use the absolute path as relative (without the leading slash)
|
|
||||||
if strings.HasPrefix(relPath, "..") {
|
|
||||||
// Use absolute path but remove leading slash and drive letter (for cross-platform)
|
|
||||||
cleanPath := strings.TrimPrefix(absPath, "/")
|
|
||||||
return cleanPath, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
return relPath, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Init initializes the lnk repository
|
|
||||||
func (l *Lnk) Init() error {
|
|
||||||
return l.InitWithRemote("")
|
|
||||||
}
|
|
||||||
|
|
||||||
// InitWithRemote initializes the lnk repository, optionally cloning from a remote
|
|
||||||
func (l *Lnk) InitWithRemote(remoteURL string) error {
|
|
||||||
if remoteURL != "" {
|
|
||||||
// Clone from remote
|
|
||||||
return l.Clone(remoteURL)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create the repository directory
|
|
||||||
if err := os.MkdirAll(l.repoPath, 0755); err != nil {
|
|
||||||
return fmt.Errorf("failed to create lnk directory: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if there's already a Git repository
|
|
||||||
if l.git.IsGitRepository() {
|
|
||||||
// Repository exists, check if it's a lnk repository
|
|
||||||
if l.git.IsLnkRepository() {
|
|
||||||
// It's a lnk repository, init is idempotent - do nothing
|
|
||||||
return nil
|
|
||||||
} else {
|
|
||||||
// It's not a lnk repository, error to prevent data loss
|
|
||||||
return fmt.Errorf("❌ Directory \033[31m%s\033[0m contains an existing Git repository\n 💡 Please backup or move the existing repository before initializing lnk", l.repoPath)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// No existing repository, initialize Git repository
|
|
||||||
if err := l.git.Init(); err != nil {
|
|
||||||
return fmt.Errorf("failed to initialize git repository: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add moves a file or directory to the repository and creates a symlink
|
|
||||||
func (l *Lnk) Add(filePath string) error {
|
|
||||||
// Validate the file or directory
|
|
||||||
if err := l.fs.ValidateFileForAdd(filePath); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get absolute path
|
|
||||||
absPath, err := filepath.Abs(filePath)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to get absolute path: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get relative path for tracking
|
|
||||||
relativePath, err := getRelativePath(absPath)
|
|
||||||
if err != nil {
|
|
||||||
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)
|
|
||||||
|
|
||||||
// Check if this relative path is already managed
|
|
||||||
managedItems, err := l.getManagedItems()
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to get managed items: %w", err)
|
|
||||||
}
|
|
||||||
for _, item := range managedItems {
|
|
||||||
if item == relativePath {
|
|
||||||
return fmt.Errorf("❌ File is already managed by lnk: \033[31m%s\033[0m", relativePath)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if it's a directory or file
|
|
||||||
info, err := os.Stat(absPath)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to stat path: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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
|
|
||||||
}
|
|
||||||
return fmt.Errorf("failed to update tracking file: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add both the item and .lnk file to git in a single commit
|
|
||||||
if err := l.git.Add(repoName); err != nil {
|
|
||||||
// Try to restore the original state if git add fails
|
|
||||||
_ = os.Remove(absPath) // Ignore error in cleanup
|
|
||||||
_ = l.removeManagedItem(relativePath) // Ignore error in cleanup
|
|
||||||
if info.IsDir() {
|
|
||||||
_ = l.fs.MoveDirectory(destPath, absPath) // Ignore error in cleanup
|
|
||||||
} else {
|
|
||||||
_ = l.fs.MoveFile(destPath, absPath) // Ignore error in cleanup
|
|
||||||
}
|
|
||||||
return fmt.Errorf("failed to add item to git: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add .lnk file to the same commit
|
|
||||||
if err := l.git.Add(".lnk"); 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)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Remove removes a symlink and restores the original file or directory
|
|
||||||
func (l *Lnk) Remove(filePath string) error {
|
|
||||||
// Get absolute path
|
|
||||||
absPath, err := filepath.Abs(filePath)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to get absolute path: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate that this is a symlink managed by lnk
|
|
||||||
if err := l.fs.ValidateSymlinkForRemove(absPath, l.repoPath); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get relative path for tracking
|
|
||||||
relativePath, err := getRelativePath(absPath)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to get relative path: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if this relative path is managed
|
|
||||||
managedItems, err := l.getManagedItems()
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to get managed items: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
found := false
|
|
||||||
for _, item := range managedItems {
|
|
||||||
if item == relativePath {
|
|
||||||
found = true
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if !found {
|
|
||||||
return fmt.Errorf("❌ File is not managed by lnk: \033[31m%s\033[0m", relativePath)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get the target path in the repository
|
|
||||||
target, err := os.Readlink(absPath)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to read symlink: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Convert relative path to absolute if needed
|
|
||||||
if !filepath.IsAbs(target) {
|
|
||||||
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 {
|
|
||||||
return fmt.Errorf("failed to stat target: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Remove the symlink
|
|
||||||
if err := os.Remove(absPath); err != nil {
|
|
||||||
return fmt.Errorf("failed to remove symlink: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Remove from .lnk tracking file using relative path
|
|
||||||
if err := l.removeManagedItem(relativePath); err != nil {
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetCommits returns the list of commits for testing purposes
|
|
||||||
func (l *Lnk) GetCommits() ([]string, error) {
|
|
||||||
return l.git.GetCommits()
|
|
||||||
}
|
|
||||||
|
|
||||||
// StatusInfo contains repository sync status information
|
|
||||||
type StatusInfo struct {
|
|
||||||
Ahead int
|
|
||||||
Behind int
|
|
||||||
Remote string
|
|
||||||
Dirty bool
|
|
||||||
}
|
|
||||||
|
|
||||||
// Status returns the repository sync status
|
|
||||||
func (l *Lnk) Status() (*StatusInfo, 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")
|
|
||||||
}
|
|
||||||
|
|
||||||
gitStatus, err := l.git.GetStatus()
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to get repository status: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return &StatusInfo{
|
|
||||||
Ahead: gitStatus.Ahead,
|
|
||||||
Behind: gitStatus.Behind,
|
|
||||||
Remote: gitStatus.Remote,
|
|
||||||
Dirty: gitStatus.Dirty,
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Push stages all changes and creates a sync commit, then pushes to remote
|
|
||||||
func (l *Lnk) Push(message 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")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if there are any changes
|
|
||||||
hasChanges, err := l.git.HasChanges()
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to check for changes: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if hasChanges {
|
|
||||||
// Stage all changes
|
|
||||||
if err := l.git.AddAll(); err != nil {
|
|
||||||
return fmt.Errorf("failed to stage changes: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create a sync commit
|
|
||||||
if err := l.git.Commit(message); err != nil {
|
|
||||||
return fmt.Errorf("failed to commit changes: %w", 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
|
|
||||||
}
|
|
||||||
|
|
||||||
// Pull fetches changes from remote and restores symlinks as needed
|
|
||||||
func (l *Lnk) Pull() ([]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")
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Find all managed files in the repository and restore symlinks
|
|
||||||
restored, err := l.RestoreSymlinks()
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to restore symlinks: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return restored, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// RestoreSymlinks finds all managed items from .lnk file and ensures they have proper symlinks
|
|
||||||
func (l *Lnk) RestoreSymlinks() ([]string, error) {
|
|
||||||
var restored []string
|
|
||||||
|
|
||||||
// Get managed items from .lnk file (now containing relative paths)
|
|
||||||
managedItems, err := l.getManagedItems()
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to get managed items: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
homeDir, err := os.UserHomeDir()
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to get home directory: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, relativePath := range managedItems {
|
|
||||||
// Generate repository name from relative path
|
|
||||||
repoName := generateRepoName(relativePath)
|
|
||||||
repoItem := filepath.Join(l.repoPath, repoName)
|
|
||||||
|
|
||||||
// Check if item exists in repository
|
|
||||||
if _, err := os.Stat(repoItem); os.IsNotExist(err) {
|
|
||||||
continue // Skip missing items
|
|
||||||
}
|
|
||||||
|
|
||||||
// Determine where the symlink should be created
|
|
||||||
symlinkPath := filepath.Join(homeDir, relativePath)
|
|
||||||
|
|
||||||
// Check if symlink already exists and is correct
|
|
||||||
if l.isValidSymlink(symlinkPath, repoItem) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ensure parent directory exists
|
|
||||||
symlinkDir := filepath.Dir(symlinkPath)
|
|
||||||
if err := os.MkdirAll(symlinkDir, 0755); err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to create directory %s: %w", symlinkDir, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Remove existing file/symlink if it exists
|
|
||||||
if _, err := os.Lstat(symlinkPath); err == nil {
|
|
||||||
if err := os.RemoveAll(symlinkPath); err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to remove existing item %s: %w", symlinkPath, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create symlink
|
|
||||||
if err := l.fs.CreateSymlink(repoItem, symlinkPath); err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to create symlink for %s: %w", relativePath, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
restored = append(restored, relativePath)
|
|
||||||
}
|
|
||||||
|
|
||||||
return restored, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// isValidSymlink checks if the given path is a symlink pointing to the expected target
|
|
||||||
func (l *Lnk) isValidSymlink(symlinkPath, expectedTarget string) bool {
|
|
||||||
info, err := os.Lstat(symlinkPath)
|
|
||||||
if err != nil {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if it's a symlink
|
|
||||||
if info.Mode()&os.ModeSymlink == 0 {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if it points to the correct target
|
|
||||||
target, err := os.Readlink(symlinkPath)
|
|
||||||
if err != nil {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// Convert relative path to absolute if needed
|
|
||||||
if !filepath.IsAbs(target) {
|
|
||||||
target = filepath.Join(filepath.Dir(symlinkPath), target)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clean both paths for comparison
|
|
||||||
targetAbs, err := filepath.Abs(target)
|
|
||||||
if err != nil {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
expectedAbs, err := filepath.Abs(expectedTarget)
|
|
||||||
if err != nil {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
return targetAbs == expectedAbs
|
|
||||||
}
|
|
||||||
|
|
||||||
// getManagedItems returns the list of managed files and directories from .lnk file
|
|
||||||
func (l *Lnk) getManagedItems() ([]string, error) {
|
|
||||||
lnkFile := filepath.Join(l.repoPath, ".lnk")
|
|
||||||
|
|
||||||
// If .lnk file doesn't exist, return empty list
|
|
||||||
if _, err := os.Stat(lnkFile); os.IsNotExist(err) {
|
|
||||||
return []string{}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
content, err := os.ReadFile(lnkFile)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to read .lnk file: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(content) == 0 {
|
|
||||||
return []string{}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
lines := strings.Split(strings.TrimSpace(string(content)), "\n")
|
|
||||||
var items []string
|
|
||||||
for _, line := range lines {
|
|
||||||
line = strings.TrimSpace(line)
|
|
||||||
if line != "" {
|
|
||||||
items = append(items, line)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return items, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// addManagedItem adds an item to the .lnk tracking file
|
|
||||||
func (l *Lnk) addManagedItem(relativePath string) error {
|
|
||||||
// Get current items
|
|
||||||
items, err := l.getManagedItems()
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to get managed items: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if already exists
|
|
||||||
for _, item := range items {
|
|
||||||
if item == relativePath {
|
|
||||||
return nil // Already managed
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add new item using relative path
|
|
||||||
items = append(items, relativePath)
|
|
||||||
|
|
||||||
// Sort for consistent ordering
|
|
||||||
sort.Strings(items)
|
|
||||||
|
|
||||||
return l.writeManagedItems(items)
|
|
||||||
}
|
|
||||||
|
|
||||||
// removeManagedItem removes an item from the .lnk tracking file
|
|
||||||
func (l *Lnk) removeManagedItem(relativePath string) error {
|
|
||||||
// Get current items
|
|
||||||
items, err := l.getManagedItems()
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to get managed items: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Remove item using relative path
|
|
||||||
var newItems []string
|
|
||||||
for _, item := range items {
|
|
||||||
if item != relativePath {
|
|
||||||
newItems = append(newItems, item)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return l.writeManagedItems(newItems)
|
|
||||||
}
|
|
||||||
|
|
||||||
// writeManagedItems writes the list of managed items to .lnk file
|
|
||||||
func (l *Lnk) writeManagedItems(items []string) error {
|
|
||||||
lnkFile := filepath.Join(l.repoPath, ".lnk")
|
|
||||||
|
|
||||||
content := strings.Join(items, "\n")
|
|
||||||
if len(items) > 0 {
|
|
||||||
content += "\n"
|
|
||||||
}
|
|
||||||
|
|
||||||
err := os.WriteFile(lnkFile, []byte(content), 0644)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to write .lnk file: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
@@ -1,521 +0,0 @@
|
|||||||
package core
|
|
||||||
|
|
||||||
import (
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/stretchr/testify/suite"
|
|
||||||
)
|
|
||||||
|
|
||||||
type CoreTestSuite struct {
|
|
||||||
suite.Suite
|
|
||||||
tempDir string
|
|
||||||
originalDir string
|
|
||||||
lnk *Lnk
|
|
||||||
}
|
|
||||||
|
|
||||||
func (suite *CoreTestSuite) SetupTest() {
|
|
||||||
// Create temporary directory for each test
|
|
||||||
tempDir, err := os.MkdirTemp("", "lnk-test-*")
|
|
||||||
suite.Require().NoError(err)
|
|
||||||
suite.tempDir = tempDir
|
|
||||||
|
|
||||||
// Change to temp directory
|
|
||||||
originalDir, err := os.Getwd()
|
|
||||||
suite.Require().NoError(err)
|
|
||||||
suite.originalDir = originalDir
|
|
||||||
|
|
||||||
err = os.Chdir(tempDir)
|
|
||||||
suite.Require().NoError(err)
|
|
||||||
|
|
||||||
// Set XDG_CONFIG_HOME to temp directory
|
|
||||||
suite.T().Setenv("XDG_CONFIG_HOME", tempDir)
|
|
||||||
|
|
||||||
// Initialize Lnk instance
|
|
||||||
suite.lnk = NewLnk()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (suite *CoreTestSuite) TearDownTest() {
|
|
||||||
// Return to original directory
|
|
||||||
err := os.Chdir(suite.originalDir)
|
|
||||||
suite.Require().NoError(err)
|
|
||||||
|
|
||||||
// Clean up temp directory
|
|
||||||
err = os.RemoveAll(suite.tempDir)
|
|
||||||
suite.Require().NoError(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Test core initialization functionality
|
|
||||||
func (suite *CoreTestSuite) TestCoreInit() {
|
|
||||||
err := suite.lnk.Init()
|
|
||||||
suite.Require().NoError(err)
|
|
||||||
|
|
||||||
// Check that the lnk directory was created
|
|
||||||
lnkDir := filepath.Join(suite.tempDir, "lnk")
|
|
||||||
suite.DirExists(lnkDir)
|
|
||||||
|
|
||||||
// Check that Git repo was initialized
|
|
||||||
gitDir := filepath.Join(lnkDir, ".git")
|
|
||||||
suite.DirExists(gitDir)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Test core add/remove functionality with files
|
|
||||||
func (suite *CoreTestSuite) TestCoreFileOperations() {
|
|
||||||
// Initialize first
|
|
||||||
err := suite.lnk.Init()
|
|
||||||
suite.Require().NoError(err)
|
|
||||||
|
|
||||||
// Create a test 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)
|
|
||||||
|
|
||||||
// Add the file
|
|
||||||
err = suite.lnk.Add(testFile)
|
|
||||||
suite.Require().NoError(err)
|
|
||||||
|
|
||||||
// Verify symlink and repo file
|
|
||||||
info, err := os.Lstat(testFile)
|
|
||||||
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
|
|
||||||
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")
|
|
||||||
suite.FileExists(repoFile)
|
|
||||||
|
|
||||||
// Verify content is preserved
|
|
||||||
repoContent, err := os.ReadFile(repoFile)
|
|
||||||
suite.Require().NoError(err)
|
|
||||||
suite.Equal(content, string(repoContent))
|
|
||||||
|
|
||||||
// Test remove
|
|
||||||
err = suite.lnk.Remove(testFile)
|
|
||||||
suite.Require().NoError(err)
|
|
||||||
|
|
||||||
// Verify symlink is gone and regular file is restored
|
|
||||||
info, err = os.Lstat(testFile)
|
|
||||||
suite.Require().NoError(err)
|
|
||||||
suite.Equal(os.FileMode(0), info.Mode()&os.ModeSymlink) // Not a symlink
|
|
||||||
|
|
||||||
// Verify content is preserved
|
|
||||||
restoredContent, err := os.ReadFile(testFile)
|
|
||||||
suite.Require().NoError(err)
|
|
||||||
suite.Equal(content, string(restoredContent))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Test core add/remove functionality with directories
|
|
||||||
func (suite *CoreTestSuite) TestCoreDirectoryOperations() {
|
|
||||||
err := suite.lnk.Init()
|
|
||||||
suite.Require().NoError(err)
|
|
||||||
|
|
||||||
// Create a directory with files
|
|
||||||
testDir := filepath.Join(suite.tempDir, "testdir")
|
|
||||||
err = os.MkdirAll(testDir, 0755)
|
|
||||||
suite.Require().NoError(err)
|
|
||||||
|
|
||||||
testFile := filepath.Join(testDir, "config.txt")
|
|
||||||
content := "test config"
|
|
||||||
err = os.WriteFile(testFile, []byte(content), 0644)
|
|
||||||
suite.Require().NoError(err)
|
|
||||||
|
|
||||||
// Add the directory
|
|
||||||
err = suite.lnk.Add(testDir)
|
|
||||||
suite.Require().NoError(err)
|
|
||||||
|
|
||||||
// Verify directory is now a symlink
|
|
||||||
info, err := os.Lstat(testDir)
|
|
||||||
suite.Require().NoError(err)
|
|
||||||
suite.Equal(os.ModeSymlink, info.Mode()&os.ModeSymlink)
|
|
||||||
|
|
||||||
// Check that some repository directory exists with testdir in the name
|
|
||||||
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")
|
|
||||||
suite.DirExists(repoDir)
|
|
||||||
|
|
||||||
// Remove the directory
|
|
||||||
err = suite.lnk.Remove(testDir)
|
|
||||||
suite.Require().NoError(err)
|
|
||||||
|
|
||||||
// Verify symlink is gone and regular directory is restored
|
|
||||||
info, err = os.Lstat(testDir)
|
|
||||||
suite.Require().NoError(err)
|
|
||||||
suite.Equal(os.FileMode(0), info.Mode()&os.ModeSymlink) // Not a symlink
|
|
||||||
suite.True(info.IsDir()) // Is a directory
|
|
||||||
|
|
||||||
// Verify content is preserved
|
|
||||||
restoredContent, err := os.ReadFile(testFile)
|
|
||||||
suite.Require().NoError(err)
|
|
||||||
suite.Equal(content, string(restoredContent))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Test .lnk file tracking functionality
|
|
||||||
func (suite *CoreTestSuite) TestLnkFileTracking() {
|
|
||||||
err := suite.lnk.Init()
|
|
||||||
suite.Require().NoError(err)
|
|
||||||
|
|
||||||
// Add multiple items
|
|
||||||
testFile := filepath.Join(suite.tempDir, ".bashrc")
|
|
||||||
err = os.WriteFile(testFile, []byte("export PATH=/usr/local/bin:$PATH"), 0644)
|
|
||||||
suite.Require().NoError(err)
|
|
||||||
err = suite.lnk.Add(testFile)
|
|
||||||
suite.Require().NoError(err)
|
|
||||||
|
|
||||||
testDir := filepath.Join(suite.tempDir, ".ssh")
|
|
||||||
err = os.MkdirAll(testDir, 0700)
|
|
||||||
suite.Require().NoError(err)
|
|
||||||
configFile := filepath.Join(testDir, "config")
|
|
||||||
err = os.WriteFile(configFile, []byte("Host example.com"), 0600)
|
|
||||||
suite.Require().NoError(err)
|
|
||||||
err = suite.lnk.Add(testDir)
|
|
||||||
suite.Require().NoError(err)
|
|
||||||
|
|
||||||
// Check .lnk file contains both entries
|
|
||||||
lnkFile := filepath.Join(suite.tempDir, "lnk", ".lnk")
|
|
||||||
suite.FileExists(lnkFile)
|
|
||||||
|
|
||||||
lnkContent, err := os.ReadFile(lnkFile)
|
|
||||||
suite.Require().NoError(err)
|
|
||||||
|
|
||||||
lines := strings.Split(strings.TrimSpace(string(lnkContent)), "\n")
|
|
||||||
suite.Len(lines, 2)
|
|
||||||
|
|
||||||
// The .lnk file now contains relative paths, not basenames
|
|
||||||
// Check that the content contains references to .bashrc and .ssh
|
|
||||||
content := string(lnkContent)
|
|
||||||
suite.Contains(content, ".bashrc", ".lnk file should contain reference to .bashrc")
|
|
||||||
suite.Contains(content, ".ssh", ".lnk file should contain reference to .ssh")
|
|
||||||
|
|
||||||
// Remove one item and verify tracking is updated
|
|
||||||
err = suite.lnk.Remove(testFile)
|
|
||||||
suite.Require().NoError(err)
|
|
||||||
|
|
||||||
lnkContent, err = os.ReadFile(lnkFile)
|
|
||||||
suite.Require().NoError(err)
|
|
||||||
|
|
||||||
lines = strings.Split(strings.TrimSpace(string(lnkContent)), "\n")
|
|
||||||
suite.Len(lines, 1)
|
|
||||||
|
|
||||||
content = string(lnkContent)
|
|
||||||
suite.Contains(content, ".ssh", ".lnk file should still contain reference to .ssh")
|
|
||||||
suite.NotContains(content, ".bashrc", ".lnk file should not contain reference to .bashrc after removal")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Test XDG_CONFIG_HOME fallback
|
|
||||||
func (suite *CoreTestSuite) TestXDGConfigHomeFallback() {
|
|
||||||
// Test fallback to ~/.config/lnk when XDG_CONFIG_HOME is not set
|
|
||||||
suite.T().Setenv("XDG_CONFIG_HOME", "")
|
|
||||||
|
|
||||||
homeDir := filepath.Join(suite.tempDir, "home")
|
|
||||||
err := os.MkdirAll(homeDir, 0755)
|
|
||||||
suite.Require().NoError(err)
|
|
||||||
suite.T().Setenv("HOME", homeDir)
|
|
||||||
|
|
||||||
lnk := NewLnk()
|
|
||||||
err = lnk.Init()
|
|
||||||
suite.Require().NoError(err)
|
|
||||||
|
|
||||||
// Check that the lnk directory was created under ~/.config/lnk
|
|
||||||
expectedDir := filepath.Join(homeDir, ".config", "lnk")
|
|
||||||
suite.DirExists(expectedDir)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Test symlink restoration (pull functionality)
|
|
||||||
func (suite *CoreTestSuite) TestSymlinkRestoration() {
|
|
||||||
_ = suite.lnk.Init()
|
|
||||||
|
|
||||||
// Create a file in the repo directly (simulating a pull)
|
|
||||||
repoFile := filepath.Join(suite.tempDir, "lnk", ".bashrc")
|
|
||||||
content := "export PATH=$PATH:/usr/local/bin"
|
|
||||||
err := os.WriteFile(repoFile, []byte(content), 0644)
|
|
||||||
suite.Require().NoError(err)
|
|
||||||
|
|
||||||
// Create .lnk file to track it
|
|
||||||
lnkFile := filepath.Join(suite.tempDir, "lnk", ".lnk")
|
|
||||||
err = os.WriteFile(lnkFile, []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 := suite.lnk.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 error conditions
|
|
||||||
func (suite *CoreTestSuite) TestErrorConditions() {
|
|
||||||
// Test add nonexistent file
|
|
||||||
err := suite.lnk.Init()
|
|
||||||
suite.Require().NoError(err)
|
|
||||||
|
|
||||||
err = suite.lnk.Add("/nonexistent/file")
|
|
||||||
suite.Error(err)
|
|
||||||
suite.Contains(err.Error(), "File does not exist")
|
|
||||||
|
|
||||||
// Test remove unmanaged file
|
|
||||||
testFile := filepath.Join(suite.tempDir, ".regularfile")
|
|
||||||
err = os.WriteFile(testFile, []byte("content"), 0644)
|
|
||||||
suite.Require().NoError(err)
|
|
||||||
|
|
||||||
err = suite.lnk.Remove(testFile)
|
|
||||||
suite.Error(err)
|
|
||||||
suite.Contains(err.Error(), "File is not managed by lnk")
|
|
||||||
|
|
||||||
// Test status without remote
|
|
||||||
_, err = suite.lnk.Status()
|
|
||||||
suite.Error(err)
|
|
||||||
suite.Contains(err.Error(), "no remote configured")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Test git operations
|
|
||||||
func (suite *CoreTestSuite) TestGitOperations() {
|
|
||||||
err := suite.lnk.Init()
|
|
||||||
suite.Require().NoError(err)
|
|
||||||
|
|
||||||
// Add a file to create a commit
|
|
||||||
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)
|
|
||||||
|
|
||||||
// Check that Git commit was made
|
|
||||||
commits, err := suite.lnk.GetCommits()
|
|
||||||
suite.Require().NoError(err)
|
|
||||||
suite.Len(commits, 1)
|
|
||||||
suite.Contains(commits[0], "lnk: added .bashrc")
|
|
||||||
|
|
||||||
// Test add remote
|
|
||||||
err = suite.lnk.AddRemote("origin", "https://github.com/test/dotfiles.git")
|
|
||||||
suite.Require().NoError(err)
|
|
||||||
|
|
||||||
// Test status with remote
|
|
||||||
status, err := suite.lnk.Status()
|
|
||||||
suite.Require().NoError(err)
|
|
||||||
suite.Equal(1, status.Ahead)
|
|
||||||
suite.Equal(0, status.Behind)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Test edge case: files with same basename from different directories should be handled properly
|
|
||||||
func (suite *CoreTestSuite) TestSameBasenameFilesOverwrite() {
|
|
||||||
err := suite.lnk.Init()
|
|
||||||
suite.Require().NoError(err)
|
|
||||||
|
|
||||||
// Create two directories with files having the same basename
|
|
||||||
dirA := filepath.Join(suite.tempDir, "a")
|
|
||||||
dirB := filepath.Join(suite.tempDir, "b")
|
|
||||||
err = os.MkdirAll(dirA, 0755)
|
|
||||||
suite.Require().NoError(err)
|
|
||||||
err = os.MkdirAll(dirB, 0755)
|
|
||||||
suite.Require().NoError(err)
|
|
||||||
|
|
||||||
// Create files with same basename but different content
|
|
||||||
fileA := filepath.Join(dirA, "config.json")
|
|
||||||
fileB := filepath.Join(dirB, "config.json")
|
|
||||||
contentA := `{"name": "config_a"}`
|
|
||||||
contentB := `{"name": "config_b"}`
|
|
||||||
|
|
||||||
err = os.WriteFile(fileA, []byte(contentA), 0644)
|
|
||||||
suite.Require().NoError(err)
|
|
||||||
err = os.WriteFile(fileB, []byte(contentB), 0644)
|
|
||||||
suite.Require().NoError(err)
|
|
||||||
|
|
||||||
// Add first file
|
|
||||||
err = suite.lnk.Add(fileA)
|
|
||||||
suite.Require().NoError(err)
|
|
||||||
|
|
||||||
// Verify first file is managed correctly and preserves content
|
|
||||||
info, err := os.Lstat(fileA)
|
|
||||||
suite.Require().NoError(err)
|
|
||||||
suite.Equal(os.ModeSymlink, info.Mode()&os.ModeSymlink)
|
|
||||||
|
|
||||||
symlinkContentA, err := os.ReadFile(fileA)
|
|
||||||
suite.Require().NoError(err)
|
|
||||||
suite.Equal(contentA, string(symlinkContentA), "First file should preserve its original content")
|
|
||||||
|
|
||||||
// Add second file - this should work without overwriting the first
|
|
||||||
err = suite.lnk.Add(fileB)
|
|
||||||
suite.Require().NoError(err)
|
|
||||||
|
|
||||||
// Verify second file is managed
|
|
||||||
info, err = os.Lstat(fileB)
|
|
||||||
suite.Require().NoError(err)
|
|
||||||
suite.Equal(os.ModeSymlink, info.Mode()&os.ModeSymlink)
|
|
||||||
|
|
||||||
// CORRECT BEHAVIOR: Both files should preserve their original content
|
|
||||||
symlinkContentA, err = os.ReadFile(fileA)
|
|
||||||
suite.Require().NoError(err)
|
|
||||||
symlinkContentB, err := os.ReadFile(fileB)
|
|
||||||
suite.Require().NoError(err)
|
|
||||||
|
|
||||||
suite.Equal(contentA, string(symlinkContentA), "First file should keep its original content")
|
|
||||||
suite.Equal(contentB, string(symlinkContentB), "Second file should keep its original content")
|
|
||||||
|
|
||||||
// Both files should be removable independently
|
|
||||||
err = suite.lnk.Remove(fileA)
|
|
||||||
suite.Require().NoError(err, "First file should be removable")
|
|
||||||
|
|
||||||
// First file should be restored with correct content
|
|
||||||
info, err = os.Lstat(fileA)
|
|
||||||
suite.Require().NoError(err)
|
|
||||||
suite.Equal(os.FileMode(0), info.Mode()&os.ModeSymlink) // Not a symlink anymore
|
|
||||||
|
|
||||||
restoredContentA, err := os.ReadFile(fileA)
|
|
||||||
suite.Require().NoError(err)
|
|
||||||
suite.Equal(contentA, string(restoredContentA), "Restored file should have original content")
|
|
||||||
|
|
||||||
// Second file should still be manageable and removable
|
|
||||||
err = suite.lnk.Remove(fileB)
|
|
||||||
suite.Require().NoError(err, "Second file should also be removable without errors")
|
|
||||||
|
|
||||||
// Second file should be restored with correct content
|
|
||||||
info, err = os.Lstat(fileB)
|
|
||||||
suite.Require().NoError(err)
|
|
||||||
suite.Equal(os.FileMode(0), info.Mode()&os.ModeSymlink) // Not a symlink anymore
|
|
||||||
|
|
||||||
restoredContentB, err := os.ReadFile(fileB)
|
|
||||||
suite.Require().NoError(err)
|
|
||||||
suite.Equal(contentB, string(restoredContentB), "Second restored file should have original content")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Test another variant: adding files with same basename should work correctly
|
|
||||||
func (suite *CoreTestSuite) TestSameBasenameSequentialAdd() {
|
|
||||||
err := suite.lnk.Init()
|
|
||||||
suite.Require().NoError(err)
|
|
||||||
|
|
||||||
// Create subdirectories in different locations
|
|
||||||
configDir := filepath.Join(suite.tempDir, "config")
|
|
||||||
backupDir := filepath.Join(suite.tempDir, "backup")
|
|
||||||
err = os.MkdirAll(configDir, 0755)
|
|
||||||
suite.Require().NoError(err)
|
|
||||||
err = os.MkdirAll(backupDir, 0755)
|
|
||||||
suite.Require().NoError(err)
|
|
||||||
|
|
||||||
// Create files with same basename (.bashrc)
|
|
||||||
configBashrc := filepath.Join(configDir, ".bashrc")
|
|
||||||
backupBashrc := filepath.Join(backupDir, ".bashrc")
|
|
||||||
|
|
||||||
originalContent := "export PATH=/usr/local/bin:$PATH"
|
|
||||||
backupContent := "export PATH=/opt/bin:$PATH"
|
|
||||||
|
|
||||||
err = os.WriteFile(configBashrc, []byte(originalContent), 0644)
|
|
||||||
suite.Require().NoError(err)
|
|
||||||
err = os.WriteFile(backupBashrc, []byte(backupContent), 0644)
|
|
||||||
suite.Require().NoError(err)
|
|
||||||
|
|
||||||
// Add first .bashrc
|
|
||||||
err = suite.lnk.Add(configBashrc)
|
|
||||||
suite.Require().NoError(err)
|
|
||||||
|
|
||||||
// Add second .bashrc - should work without overwriting the first
|
|
||||||
err = suite.lnk.Add(backupBashrc)
|
|
||||||
suite.Require().NoError(err)
|
|
||||||
|
|
||||||
// Check .lnk tracking file should track both properly
|
|
||||||
lnkFile := filepath.Join(suite.tempDir, "lnk", ".lnk")
|
|
||||||
lnkContent, err := os.ReadFile(lnkFile)
|
|
||||||
suite.Require().NoError(err)
|
|
||||||
|
|
||||||
// Both entries should be tracked and distinguishable
|
|
||||||
content := string(lnkContent)
|
|
||||||
suite.Contains(content, ".bashrc", "Both .bashrc files should be tracked")
|
|
||||||
|
|
||||||
// Both files should maintain their distinct content
|
|
||||||
content1, err := os.ReadFile(configBashrc)
|
|
||||||
suite.Require().NoError(err)
|
|
||||||
content2, err := os.ReadFile(backupBashrc)
|
|
||||||
suite.Require().NoError(err)
|
|
||||||
|
|
||||||
suite.Equal(originalContent, string(content1), "First file should keep original content")
|
|
||||||
suite.Equal(backupContent, string(content2), "Second file should keep its distinct content")
|
|
||||||
|
|
||||||
// Both should be removable independently
|
|
||||||
err = suite.lnk.Remove(configBashrc)
|
|
||||||
suite.Require().NoError(err, "First .bashrc should be removable")
|
|
||||||
|
|
||||||
err = suite.lnk.Remove(backupBashrc)
|
|
||||||
suite.Require().NoError(err, "Second .bashrc should be removable")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Test dirty repository status detection
|
|
||||||
func (suite *CoreTestSuite) TestStatusDetectsDirtyRepo() {
|
|
||||||
err := suite.lnk.Init()
|
|
||||||
suite.Require().NoError(err)
|
|
||||||
|
|
||||||
// Add and commit a file
|
|
||||||
testFile := filepath.Join(suite.tempDir, "a")
|
|
||||||
err = os.WriteFile(testFile, []byte("abc"), 0644)
|
|
||||||
suite.Require().NoError(err)
|
|
||||||
|
|
||||||
err = suite.lnk.Add(testFile)
|
|
||||||
suite.Require().NoError(err)
|
|
||||||
|
|
||||||
// Add a remote so status works
|
|
||||||
err = suite.lnk.AddRemote("origin", "https://github.com/test/dotfiles.git")
|
|
||||||
suite.Require().NoError(err)
|
|
||||||
|
|
||||||
// Check status - should be clean but ahead of remote
|
|
||||||
status, err := suite.lnk.Status()
|
|
||||||
suite.Require().NoError(err)
|
|
||||||
suite.Equal(1, status.Ahead)
|
|
||||||
suite.Equal(0, status.Behind)
|
|
||||||
suite.False(status.Dirty, "Repository should not be dirty after commit")
|
|
||||||
|
|
||||||
// Now edit the managed file (simulating the issue scenario)
|
|
||||||
err = os.WriteFile(testFile, []byte("def"), 0644)
|
|
||||||
suite.Require().NoError(err)
|
|
||||||
|
|
||||||
// Check status again - should detect dirty state
|
|
||||||
status, err = suite.lnk.Status()
|
|
||||||
suite.Require().NoError(err)
|
|
||||||
suite.Equal(1, status.Ahead)
|
|
||||||
suite.Equal(0, status.Behind)
|
|
||||||
suite.True(status.Dirty, "Repository should be dirty after editing managed file")
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCoreSuite(t *testing.T) {
|
|
||||||
suite.Run(t, new(CoreTestSuite))
|
|
||||||
}
|
|
241
internal/errors/errors.go
Normal file
241
internal/errors/errors.go
Normal file
@@ -0,0 +1,241 @@
|
|||||||
|
package errors
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Standard error variables
|
||||||
|
var (
|
||||||
|
// ErrFileNotFound indicates a file or directory was not found
|
||||||
|
ErrFileNotFound = errors.New("file not found")
|
||||||
|
|
||||||
|
// ErrFileAlreadyManaged indicates a file is already being managed by lnk
|
||||||
|
ErrFileAlreadyManaged = errors.New("file already managed")
|
||||||
|
|
||||||
|
// ErrNotSymlink indicates the file is not a symbolic link
|
||||||
|
ErrNotSymlink = errors.New("not a symlink")
|
||||||
|
|
||||||
|
// ErrRepoNotInitialized indicates the lnk repository has not been initialized
|
||||||
|
ErrRepoNotInitialized = errors.New("repository not initialized")
|
||||||
|
|
||||||
|
// ErrNoRemoteConfigured indicates no Git remote has been configured
|
||||||
|
ErrNoRemoteConfigured = errors.New("no remote configured")
|
||||||
|
|
||||||
|
// ErrOperationAborted indicates an operation was aborted by the user
|
||||||
|
ErrOperationAborted = errors.New("operation aborted")
|
||||||
|
|
||||||
|
// ErrConfigNotFound indicates a configuration file was not found
|
||||||
|
ErrConfigNotFound = errors.New("configuration not found")
|
||||||
|
|
||||||
|
// ErrInvalidPath indicates an invalid file path was provided
|
||||||
|
ErrInvalidPath = errors.New("invalid path")
|
||||||
|
|
||||||
|
// ErrPermissionDenied indicates insufficient permissions for the operation
|
||||||
|
ErrPermissionDenied = errors.New("permission denied")
|
||||||
|
|
||||||
|
// ErrGitOperation indicates a Git operation failed
|
||||||
|
ErrGitOperation = errors.New("git operation failed")
|
||||||
|
|
||||||
|
// ErrFileSystemOperation indicates a file system operation failed
|
||||||
|
ErrFileSystemOperation = errors.New("file system operation failed")
|
||||||
|
)
|
||||||
|
|
||||||
|
// ErrorCode represents different types of errors that can occur
|
||||||
|
type ErrorCode int
|
||||||
|
|
||||||
|
const (
|
||||||
|
// ErrorCodeUnknown represents an unknown error
|
||||||
|
ErrorCodeUnknown ErrorCode = iota
|
||||||
|
|
||||||
|
// ErrorCodeFileNotFound represents file not found errors
|
||||||
|
ErrorCodeFileNotFound
|
||||||
|
|
||||||
|
// ErrorCodeFileAlreadyManaged represents file already managed errors
|
||||||
|
ErrorCodeFileAlreadyManaged
|
||||||
|
|
||||||
|
// ErrorCodeNotSymlink represents not a symlink errors
|
||||||
|
ErrorCodeNotSymlink
|
||||||
|
|
||||||
|
// ErrorCodeRepoNotInitialized represents repository not initialized errors
|
||||||
|
ErrorCodeRepoNotInitialized
|
||||||
|
|
||||||
|
// ErrorCodeNoRemoteConfigured represents no remote configured errors
|
||||||
|
ErrorCodeNoRemoteConfigured
|
||||||
|
|
||||||
|
// ErrorCodeOperationAborted represents operation aborted errors
|
||||||
|
ErrorCodeOperationAborted
|
||||||
|
|
||||||
|
// ErrorCodeConfigNotFound represents configuration not found errors
|
||||||
|
ErrorCodeConfigNotFound
|
||||||
|
|
||||||
|
// ErrorCodeInvalidPath represents invalid path errors
|
||||||
|
ErrorCodeInvalidPath
|
||||||
|
|
||||||
|
// ErrorCodePermissionDenied represents permission denied errors
|
||||||
|
ErrorCodePermissionDenied
|
||||||
|
|
||||||
|
// ErrorCodeGitOperation represents Git operation errors
|
||||||
|
ErrorCodeGitOperation
|
||||||
|
|
||||||
|
// ErrorCodeFileSystemOperation represents file system operation errors
|
||||||
|
ErrorCodeFileSystemOperation
|
||||||
|
)
|
||||||
|
|
||||||
|
// String returns a string representation of the error code
|
||||||
|
func (e ErrorCode) String() string {
|
||||||
|
switch e {
|
||||||
|
case ErrorCodeFileNotFound:
|
||||||
|
return "FILE_NOT_FOUND"
|
||||||
|
case ErrorCodeFileAlreadyManaged:
|
||||||
|
return "FILE_ALREADY_MANAGED"
|
||||||
|
case ErrorCodeNotSymlink:
|
||||||
|
return "NOT_SYMLINK"
|
||||||
|
case ErrorCodeRepoNotInitialized:
|
||||||
|
return "REPO_NOT_INITIALIZED"
|
||||||
|
case ErrorCodeNoRemoteConfigured:
|
||||||
|
return "NO_REMOTE_CONFIGURED"
|
||||||
|
case ErrorCodeOperationAborted:
|
||||||
|
return "OPERATION_ABORTED"
|
||||||
|
case ErrorCodeConfigNotFound:
|
||||||
|
return "CONFIG_NOT_FOUND"
|
||||||
|
case ErrorCodeInvalidPath:
|
||||||
|
return "INVALID_PATH"
|
||||||
|
case ErrorCodePermissionDenied:
|
||||||
|
return "PERMISSION_DENIED"
|
||||||
|
case ErrorCodeGitOperation:
|
||||||
|
return "GIT_OPERATION"
|
||||||
|
case ErrorCodeFileSystemOperation:
|
||||||
|
return "FILE_SYSTEM_OPERATION"
|
||||||
|
default:
|
||||||
|
return "UNKNOWN"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// LnkError represents a structured error with additional context
|
||||||
|
type LnkError struct {
|
||||||
|
// Code represents the type of error
|
||||||
|
Code ErrorCode
|
||||||
|
|
||||||
|
// Message is the human-readable error message
|
||||||
|
Message string
|
||||||
|
|
||||||
|
// Cause is the underlying error that caused this error
|
||||||
|
Cause error
|
||||||
|
|
||||||
|
// Context provides additional context about when/where the error occurred
|
||||||
|
Context map[string]interface{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Error implements the error interface
|
||||||
|
func (e *LnkError) Error() string {
|
||||||
|
if e.Cause != nil {
|
||||||
|
return fmt.Sprintf("%s: %v", e.Message, e.Cause)
|
||||||
|
}
|
||||||
|
return e.Message
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unwrap returns the underlying cause error for Go 1.13+ error handling
|
||||||
|
func (e *LnkError) Unwrap() error {
|
||||||
|
return e.Cause
|
||||||
|
}
|
||||||
|
|
||||||
|
// Is implements error comparison for Go 1.13+ error handling
|
||||||
|
func (e *LnkError) Is(target error) bool {
|
||||||
|
if lnkErr, ok := target.(*LnkError); ok {
|
||||||
|
return e.Code == lnkErr.Code
|
||||||
|
}
|
||||||
|
return errors.Is(e.Cause, target)
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithContext adds context information to the error
|
||||||
|
func (e *LnkError) WithContext(key string, value interface{}) *LnkError {
|
||||||
|
if e.Context == nil {
|
||||||
|
e.Context = make(map[string]interface{})
|
||||||
|
}
|
||||||
|
e.Context[key] = value
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewLnkError creates a new LnkError with the given code and message
|
||||||
|
func NewLnkError(code ErrorCode, message string) *LnkError {
|
||||||
|
return &LnkError{
|
||||||
|
Code: code,
|
||||||
|
Message: message,
|
||||||
|
Context: make(map[string]interface{}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// WrapError wraps an existing error with LnkError context
|
||||||
|
func WrapError(code ErrorCode, message string, cause error) *LnkError {
|
||||||
|
return &LnkError{
|
||||||
|
Code: code,
|
||||||
|
Message: message,
|
||||||
|
Cause: cause,
|
||||||
|
Context: make(map[string]interface{}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper functions for creating common errors
|
||||||
|
|
||||||
|
// NewFileNotFoundError creates a file not found error
|
||||||
|
func NewFileNotFoundError(path string) *LnkError {
|
||||||
|
return NewLnkError(ErrorCodeFileNotFound, fmt.Sprintf("❌ File does not exist: \033[31m%s\033[0m", path)).
|
||||||
|
WithContext("path", path)
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewFileAlreadyManagedError creates a file already managed error
|
||||||
|
func NewFileAlreadyManagedError(path string) *LnkError {
|
||||||
|
return NewLnkError(ErrorCodeFileAlreadyManaged, fmt.Sprintf("file already managed: %s", path)).
|
||||||
|
WithContext("path", path)
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewNotSymlinkError creates a not symlink error
|
||||||
|
func NewNotSymlinkError(path string) *LnkError {
|
||||||
|
return NewLnkError(ErrorCodeNotSymlink, fmt.Sprintf("not a symlink: %s", path)).
|
||||||
|
WithContext("path", path)
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewRepoNotInitializedError creates a repository not initialized error
|
||||||
|
func NewRepoNotInitializedError(repoPath string) *LnkError {
|
||||||
|
return NewLnkError(ErrorCodeRepoNotInitialized, "Lnk repository not initialized").
|
||||||
|
WithContext("repo_path", repoPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewNoRemoteConfiguredError creates a no remote configured error
|
||||||
|
func NewNoRemoteConfiguredError() *LnkError {
|
||||||
|
return NewLnkError(ErrorCodeNoRemoteConfigured, "no git remote configured")
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewConfigNotFoundError creates a configuration not found error
|
||||||
|
func NewConfigNotFoundError(host string) *LnkError {
|
||||||
|
return NewLnkError(ErrorCodeConfigNotFound, fmt.Sprintf("configuration not found for host: %s", host)).
|
||||||
|
WithContext("host", host)
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewInvalidPathError creates an invalid path error
|
||||||
|
func NewInvalidPathError(path string, reason string) *LnkError {
|
||||||
|
return NewLnkError(ErrorCodeInvalidPath, fmt.Sprintf("invalid path %s: %s", path, reason)).
|
||||||
|
WithContext("path", path).
|
||||||
|
WithContext("reason", reason)
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewPermissionDeniedError creates a permission denied error
|
||||||
|
func NewPermissionDeniedError(operation, path string) *LnkError {
|
||||||
|
return NewLnkError(ErrorCodePermissionDenied, fmt.Sprintf("permission denied for %s: %s", operation, path)).
|
||||||
|
WithContext("operation", operation).
|
||||||
|
WithContext("path", path)
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewGitOperationError creates a Git operation error
|
||||||
|
func NewGitOperationError(operation string, cause error) *LnkError {
|
||||||
|
return WrapError(ErrorCodeGitOperation, fmt.Sprintf("git %s failed", operation), cause).
|
||||||
|
WithContext("operation", operation)
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewFileSystemOperationError creates a file system operation error
|
||||||
|
func NewFileSystemOperationError(operation, path string, cause error) *LnkError {
|
||||||
|
return WrapError(ErrorCodeFileSystemOperation, fmt.Sprintf("file system %s failed for %s", operation, path), cause).
|
||||||
|
WithContext("operation", operation).
|
||||||
|
WithContext("path", path)
|
||||||
|
}
|
126
internal/errors/errors_test.go
Normal file
126
internal/errors/errors_test.go
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
package errors
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/suite"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ErrorsTestSuite struct {
|
||||||
|
suite.Suite
|
||||||
|
}
|
||||||
|
|
||||||
|
func (suite *ErrorsTestSuite) TestErrorCodeString() {
|
||||||
|
tests := []struct {
|
||||||
|
code ErrorCode
|
||||||
|
expected string
|
||||||
|
}{
|
||||||
|
{ErrorCodeFileNotFound, "FILE_NOT_FOUND"},
|
||||||
|
{ErrorCodeFileAlreadyManaged, "FILE_ALREADY_MANAGED"},
|
||||||
|
{ErrorCodeNotSymlink, "NOT_SYMLINK"},
|
||||||
|
{ErrorCodeRepoNotInitialized, "REPO_NOT_INITIALIZED"},
|
||||||
|
{ErrorCodeNoRemoteConfigured, "NO_REMOTE_CONFIGURED"},
|
||||||
|
{ErrorCodeOperationAborted, "OPERATION_ABORTED"},
|
||||||
|
{ErrorCodeConfigNotFound, "CONFIG_NOT_FOUND"},
|
||||||
|
{ErrorCodeInvalidPath, "INVALID_PATH"},
|
||||||
|
{ErrorCodePermissionDenied, "PERMISSION_DENIED"},
|
||||||
|
{ErrorCodeGitOperation, "GIT_OPERATION"},
|
||||||
|
{ErrorCodeFileSystemOperation, "FILE_SYSTEM_OPERATION"},
|
||||||
|
{ErrorCodeUnknown, "UNKNOWN"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
suite.Run(tt.expected, func() {
|
||||||
|
result := tt.code.String()
|
||||||
|
suite.Equal(tt.expected, result)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (suite *ErrorsTestSuite) TestLnkErrorError() {
|
||||||
|
suite.Run("without_cause", func() {
|
||||||
|
err := NewLnkError(ErrorCodeFileNotFound, "test file not found")
|
||||||
|
expected := "test file not found"
|
||||||
|
suite.Equal(expected, err.Error())
|
||||||
|
})
|
||||||
|
|
||||||
|
suite.Run("with_cause", func() {
|
||||||
|
cause := errors.New("underlying error")
|
||||||
|
err := WrapError(ErrorCodeFileSystemOperation, "file operation failed", cause)
|
||||||
|
expected := "file operation failed: underlying error"
|
||||||
|
suite.Equal(expected, err.Error())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (suite *ErrorsTestSuite) TestLnkErrorUnwrap() {
|
||||||
|
cause := errors.New("underlying error")
|
||||||
|
err := WrapError(ErrorCodeFileSystemOperation, "file operation failed", cause)
|
||||||
|
|
||||||
|
unwrapped := err.Unwrap()
|
||||||
|
suite.Equal(cause, unwrapped)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (suite *ErrorsTestSuite) TestLnkErrorIs() {
|
||||||
|
err1 := NewLnkError(ErrorCodeFileNotFound, "file not found")
|
||||||
|
err2 := NewLnkError(ErrorCodeFileNotFound, "another file not found")
|
||||||
|
err3 := NewLnkError(ErrorCodeFileAlreadyManaged, "file already managed")
|
||||||
|
|
||||||
|
// Same error code should match
|
||||||
|
suite.True(errors.Is(err1, err2), "expected errors with same code to match")
|
||||||
|
|
||||||
|
// Different error codes should not match
|
||||||
|
suite.False(errors.Is(err1, err3), "expected errors with different codes to not match")
|
||||||
|
|
||||||
|
// Test with wrapped errors
|
||||||
|
cause := errors.New("io error")
|
||||||
|
wrappedErr := WrapError(ErrorCodeFileSystemOperation, "wrapped", cause)
|
||||||
|
suite.True(errors.Is(wrappedErr, cause), "expected wrapped error to match its cause")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (suite *ErrorsTestSuite) TestLnkErrorWithContext() {
|
||||||
|
err := NewLnkError(ErrorCodeFileNotFound, "file not found")
|
||||||
|
err = err.WithContext("path", "/test/file.txt")
|
||||||
|
err = err.WithContext("operation", "read")
|
||||||
|
|
||||||
|
suite.Equal("/test/file.txt", err.Context["path"])
|
||||||
|
suite.Equal("read", err.Context["operation"])
|
||||||
|
}
|
||||||
|
|
||||||
|
func (suite *ErrorsTestSuite) TestNewFileNotFoundError() {
|
||||||
|
path := "/test/file.txt"
|
||||||
|
err := NewFileNotFoundError(path)
|
||||||
|
|
||||||
|
suite.Equal(ErrorCodeFileNotFound, err.Code)
|
||||||
|
suite.Equal(path, err.Context["path"])
|
||||||
|
}
|
||||||
|
|
||||||
|
func (suite *ErrorsTestSuite) TestNewFileAlreadyManagedError() {
|
||||||
|
path := "/test/file.txt"
|
||||||
|
err := NewFileAlreadyManagedError(path)
|
||||||
|
|
||||||
|
suite.Equal(ErrorCodeFileAlreadyManaged, err.Code)
|
||||||
|
suite.Equal(path, err.Context["path"])
|
||||||
|
}
|
||||||
|
|
||||||
|
func (suite *ErrorsTestSuite) TestNewRepoNotInitializedError() {
|
||||||
|
repoPath := "/test/repo"
|
||||||
|
err := NewRepoNotInitializedError(repoPath)
|
||||||
|
|
||||||
|
suite.Equal(ErrorCodeRepoNotInitialized, err.Code)
|
||||||
|
suite.Equal(repoPath, err.Context["repo_path"])
|
||||||
|
}
|
||||||
|
|
||||||
|
func (suite *ErrorsTestSuite) TestNewGitOperationError() {
|
||||||
|
operation := "push"
|
||||||
|
cause := errors.New("network error")
|
||||||
|
err := NewGitOperationError(operation, cause)
|
||||||
|
|
||||||
|
suite.Equal(ErrorCodeGitOperation, err.Code)
|
||||||
|
suite.Equal(cause, err.Cause)
|
||||||
|
suite.Equal(operation, err.Context["operation"])
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestErrorsSuite(t *testing.T) {
|
||||||
|
suite.Run(t, new(ErrorsTestSuite))
|
||||||
|
}
|
254
internal/fs/filemanager.go
Normal file
254
internal/fs/filemanager.go
Normal file
@@ -0,0 +1,254 @@
|
|||||||
|
package fs
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
|
||||||
|
"github.com/yarlson/lnk/internal/errors"
|
||||||
|
)
|
||||||
|
|
||||||
|
// FileManager implements the models.FileManager interface
|
||||||
|
type FileManager struct{}
|
||||||
|
|
||||||
|
// New creates a new FileManager instance
|
||||||
|
func New() *FileManager {
|
||||||
|
return &FileManager{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Exists checks if a file or directory exists
|
||||||
|
func (fm *FileManager) Exists(ctx context.Context, path string) (bool, error) {
|
||||||
|
// Check for context cancellation
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return false, ctx.Err()
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := os.Stat(path)
|
||||||
|
if err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
return false, errors.NewFileSystemOperationError("stat", path, err)
|
||||||
|
}
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsDirectory checks if the path points to a directory
|
||||||
|
func (fm *FileManager) IsDirectory(ctx context.Context, path string) (bool, error) {
|
||||||
|
// Check for context cancellation
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return false, ctx.Err()
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
|
||||||
|
info, err := os.Stat(path)
|
||||||
|
if err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return false, errors.NewFileNotFoundError(path)
|
||||||
|
}
|
||||||
|
return false, errors.NewFileSystemOperationError("stat", path, err)
|
||||||
|
}
|
||||||
|
return info.IsDir(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Move moves a file or directory from src to dst
|
||||||
|
func (fm *FileManager) Move(ctx context.Context, src, dst string) error {
|
||||||
|
// Check for context cancellation
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return ctx.Err()
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure destination directory exists
|
||||||
|
dstDir := filepath.Dir(dst)
|
||||||
|
if err := fm.MkdirAll(ctx, dstDir, 0755); err != nil {
|
||||||
|
return fmt.Errorf("failed to create destination directory: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for context cancellation before move
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return ctx.Err()
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
|
||||||
|
// Move the file or directory
|
||||||
|
if err := os.Rename(src, dst); err != nil {
|
||||||
|
return errors.NewFileSystemOperationError("move", src, err).
|
||||||
|
WithContext("destination", dst)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateSymlink creates a symlink pointing from linkPath to target
|
||||||
|
func (fm *FileManager) CreateSymlink(ctx context.Context, target, linkPath string) error {
|
||||||
|
// Check for context cancellation
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return ctx.Err()
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate relative path from linkPath to target
|
||||||
|
linkDir := filepath.Dir(linkPath)
|
||||||
|
relTarget, err := filepath.Rel(linkDir, target)
|
||||||
|
if err != nil {
|
||||||
|
return errors.NewFileSystemOperationError("calculate_relative_path", linkPath, err).
|
||||||
|
WithContext("target", target)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create the symlink
|
||||||
|
if err := os.Symlink(relTarget, linkPath); err != nil {
|
||||||
|
return errors.NewFileSystemOperationError("create_symlink", linkPath, err).
|
||||||
|
WithContext("target", relTarget)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove removes a file or directory
|
||||||
|
func (fm *FileManager) Remove(ctx context.Context, path string) error {
|
||||||
|
// Check for context cancellation
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return ctx.Err()
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := os.RemoveAll(path); err != nil {
|
||||||
|
return errors.NewFileSystemOperationError("remove", path, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReadFile reads the contents of a file
|
||||||
|
func (fm *FileManager) ReadFile(ctx context.Context, path string) ([]byte, error) {
|
||||||
|
// Check for context cancellation
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return nil, ctx.Err()
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return nil, errors.NewFileNotFoundError(path)
|
||||||
|
}
|
||||||
|
return nil, errors.NewFileSystemOperationError("read", path, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return data, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// WriteFile writes data to a file with the given permissions
|
||||||
|
func (fm *FileManager) WriteFile(ctx context.Context, path string, data []byte, perm os.FileMode) error {
|
||||||
|
// Check for context cancellation
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return ctx.Err()
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure parent directory exists
|
||||||
|
dir := filepath.Dir(path)
|
||||||
|
if err := fm.MkdirAll(ctx, dir, 0755); err != nil {
|
||||||
|
return fmt.Errorf("failed to create parent directory: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for context cancellation before write
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return ctx.Err()
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := os.WriteFile(path, data, perm); err != nil {
|
||||||
|
return errors.NewFileSystemOperationError("write", path, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// MkdirAll creates a directory and all necessary parent directories
|
||||||
|
func (fm *FileManager) MkdirAll(ctx context.Context, path string, perm os.FileMode) error {
|
||||||
|
// Check for context cancellation
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return ctx.Err()
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := os.MkdirAll(path, perm); err != nil {
|
||||||
|
return errors.NewFileSystemOperationError("mkdir", path, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Readlink returns the target of a symbolic link
|
||||||
|
func (fm *FileManager) Readlink(ctx context.Context, path string) (string, error) {
|
||||||
|
// Check for context cancellation
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return "", ctx.Err()
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
|
||||||
|
target, err := os.Readlink(path)
|
||||||
|
if err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return "", errors.NewFileNotFoundError(path)
|
||||||
|
}
|
||||||
|
return "", errors.NewFileSystemOperationError("readlink", path, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return target, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lstat returns file info without following symbolic links
|
||||||
|
func (fm *FileManager) Lstat(ctx context.Context, path string) (os.FileInfo, error) {
|
||||||
|
// Check for context cancellation
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return nil, ctx.Err()
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
|
||||||
|
info, err := os.Lstat(path)
|
||||||
|
if err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return nil, errors.NewFileNotFoundError(path)
|
||||||
|
}
|
||||||
|
return nil, errors.NewFileSystemOperationError("lstat", path, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return info, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stat returns file info, following symbolic links
|
||||||
|
func (fm *FileManager) Stat(ctx context.Context, path string) (os.FileInfo, error) {
|
||||||
|
// Check for context cancellation
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return nil, ctx.Err()
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
|
||||||
|
info, err := os.Stat(path)
|
||||||
|
if err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return nil, errors.NewFileNotFoundError(path)
|
||||||
|
}
|
||||||
|
return nil, errors.NewFileSystemOperationError("stat", path, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return info, nil
|
||||||
|
}
|
261
internal/fs/filemanager_test.go
Normal file
261
internal/fs/filemanager_test.go
Normal file
@@ -0,0 +1,261 @@
|
|||||||
|
package fs
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/suite"
|
||||||
|
|
||||||
|
"github.com/yarlson/lnk/internal/errors"
|
||||||
|
)
|
||||||
|
|
||||||
|
type FileManagerTestSuite struct {
|
||||||
|
suite.Suite
|
||||||
|
tempDir string
|
||||||
|
fileManager *FileManager
|
||||||
|
ctx context.Context
|
||||||
|
}
|
||||||
|
|
||||||
|
func (suite *FileManagerTestSuite) SetupTest() {
|
||||||
|
// Create temp directory for testing
|
||||||
|
tempDir, err := os.MkdirTemp("", "lnk_test_*")
|
||||||
|
suite.Require().NoError(err)
|
||||||
|
suite.tempDir = tempDir
|
||||||
|
|
||||||
|
// Create file manager
|
||||||
|
suite.fileManager = New()
|
||||||
|
|
||||||
|
// Create context
|
||||||
|
suite.ctx = context.Background()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (suite *FileManagerTestSuite) TearDownTest() {
|
||||||
|
err := os.RemoveAll(suite.tempDir)
|
||||||
|
suite.Require().NoError(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (suite *FileManagerTestSuite) TestExists() {
|
||||||
|
// Test existing file
|
||||||
|
testFile := filepath.Join(suite.tempDir, "test.txt")
|
||||||
|
err := os.WriteFile(testFile, []byte("test"), 0644)
|
||||||
|
suite.Require().NoError(err)
|
||||||
|
|
||||||
|
exists, err := suite.fileManager.Exists(suite.ctx, testFile)
|
||||||
|
suite.NoError(err)
|
||||||
|
suite.True(exists)
|
||||||
|
|
||||||
|
// Test non-existing file
|
||||||
|
nonExistentFile := filepath.Join(suite.tempDir, "nonexistent.txt")
|
||||||
|
exists, err = suite.fileManager.Exists(suite.ctx, nonExistentFile)
|
||||||
|
suite.NoError(err)
|
||||||
|
suite.False(exists)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (suite *FileManagerTestSuite) TestExistsWithCancellation() {
|
||||||
|
// Create cancelled context
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
cancel()
|
||||||
|
|
||||||
|
_, err := suite.fileManager.Exists(ctx, "/any/path")
|
||||||
|
suite.Equal(context.Canceled, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (suite *FileManagerTestSuite) TestIsDirectory() {
|
||||||
|
// Test directory
|
||||||
|
isDir, err := suite.fileManager.IsDirectory(suite.ctx, suite.tempDir)
|
||||||
|
suite.NoError(err)
|
||||||
|
suite.True(isDir)
|
||||||
|
|
||||||
|
// Test file
|
||||||
|
testFile := filepath.Join(suite.tempDir, "test.txt")
|
||||||
|
err = os.WriteFile(testFile, []byte("test"), 0644)
|
||||||
|
suite.Require().NoError(err)
|
||||||
|
|
||||||
|
isDir, err = suite.fileManager.IsDirectory(suite.ctx, testFile)
|
||||||
|
suite.NoError(err)
|
||||||
|
suite.False(isDir)
|
||||||
|
|
||||||
|
// Test non-existing file
|
||||||
|
nonExistentFile := filepath.Join(suite.tempDir, "nonexistent.txt")
|
||||||
|
_, err = suite.fileManager.IsDirectory(suite.ctx, nonExistentFile)
|
||||||
|
suite.Error(err)
|
||||||
|
|
||||||
|
// Check that it's a models error
|
||||||
|
suite.True(errors.NewFileNotFoundError("").Is(err))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (suite *FileManagerTestSuite) TestMove() {
|
||||||
|
// Create test file
|
||||||
|
srcFile := filepath.Join(suite.tempDir, "source.txt")
|
||||||
|
testContent := []byte("test content")
|
||||||
|
err := os.WriteFile(srcFile, testContent, 0644)
|
||||||
|
suite.Require().NoError(err)
|
||||||
|
|
||||||
|
// Test moving file
|
||||||
|
dstFile := filepath.Join(suite.tempDir, "subdir", "destination.txt")
|
||||||
|
err = suite.fileManager.Move(suite.ctx, srcFile, dstFile)
|
||||||
|
suite.NoError(err)
|
||||||
|
|
||||||
|
// Verify source doesn't exist
|
||||||
|
_, err = os.Stat(srcFile)
|
||||||
|
suite.True(os.IsNotExist(err))
|
||||||
|
|
||||||
|
// Verify destination exists with correct content
|
||||||
|
content, err := os.ReadFile(dstFile)
|
||||||
|
suite.NoError(err)
|
||||||
|
suite.Equal(string(testContent), string(content))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (suite *FileManagerTestSuite) TestCreateSymlink() {
|
||||||
|
// Create target file
|
||||||
|
targetFile := filepath.Join(suite.tempDir, "target.txt")
|
||||||
|
err := os.WriteFile(targetFile, []byte("test"), 0644)
|
||||||
|
suite.Require().NoError(err)
|
||||||
|
|
||||||
|
// Create symlink
|
||||||
|
linkFile := filepath.Join(suite.tempDir, "link.txt")
|
||||||
|
err = suite.fileManager.CreateSymlink(suite.ctx, targetFile, linkFile)
|
||||||
|
suite.NoError(err)
|
||||||
|
|
||||||
|
// Verify symlink exists and points to target
|
||||||
|
info, err := os.Lstat(linkFile)
|
||||||
|
suite.NoError(err)
|
||||||
|
suite.NotZero(info.Mode() & os.ModeSymlink)
|
||||||
|
|
||||||
|
// Verify symlink target
|
||||||
|
target, err := os.Readlink(linkFile)
|
||||||
|
suite.NoError(err)
|
||||||
|
|
||||||
|
expectedTarget := "target.txt" // Should be relative
|
||||||
|
suite.Equal(expectedTarget, target)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (suite *FileManagerTestSuite) TestReadWriteFile() {
|
||||||
|
// Test writing file
|
||||||
|
testFile := filepath.Join(suite.tempDir, "subdir", "test.txt")
|
||||||
|
testContent := []byte("test content")
|
||||||
|
err := suite.fileManager.WriteFile(suite.ctx, testFile, testContent, 0644)
|
||||||
|
suite.NoError(err)
|
||||||
|
|
||||||
|
// Test reading file
|
||||||
|
content, err := suite.fileManager.ReadFile(suite.ctx, testFile)
|
||||||
|
suite.NoError(err)
|
||||||
|
suite.Equal(string(testContent), string(content))
|
||||||
|
|
||||||
|
// Test reading non-existent file
|
||||||
|
nonExistentFile := filepath.Join(suite.tempDir, "nonexistent.txt")
|
||||||
|
_, err = suite.fileManager.ReadFile(suite.ctx, nonExistentFile)
|
||||||
|
suite.Error(err)
|
||||||
|
suite.True(errors.NewFileNotFoundError("").Is(err))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (suite *FileManagerTestSuite) TestRemove() {
|
||||||
|
// Create test file
|
||||||
|
testFile := filepath.Join(suite.tempDir, "test.txt")
|
||||||
|
err := os.WriteFile(testFile, []byte("test"), 0644)
|
||||||
|
suite.Require().NoError(err)
|
||||||
|
|
||||||
|
// Remove file
|
||||||
|
err = suite.fileManager.Remove(suite.ctx, testFile)
|
||||||
|
suite.NoError(err)
|
||||||
|
|
||||||
|
// Verify file doesn't exist
|
||||||
|
_, err = os.Stat(testFile)
|
||||||
|
suite.True(os.IsNotExist(err))
|
||||||
|
|
||||||
|
// Test removing non-existent file (should not error)
|
||||||
|
err = suite.fileManager.Remove(suite.ctx, testFile)
|
||||||
|
suite.NoError(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (suite *FileManagerTestSuite) TestMkdirAll() {
|
||||||
|
// Create nested directory
|
||||||
|
nestedDir := filepath.Join(suite.tempDir, "a", "b", "c")
|
||||||
|
err := suite.fileManager.MkdirAll(suite.ctx, nestedDir, 0755)
|
||||||
|
suite.NoError(err)
|
||||||
|
|
||||||
|
// Verify directory exists
|
||||||
|
info, err := os.Stat(nestedDir)
|
||||||
|
suite.NoError(err)
|
||||||
|
suite.True(info.IsDir())
|
||||||
|
}
|
||||||
|
|
||||||
|
func (suite *FileManagerTestSuite) TestReadlink() {
|
||||||
|
// Create target file
|
||||||
|
targetFile := filepath.Join(suite.tempDir, "target.txt")
|
||||||
|
err := os.WriteFile(targetFile, []byte("test"), 0644)
|
||||||
|
suite.Require().NoError(err)
|
||||||
|
|
||||||
|
// Create symlink
|
||||||
|
linkFile := filepath.Join(suite.tempDir, "link.txt")
|
||||||
|
err = os.Symlink("target.txt", linkFile)
|
||||||
|
suite.Require().NoError(err)
|
||||||
|
|
||||||
|
// Test reading symlink
|
||||||
|
target, err := suite.fileManager.Readlink(suite.ctx, linkFile)
|
||||||
|
suite.NoError(err)
|
||||||
|
suite.Equal("target.txt", target)
|
||||||
|
|
||||||
|
// Test reading non-symlink
|
||||||
|
_, err = suite.fileManager.Readlink(suite.ctx, targetFile)
|
||||||
|
suite.Error(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (suite *FileManagerTestSuite) TestStatAndLstat() {
|
||||||
|
// Create target file
|
||||||
|
targetFile := filepath.Join(suite.tempDir, "target.txt")
|
||||||
|
err := os.WriteFile(targetFile, []byte("test"), 0644)
|
||||||
|
suite.Require().NoError(err)
|
||||||
|
|
||||||
|
// Create symlink
|
||||||
|
linkFile := filepath.Join(suite.tempDir, "link.txt")
|
||||||
|
err = os.Symlink("target.txt", linkFile)
|
||||||
|
suite.Require().NoError(err)
|
||||||
|
|
||||||
|
// Test Stat on regular file
|
||||||
|
info, err := suite.fileManager.Stat(suite.ctx, targetFile)
|
||||||
|
suite.NoError(err)
|
||||||
|
suite.False(info.IsDir())
|
||||||
|
|
||||||
|
// Test Stat on symlink (should follow link)
|
||||||
|
info, err = suite.fileManager.Stat(suite.ctx, linkFile)
|
||||||
|
suite.NoError(err)
|
||||||
|
suite.False(info.IsDir())
|
||||||
|
|
||||||
|
// Test Lstat on symlink (should not follow link)
|
||||||
|
info, err = suite.fileManager.Lstat(suite.ctx, linkFile)
|
||||||
|
suite.NoError(err)
|
||||||
|
suite.NotZero(info.Mode() & os.ModeSymlink)
|
||||||
|
|
||||||
|
// Test on non-existent file
|
||||||
|
nonExistentFile := filepath.Join(suite.tempDir, "nonexistent.txt")
|
||||||
|
_, err = suite.fileManager.Stat(suite.ctx, nonExistentFile)
|
||||||
|
suite.Error(err)
|
||||||
|
suite.True(errors.NewFileNotFoundError("").Is(err))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (suite *FileManagerTestSuite) TestContextCancellation() {
|
||||||
|
// Test with timeout context
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 1*time.Nanosecond)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
// Allow time for context to expire
|
||||||
|
time.Sleep(1 * time.Millisecond)
|
||||||
|
|
||||||
|
// Test various operations with cancelled context
|
||||||
|
_, err := suite.fileManager.Exists(ctx, "/any/path")
|
||||||
|
suite.Equal(context.DeadlineExceeded, err)
|
||||||
|
|
||||||
|
_, err = suite.fileManager.IsDirectory(ctx, "/any/path")
|
||||||
|
suite.Equal(context.DeadlineExceeded, err)
|
||||||
|
|
||||||
|
err = suite.fileManager.Move(ctx, "/src", "/dst")
|
||||||
|
suite.Equal(context.DeadlineExceeded, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFileManagerSuite(t *testing.T) {
|
||||||
|
suite.Run(t, new(FileManagerTestSuite))
|
||||||
|
}
|
@@ -1,133 +0,0 @@
|
|||||||
package fs
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
|
||||||
)
|
|
||||||
|
|
||||||
// FileSystem handles file system operations
|
|
||||||
type FileSystem struct{}
|
|
||||||
|
|
||||||
// New creates a new FileSystem instance
|
|
||||||
func New() *FileSystem {
|
|
||||||
return &FileSystem{}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ValidateFileForAdd validates that a file or directory can be added to lnk
|
|
||||||
func (fs *FileSystem) ValidateFileForAdd(filePath string) error {
|
|
||||||
// Check if file exists
|
|
||||||
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 fmt.Errorf("❌ Failed to check file: %w", 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 nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// ValidateSymlinkForRemove validates that a symlink can be removed from lnk
|
|
||||||
func (fs *FileSystem) ValidateSymlinkForRemove(filePath, repoPath string) error {
|
|
||||||
// Check if file exists
|
|
||||||
info, err := os.Lstat(filePath) // Use Lstat to not follow symlinks
|
|
||||||
if err != nil {
|
|
||||||
if os.IsNotExist(err) {
|
|
||||||
return fmt.Errorf("❌ File does not exist: \033[31m%s\033[0m", filePath)
|
|
||||||
}
|
|
||||||
return fmt.Errorf("❌ Failed to check file: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if symlink points to the repository
|
|
||||||
target, err := os.Readlink(filePath)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to read symlink: %w", 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
|
|
||||||
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 nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to calculate relative path: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create the symlink
|
|
||||||
if err := os.Symlink(relTarget, linkPath); err != nil {
|
|
||||||
return fmt.Errorf("failed to create symlink: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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
|
|
||||||
}
|
|
@@ -1,508 +0,0 @@
|
|||||||
package git
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
"os/exec"
|
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Git handles Git operations
|
|
||||||
type Git struct {
|
|
||||||
repoPath string
|
|
||||||
}
|
|
||||||
|
|
||||||
// New creates a new Git instance
|
|
||||||
func New(repoPath string) *Git {
|
|
||||||
return &Git{
|
|
||||||
repoPath: repoPath,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Init initializes a new Git repository
|
|
||||||
func (g *Git) Init() error {
|
|
||||||
// Try using git init -b main first (Git 2.28+)
|
|
||||||
cmd := exec.Command("git", "init", "-b", "main")
|
|
||||||
cmd.Dir = g.repoPath
|
|
||||||
|
|
||||||
_, err := cmd.CombinedOutput()
|
|
||||||
if err != nil {
|
|
||||||
// Fallback to regular init + branch rename for older Git versions
|
|
||||||
cmd = exec.Command("git", "init")
|
|
||||||
cmd.Dir = g.repoPath
|
|
||||||
|
|
||||||
output, err := cmd.CombinedOutput()
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("git init failed: %w\nOutput: %s", err, string(output))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set the default branch to main
|
|
||||||
cmd = exec.Command("git", "symbolic-ref", "HEAD", "refs/heads/main")
|
|
||||||
cmd.Dir = g.repoPath
|
|
||||||
|
|
||||||
if err := cmd.Run(); err != nil {
|
|
||||||
return fmt.Errorf("failed to set default branch to main: %w", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// AddRemote adds a remote to the repository
|
|
||||||
func (g *Git) AddRemote(name, url string) error {
|
|
||||||
// Check if remote already exists
|
|
||||||
existingURL, err := g.getRemoteURL(name)
|
|
||||||
if err == nil {
|
|
||||||
// Remote exists, check if URL matches
|
|
||||||
if existingURL == url {
|
|
||||||
// Same URL, idempotent - do nothing
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
// Different URL, error
|
|
||||||
return fmt.Errorf("remote %s already exists with different URL: %s (trying to add: %s)", name, existingURL, url)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Remote doesn't exist, add it
|
|
||||||
cmd := exec.Command("git", "remote", "add", name, url)
|
|
||||||
cmd.Dir = g.repoPath
|
|
||||||
|
|
||||||
output, err := cmd.CombinedOutput()
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("git remote add failed: %w\nOutput: %s", err, string(output))
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// getRemoteURL returns the URL for a remote, or error if not found
|
|
||||||
func (g *Git) getRemoteURL(name string) (string, error) {
|
|
||||||
cmd := exec.Command("git", "remote", "get-url", name)
|
|
||||||
cmd.Dir = g.repoPath
|
|
||||||
|
|
||||||
output, err := cmd.Output()
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
|
|
||||||
return strings.TrimSpace(string(output)), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// IsGitRepository checks if the directory contains a Git repository
|
|
||||||
func (g *Git) IsGitRepository() bool {
|
|
||||||
gitDir := filepath.Join(g.repoPath, ".git")
|
|
||||||
_, err := os.Stat(gitDir)
|
|
||||||
return err == nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// IsLnkRepository checks if the repository appears to be managed by lnk
|
|
||||||
func (g *Git) IsLnkRepository() bool {
|
|
||||||
if !g.IsGitRepository() {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if this looks like a lnk repository
|
|
||||||
// We consider it a lnk repo if:
|
|
||||||
// 1. It has no commits (fresh repo), OR
|
|
||||||
// 2. All commits start with "lnk:" pattern
|
|
||||||
|
|
||||||
commits, err := g.GetCommits()
|
|
||||||
if err != nil {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// If no commits, it's a fresh repo - could be lnk
|
|
||||||
if len(commits) == 0 {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
// If all commits start with "lnk:", it's definitely ours
|
|
||||||
// If ANY commit doesn't start with "lnk:", it's probably not ours
|
|
||||||
for _, commit := range commits {
|
|
||||||
if !strings.HasPrefix(commit, "lnk:") {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
// AddAndCommit stages a file and commits it
|
|
||||||
func (g *Git) AddAndCommit(filename, message string) error {
|
|
||||||
// Stage the file
|
|
||||||
if err := g.Add(filename); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Commit the changes
|
|
||||||
if err := g.Commit(message); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// RemoveAndCommit removes a file from Git and commits the change
|
|
||||||
func (g *Git) RemoveAndCommit(filename, message string) error {
|
|
||||||
// Remove the file from Git
|
|
||||||
if err := g.Remove(filename); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Commit the changes
|
|
||||||
if err := g.Commit(message); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add stages a file
|
|
||||||
func (g *Git) Add(filename string) error {
|
|
||||||
cmd := exec.Command("git", "add", filename)
|
|
||||||
cmd.Dir = g.repoPath
|
|
||||||
|
|
||||||
output, err := cmd.CombinedOutput()
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("git add failed: %w\nOutput: %s", err, string(output))
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Remove removes a file from Git tracking
|
|
||||||
func (g *Git) Remove(filename string) error {
|
|
||||||
// Check if it's a directory that needs -r flag
|
|
||||||
fullPath := filepath.Join(g.repoPath, filename)
|
|
||||||
info, err := os.Stat(fullPath)
|
|
||||||
|
|
||||||
var cmd *exec.Cmd
|
|
||||||
if err == nil && info.IsDir() {
|
|
||||||
// Use -r and --cached flags for directories (only remove from git, not filesystem)
|
|
||||||
cmd = exec.Command("git", "rm", "-r", "--cached", filename)
|
|
||||||
} else {
|
|
||||||
// Regular file (only remove from git, not filesystem)
|
|
||||||
cmd = exec.Command("git", "rm", "--cached", filename)
|
|
||||||
}
|
|
||||||
|
|
||||||
cmd.Dir = g.repoPath
|
|
||||||
|
|
||||||
output, err := cmd.CombinedOutput()
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("git rm failed: %w\nOutput: %s", err, string(output))
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Commit creates a commit with the given message
|
|
||||||
func (g *Git) Commit(message string) error {
|
|
||||||
// Configure git user if not already configured
|
|
||||||
if err := g.ensureGitConfig(); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
cmd := exec.Command("git", "commit", "-m", message)
|
|
||||||
cmd.Dir = g.repoPath
|
|
||||||
|
|
||||||
output, err := cmd.CombinedOutput()
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("git commit failed: %w\nOutput: %s", err, string(output))
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// ensureGitConfig ensures that git user.name and user.email are configured
|
|
||||||
func (g *Git) ensureGitConfig() error {
|
|
||||||
// Check if user.name is configured
|
|
||||||
cmd := exec.Command("git", "config", "user.name")
|
|
||||||
cmd.Dir = g.repoPath
|
|
||||||
if output, err := cmd.Output(); err != nil || len(strings.TrimSpace(string(output))) == 0 {
|
|
||||||
// Set a default user.name
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if user.email is configured
|
|
||||||
cmd = exec.Command("git", "config", "user.email")
|
|
||||||
cmd.Dir = g.repoPath
|
|
||||||
if output, err := cmd.Output(); err != nil || len(strings.TrimSpace(string(output))) == 0 {
|
|
||||||
// Set a default user.email
|
|
||||||
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 nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetCommits returns the list of commit messages for testing purposes
|
|
||||||
func (g *Git) GetCommits() ([]string, error) {
|
|
||||||
// Check if .git directory exists
|
|
||||||
gitDir := filepath.Join(g.repoPath, ".git")
|
|
||||||
if _, err := os.Stat(gitDir); os.IsNotExist(err) {
|
|
||||||
return []string{}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
cmd := exec.Command("git", "log", "--oneline", "--format=%s")
|
|
||||||
cmd.Dir = g.repoPath
|
|
||||||
|
|
||||||
output, err := cmd.CombinedOutput()
|
|
||||||
if err != nil {
|
|
||||||
// If there are no commits yet, return empty slice
|
|
||||||
outputStr := string(output)
|
|
||||||
if strings.Contains(outputStr, "does not have any commits yet") {
|
|
||||||
return []string{}, nil
|
|
||||||
}
|
|
||||||
return nil, fmt.Errorf("git log failed: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
commits := strings.Split(strings.TrimSpace(string(output)), "\n")
|
|
||||||
if len(commits) == 1 && commits[0] == "" {
|
|
||||||
return []string{}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
return commits, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetRemoteInfo returns information about the default remote
|
|
||||||
func (g *Git) GetRemoteInfo() (string, error) {
|
|
||||||
// First try to get origin remote
|
|
||||||
url, err := g.getRemoteURL("origin")
|
|
||||||
if err != nil {
|
|
||||||
// If origin doesn't exist, try to get any remote
|
|
||||||
cmd := exec.Command("git", "remote")
|
|
||||||
cmd.Dir = g.repoPath
|
|
||||||
|
|
||||||
output, err := cmd.Output()
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("failed to list remotes: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
remotes := strings.Split(strings.TrimSpace(string(output)), "\n")
|
|
||||||
if len(remotes) == 0 || remotes[0] == "" {
|
|
||||||
return "", fmt.Errorf("no remote configured")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Use the first remote
|
|
||||||
url, err = g.getRemoteURL(remotes[0])
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("failed to get remote URL: %w", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return url, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// StatusInfo contains repository status information
|
|
||||||
type StatusInfo struct {
|
|
||||||
Ahead int
|
|
||||||
Behind int
|
|
||||||
Remote string
|
|
||||||
Dirty bool
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetStatus returns the repository status relative to remote
|
|
||||||
func (g *Git) GetStatus() (*StatusInfo, error) {
|
|
||||||
// Check if we have a remote
|
|
||||||
_, err := g.GetRemoteInfo()
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check for uncommitted changes
|
|
||||||
dirty, err := g.HasChanges()
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to check for uncommitted changes: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get the remote tracking branch
|
|
||||||
cmd := exec.Command("git", "rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}")
|
|
||||||
cmd.Dir = g.repoPath
|
|
||||||
|
|
||||||
output, err := cmd.Output()
|
|
||||||
if err != nil {
|
|
||||||
// No upstream branch set, assume origin/main
|
|
||||||
remoteBranch := "origin/main"
|
|
||||||
return &StatusInfo{
|
|
||||||
Ahead: g.getAheadCount(remoteBranch),
|
|
||||||
Behind: 0, // Can't be behind if no upstream
|
|
||||||
Remote: remoteBranch,
|
|
||||||
Dirty: dirty,
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
remoteBranch := strings.TrimSpace(string(output))
|
|
||||||
|
|
||||||
return &StatusInfo{
|
|
||||||
Ahead: g.getAheadCount(remoteBranch),
|
|
||||||
Behind: g.getBehindCount(remoteBranch),
|
|
||||||
Remote: remoteBranch,
|
|
||||||
Dirty: dirty,
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// getAheadCount returns how many commits ahead of remote
|
|
||||||
func (g *Git) getAheadCount(remoteBranch string) int {
|
|
||||||
cmd := exec.Command("git", "rev-list", "--count", fmt.Sprintf("%s..HEAD", remoteBranch))
|
|
||||||
cmd.Dir = g.repoPath
|
|
||||||
|
|
||||||
output, err := cmd.Output()
|
|
||||||
if err != nil {
|
|
||||||
// If remote branch doesn't exist, count all local commits
|
|
||||||
cmd = exec.Command("git", "rev-list", "--count", "HEAD")
|
|
||||||
cmd.Dir = g.repoPath
|
|
||||||
|
|
||||||
output, err = cmd.Output()
|
|
||||||
if err != nil {
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
count := strings.TrimSpace(string(output))
|
|
||||||
if count == "" {
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
|
|
||||||
// Convert to int
|
|
||||||
var ahead int
|
|
||||||
if _, err := fmt.Sscanf(count, "%d", &ahead); err != nil {
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
|
|
||||||
return ahead
|
|
||||||
}
|
|
||||||
|
|
||||||
// getBehindCount returns how many commits behind remote
|
|
||||||
func (g *Git) getBehindCount(remoteBranch string) int {
|
|
||||||
cmd := exec.Command("git", "rev-list", "--count", fmt.Sprintf("HEAD..%s", remoteBranch))
|
|
||||||
cmd.Dir = g.repoPath
|
|
||||||
|
|
||||||
output, err := cmd.Output()
|
|
||||||
if err != nil {
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
|
|
||||||
count := strings.TrimSpace(string(output))
|
|
||||||
if count == "" {
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
|
|
||||||
// Convert to int
|
|
||||||
var behind int
|
|
||||||
if _, err := fmt.Sscanf(count, "%d", &behind); err != nil {
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
|
|
||||||
return behind
|
|
||||||
}
|
|
||||||
|
|
||||||
// HasChanges checks if there are uncommitted changes
|
|
||||||
func (g *Git) HasChanges() (bool, error) {
|
|
||||||
cmd := exec.Command("git", "status", "--porcelain")
|
|
||||||
cmd.Dir = g.repoPath
|
|
||||||
|
|
||||||
output, err := cmd.Output()
|
|
||||||
if err != nil {
|
|
||||||
return false, fmt.Errorf("git status failed: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return len(strings.TrimSpace(string(output))) > 0, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// AddAll stages all changes in the repository
|
|
||||||
func (g *Git) AddAll() error {
|
|
||||||
cmd := exec.Command("git", "add", "-A")
|
|
||||||
cmd.Dir = g.repoPath
|
|
||||||
|
|
||||||
output, err := cmd.CombinedOutput()
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("git add failed: %w\nOutput: %s", err, string(output))
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Push pushes changes to remote
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
|
|
||||||
cmd := exec.Command("git", "push", "-u", "origin", "main")
|
|
||||||
cmd.Dir = g.repoPath
|
|
||||||
|
|
||||||
output, err := cmd.CombinedOutput()
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("git push failed: %w\nOutput: %s", err, string(output))
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Pull pulls changes from remote
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
|
|
||||||
cmd := exec.Command("git", "pull", "origin", "main")
|
|
||||||
cmd.Dir = g.repoPath
|
|
||||||
|
|
||||||
output, err := cmd.CombinedOutput()
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("git pull failed: %w\nOutput: %s", err, string(output))
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clone clones a repository from the given URL
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set up upstream tracking for main branch
|
|
||||||
cmd = exec.Command("git", "branch", "--set-upstream-to=origin/main", "main")
|
|
||||||
cmd.Dir = g.repoPath
|
|
||||||
_, err = cmd.CombinedOutput()
|
|
||||||
if err != nil {
|
|
||||||
// If main doesn't exist, try master
|
|
||||||
cmd = exec.Command("git", "branch", "--set-upstream-to=origin/master", "master")
|
|
||||||
cmd.Dir = g.repoPath
|
|
||||||
_, err = cmd.CombinedOutput()
|
|
||||||
if err != nil {
|
|
||||||
// If that also fails, try to set upstream for current branch
|
|
||||||
cmd = exec.Command("git", "branch", "--set-upstream-to=origin/HEAD")
|
|
||||||
cmd.Dir = g.repoPath
|
|
||||||
_, _ = cmd.CombinedOutput() // Ignore error as this is best effort
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
547
internal/git/gitmanager.go
Normal file
547
internal/git/gitmanager.go
Normal file
@@ -0,0 +1,547 @@
|
|||||||
|
package git
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/yarlson/lnk/internal/errors"
|
||||||
|
"github.com/yarlson/lnk/internal/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
// GitManager implements the models.GitManager interface
|
||||||
|
type GitManager struct{}
|
||||||
|
|
||||||
|
// New creates a new GitManager instance
|
||||||
|
func New() *GitManager {
|
||||||
|
return &GitManager{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Init initializes a new Git repository at repoPath
|
||||||
|
func (g *GitManager) Init(ctx context.Context, repoPath string) error {
|
||||||
|
// Try using git init -b main first (Git 2.28+)
|
||||||
|
cmd := exec.CommandContext(ctx, "git", "init", "-b", "main")
|
||||||
|
cmd.Dir = repoPath
|
||||||
|
|
||||||
|
_, err := cmd.CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
// Fallback to regular init + branch rename for older Git versions
|
||||||
|
cmd = exec.CommandContext(ctx, "git", "init")
|
||||||
|
cmd.Dir = repoPath
|
||||||
|
|
||||||
|
output, err := cmd.CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
return errors.NewGitOperationError("init", fmt.Errorf("git init failed: %w\nOutput: %s", err, string(output)))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set the default branch to main
|
||||||
|
cmd = exec.CommandContext(ctx, "git", "symbolic-ref", "HEAD", "refs/heads/main")
|
||||||
|
cmd.Dir = repoPath
|
||||||
|
|
||||||
|
if err := cmd.Run(); err != nil {
|
||||||
|
return errors.NewGitOperationError("init", fmt.Errorf("failed to set default branch to main: %w", err))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clone clones a repository from url to repoPath
|
||||||
|
func (g *GitManager) Clone(ctx context.Context, repoPath, url string) error {
|
||||||
|
// Remove the directory if it exists to ensure clean clone
|
||||||
|
if err := os.RemoveAll(repoPath); err != nil {
|
||||||
|
return errors.NewFileSystemOperationError("remove_existing_dir", repoPath,
|
||||||
|
fmt.Errorf("failed to remove existing directory: %w", err))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create parent directory
|
||||||
|
parentDir := filepath.Dir(repoPath)
|
||||||
|
if err := os.MkdirAll(parentDir, 0755); err != nil {
|
||||||
|
return errors.NewFileSystemOperationError("create_parent_dir", parentDir,
|
||||||
|
fmt.Errorf("failed to create parent directory: %w", err))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clone the repository
|
||||||
|
cmd := exec.CommandContext(ctx, "git", "clone", url, repoPath)
|
||||||
|
output, err := cmd.CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
return errors.NewGitOperationError("clone", fmt.Errorf("git clone failed: %w\nOutput: %s", err, string(output)))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set up upstream tracking for main branch
|
||||||
|
cmd = exec.CommandContext(ctx, "git", "branch", "--set-upstream-to=origin/main", "main")
|
||||||
|
cmd.Dir = repoPath
|
||||||
|
_, err = cmd.CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
// If main doesn't exist, try master
|
||||||
|
cmd = exec.CommandContext(ctx, "git", "branch", "--set-upstream-to=origin/master", "master")
|
||||||
|
cmd.Dir = repoPath
|
||||||
|
_, err = cmd.CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
// If that also fails, try to set upstream for current branch
|
||||||
|
cmd = exec.CommandContext(ctx, "git", "branch", "--set-upstream-to=origin/HEAD")
|
||||||
|
cmd.Dir = repoPath
|
||||||
|
_, _ = cmd.CombinedOutput() // Ignore error as this is best effort
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add stages files for commit
|
||||||
|
func (g *GitManager) Add(ctx context.Context, repoPath string, files ...string) error {
|
||||||
|
args := append([]string{"add"}, files...)
|
||||||
|
cmd := exec.CommandContext(ctx, "git", args...)
|
||||||
|
cmd.Dir = repoPath
|
||||||
|
|
||||||
|
output, err := cmd.CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
return errors.NewGitOperationError("add", fmt.Errorf("git add failed: %w\nOutput: %s", err, string(output)))
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove removes files from Git tracking
|
||||||
|
func (g *GitManager) Remove(ctx context.Context, repoPath string, files ...string) error {
|
||||||
|
for _, filename := range files {
|
||||||
|
// Check if it's a directory in the repository by checking the actual repo path
|
||||||
|
fullPath := filepath.Join(repoPath, filename)
|
||||||
|
info, err := os.Stat(fullPath)
|
||||||
|
|
||||||
|
var cmd *exec.Cmd
|
||||||
|
useRecursive := false
|
||||||
|
if err == nil && info.IsDir() {
|
||||||
|
useRecursive = true
|
||||||
|
}
|
||||||
|
|
||||||
|
if useRecursive {
|
||||||
|
// Use -r and --cached flags for directories (only remove from git, not fs)
|
||||||
|
cmd = exec.CommandContext(ctx, "git", "rm", "-r", "--cached", filename)
|
||||||
|
} else {
|
||||||
|
// Regular file (only remove from git, not fs)
|
||||||
|
cmd = exec.CommandContext(ctx, "git", "rm", "--cached", filename)
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd.Dir = repoPath
|
||||||
|
|
||||||
|
output, err := cmd.CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
// If we tried without -r and got a "recursively without -r" error, try with -r
|
||||||
|
if !useRecursive && strings.Contains(string(output), "recursively without -r") {
|
||||||
|
cmd = exec.CommandContext(ctx, "git", "rm", "-r", "--cached", filename)
|
||||||
|
cmd.Dir = repoPath
|
||||||
|
output, err = cmd.CombinedOutput()
|
||||||
|
}
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return errors.NewGitOperationError("remove", fmt.Errorf("git rm failed: %w\nOutput: %s", err, string(output)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Commit creates a commit with the given message
|
||||||
|
func (g *GitManager) Commit(ctx context.Context, repoPath, message string) error {
|
||||||
|
// Configure git user if not already configured
|
||||||
|
if err := g.ensureGitConfig(ctx, repoPath); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd := exec.CommandContext(ctx, "git", "commit", "-m", message)
|
||||||
|
cmd.Dir = repoPath
|
||||||
|
|
||||||
|
output, err := cmd.CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
return errors.NewGitOperationError("commit", fmt.Errorf("git commit failed: %w\nOutput: %s", err, string(output)))
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Push pushes changes to the remote repository
|
||||||
|
func (g *GitManager) Push(ctx context.Context, repoPath string) error {
|
||||||
|
// First ensure we have a remote configured
|
||||||
|
_, err := g.GetRemoteURL(ctx, repoPath, "origin")
|
||||||
|
if err != nil {
|
||||||
|
return errors.NewGitOperationError("push", fmt.Errorf("cannot push: %w", err))
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd := exec.CommandContext(ctx, "git", "push", "-u", "origin", "main")
|
||||||
|
cmd.Dir = repoPath
|
||||||
|
|
||||||
|
output, err := cmd.CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
return errors.NewGitOperationError("push", fmt.Errorf("git push failed: %w\nOutput: %s", err, string(output)))
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pull pulls changes from the remote repository
|
||||||
|
func (g *GitManager) Pull(ctx context.Context, repoPath string) error {
|
||||||
|
// First ensure we have a remote configured
|
||||||
|
_, err := g.GetRemoteURL(ctx, repoPath, "origin")
|
||||||
|
if err != nil {
|
||||||
|
return errors.NewGitOperationError("pull", fmt.Errorf("cannot pull: %w", err))
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd := exec.CommandContext(ctx, "git", "pull", "origin", "main")
|
||||||
|
cmd.Dir = repoPath
|
||||||
|
|
||||||
|
output, err := cmd.CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
return errors.NewGitOperationError("pull", fmt.Errorf("git pull failed: %w\nOutput: %s", err, string(output)))
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Status returns the current Git status
|
||||||
|
func (g *GitManager) Status(ctx context.Context, repoPath string) (*models.SyncStatus, error) {
|
||||||
|
// First check if we have a remote configured - this should match old behavior
|
||||||
|
_, err := g.GetRemoteURL(ctx, repoPath, "origin")
|
||||||
|
if err != nil {
|
||||||
|
// If origin doesn't exist, check if we have any remotes at all
|
||||||
|
cmd := exec.CommandContext(ctx, "git", "remote")
|
||||||
|
cmd.Dir = repoPath
|
||||||
|
|
||||||
|
output, err := cmd.Output()
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.NewGitOperationError("list_remotes", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
remotes := strings.Split(strings.TrimSpace(string(output)), "\n")
|
||||||
|
if len(remotes) == 0 || remotes[0] == "" {
|
||||||
|
return nil, errors.NewGitOperationError("status", fmt.Errorf("no remote configured"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get current branch
|
||||||
|
currentBranch, err := g.getCurrentBranch(ctx, repoPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.NewGitOperationError("get_current_branch", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for uncommitted changes
|
||||||
|
dirty, err := g.HasChanges(ctx, repoPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.NewGitOperationError("check_changes", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the remote URL
|
||||||
|
remoteURL, err := g.GetRemoteURL(ctx, repoPath, "origin")
|
||||||
|
hasRemote := err == nil
|
||||||
|
|
||||||
|
// Initialize status with basic information
|
||||||
|
status := &models.SyncStatus{
|
||||||
|
CurrentBranch: currentBranch,
|
||||||
|
Dirty: dirty,
|
||||||
|
HasRemote: hasRemote,
|
||||||
|
RemoteURL: remoteURL,
|
||||||
|
}
|
||||||
|
|
||||||
|
// If no remote, we can't determine ahead/behind counts
|
||||||
|
if !hasRemote {
|
||||||
|
return status, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the remote tracking branch
|
||||||
|
remoteBranch, err := g.getRemoteTrackingBranch(ctx, repoPath)
|
||||||
|
if err != nil {
|
||||||
|
// No upstream branch set, assume origin/main
|
||||||
|
remoteBranch = "origin/main"
|
||||||
|
}
|
||||||
|
status.RemoteBranch = remoteBranch
|
||||||
|
|
||||||
|
// Get ahead/behind counts
|
||||||
|
status.Ahead = g.getAheadCount(ctx, repoPath, remoteBranch)
|
||||||
|
status.Behind = g.getBehindCount(ctx, repoPath, remoteBranch)
|
||||||
|
|
||||||
|
// Get last commit hash
|
||||||
|
lastCommitHash, err := g.getLastCommitHash(ctx, repoPath)
|
||||||
|
if err == nil {
|
||||||
|
status.LastCommitHash = lastCommitHash
|
||||||
|
}
|
||||||
|
|
||||||
|
return status, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsRepository checks if the path is a Git repository
|
||||||
|
func (g *GitManager) IsRepository(ctx context.Context, repoPath string) (bool, error) {
|
||||||
|
gitDir := filepath.Join(repoPath, ".git")
|
||||||
|
_, err := os.Stat(gitDir)
|
||||||
|
if err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
return false, errors.NewFileSystemOperationError("check_git_dir", gitDir, err)
|
||||||
|
}
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// HasChanges checks if there are uncommitted changes
|
||||||
|
func (g *GitManager) HasChanges(ctx context.Context, repoPath string) (bool, error) {
|
||||||
|
cmd := exec.CommandContext(ctx, "git", "status", "--porcelain")
|
||||||
|
cmd.Dir = repoPath
|
||||||
|
|
||||||
|
output, err := cmd.Output()
|
||||||
|
if err != nil {
|
||||||
|
return false, errors.NewGitOperationError("status", fmt.Errorf("git status failed: %w", err))
|
||||||
|
}
|
||||||
|
|
||||||
|
return len(strings.TrimSpace(string(output))) > 0, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddRemote adds a remote to the repository
|
||||||
|
func (g *GitManager) AddRemote(ctx context.Context, repoPath, name, url string) error {
|
||||||
|
// Check if remote already exists
|
||||||
|
existingURL, err := g.GetRemoteURL(ctx, repoPath, name)
|
||||||
|
if err == nil {
|
||||||
|
// Remote exists, check if URL matches
|
||||||
|
if existingURL == url {
|
||||||
|
// Same URL, idempotent - do nothing
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
// Different URL, error
|
||||||
|
return errors.NewGitOperationError("add_remote",
|
||||||
|
fmt.Errorf("remote %s already exists with different URL: %s (trying to add: %s)", name, existingURL, url))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remote doesn't exist, add it
|
||||||
|
cmd := exec.CommandContext(ctx, "git", "remote", "add", name, url)
|
||||||
|
cmd.Dir = repoPath
|
||||||
|
|
||||||
|
output, err := cmd.CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
return errors.NewGitOperationError("add_remote", fmt.Errorf("git remote add failed: %w\nOutput: %s", err, string(output)))
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetRemoteURL returns the URL of a remote
|
||||||
|
func (g *GitManager) GetRemoteURL(ctx context.Context, repoPath, name string) (string, error) {
|
||||||
|
cmd := exec.CommandContext(ctx, "git", "remote", "get-url", name)
|
||||||
|
cmd.Dir = repoPath
|
||||||
|
|
||||||
|
output, err := cmd.Output()
|
||||||
|
if err != nil {
|
||||||
|
return "", errors.NewGitOperationError("get_remote_url", fmt.Errorf("failed to get remote URL for %s: %w", name, err))
|
||||||
|
}
|
||||||
|
|
||||||
|
return strings.TrimSpace(string(output)), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsLnkRepository checks if the repository appears to be managed by lnk
|
||||||
|
func (g *GitManager) IsLnkRepository(ctx context.Context, repoPath string) (bool, error) {
|
||||||
|
isRepo, err := g.IsRepository(ctx, repoPath)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
if !isRepo {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if this looks like a lnk repository
|
||||||
|
// We consider it a lnk repo if:
|
||||||
|
// 1. It has no commits (fresh repo), OR
|
||||||
|
// 2. All commits start with "lnk:" pattern
|
||||||
|
|
||||||
|
commits, err := g.getCommits(ctx, repoPath)
|
||||||
|
if err != nil {
|
||||||
|
return false, errors.NewGitOperationError("get_commits", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// If no commits, it's a fresh repo - could be lnk
|
||||||
|
if len(commits) == 0 {
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// If all commits start with "lnk:", it's definitely ours
|
||||||
|
// If ANY commit doesn't start with "lnk:", it's probably not ours
|
||||||
|
for _, commit := range commits {
|
||||||
|
if !strings.HasPrefix(commit, "lnk:") {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper methods
|
||||||
|
|
||||||
|
// ensureGitConfig configures git user if not already configured
|
||||||
|
func (g *GitManager) ensureGitConfig(ctx context.Context, repoPath string) error {
|
||||||
|
// Check if user.name is configured
|
||||||
|
cmd := exec.CommandContext(ctx, "git", "config", "user.name")
|
||||||
|
cmd.Dir = repoPath
|
||||||
|
if err := cmd.Run(); err != nil {
|
||||||
|
// Set default user.name
|
||||||
|
cmd = exec.CommandContext(ctx, "git", "config", "user.name", "lnk")
|
||||||
|
cmd.Dir = repoPath
|
||||||
|
if err := cmd.Run(); err != nil {
|
||||||
|
return errors.NewGitOperationError("config_user_name", fmt.Errorf("failed to set git user.name: %w", err))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if user.email is configured
|
||||||
|
cmd = exec.CommandContext(ctx, "git", "config", "user.email")
|
||||||
|
cmd.Dir = repoPath
|
||||||
|
if err := cmd.Run(); err != nil {
|
||||||
|
// Set default user.email
|
||||||
|
cmd = exec.CommandContext(ctx, "git", "config", "user.email", "lnk@local")
|
||||||
|
cmd.Dir = repoPath
|
||||||
|
if err := cmd.Run(); err != nil {
|
||||||
|
return errors.NewGitOperationError("config_user_email", fmt.Errorf("failed to set git user.email: %w", err))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// getCurrentBranch returns the current branch name
|
||||||
|
func (g *GitManager) getCurrentBranch(ctx context.Context, repoPath string) (string, error) {
|
||||||
|
cmd := exec.CommandContext(ctx, "git", "rev-parse", "--abbrev-ref", "HEAD")
|
||||||
|
cmd.Dir = repoPath
|
||||||
|
|
||||||
|
output, err := cmd.CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
// For empty repositories, HEAD might not exist yet, default to main
|
||||||
|
errStr := string(output)
|
||||||
|
|
||||||
|
if strings.Contains(errStr, "fatal: ambiguous argument 'HEAD'") ||
|
||||||
|
strings.Contains(errStr, "unknown revision") ||
|
||||||
|
strings.Contains(errStr, "not a valid ref") ||
|
||||||
|
strings.Contains(errStr, "bad revision") {
|
||||||
|
return "main", nil
|
||||||
|
}
|
||||||
|
return "", fmt.Errorf("failed to get current branch: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
branch := strings.TrimSpace(string(output))
|
||||||
|
// If the branch is HEAD (detached state), try to get the default branch
|
||||||
|
if branch == "HEAD" {
|
||||||
|
return "main", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return branch, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// getRemoteTrackingBranch returns the remote tracking branch
|
||||||
|
func (g *GitManager) getRemoteTrackingBranch(ctx context.Context, repoPath string) (string, error) {
|
||||||
|
cmd := exec.CommandContext(ctx, "git", "rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}")
|
||||||
|
cmd.Dir = repoPath
|
||||||
|
|
||||||
|
output, err := cmd.Output()
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("no upstream branch set: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return strings.TrimSpace(string(output)), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// getAheadCount returns how many commits ahead of remote
|
||||||
|
func (g *GitManager) getAheadCount(ctx context.Context, repoPath, remoteBranch string) int {
|
||||||
|
cmd := exec.CommandContext(ctx, "git", "rev-list", "--count", fmt.Sprintf("%s..HEAD", remoteBranch))
|
||||||
|
cmd.Dir = repoPath
|
||||||
|
|
||||||
|
output, err := cmd.Output()
|
||||||
|
if err != nil {
|
||||||
|
// If remote branch doesn't exist, count all local commits
|
||||||
|
cmd = exec.CommandContext(ctx, "git", "rev-list", "--count", "HEAD")
|
||||||
|
cmd.Dir = repoPath
|
||||||
|
|
||||||
|
output, err = cmd.Output()
|
||||||
|
if err != nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
count := strings.TrimSpace(string(output))
|
||||||
|
if count == "" {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert to int
|
||||||
|
var ahead int
|
||||||
|
if _, err := fmt.Sscanf(count, "%d", &ahead); err != nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
return ahead
|
||||||
|
}
|
||||||
|
|
||||||
|
// getBehindCount returns how many commits behind remote
|
||||||
|
func (g *GitManager) getBehindCount(ctx context.Context, repoPath, remoteBranch string) int {
|
||||||
|
cmd := exec.CommandContext(ctx, "git", "rev-list", "--count", fmt.Sprintf("HEAD..%s", remoteBranch))
|
||||||
|
cmd.Dir = repoPath
|
||||||
|
|
||||||
|
output, err := cmd.Output()
|
||||||
|
if err != nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
count := strings.TrimSpace(string(output))
|
||||||
|
if count == "" {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert to int
|
||||||
|
var behind int
|
||||||
|
if _, err := fmt.Sscanf(count, "%d", &behind); err != nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
return behind
|
||||||
|
}
|
||||||
|
|
||||||
|
// getLastCommitHash returns the hash of the last commit
|
||||||
|
func (g *GitManager) getLastCommitHash(ctx context.Context, repoPath string) (string, error) {
|
||||||
|
cmd := exec.CommandContext(ctx, "git", "rev-parse", "HEAD")
|
||||||
|
cmd.Dir = repoPath
|
||||||
|
|
||||||
|
output, err := cmd.Output()
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to get last commit hash: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return strings.TrimSpace(string(output)), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// getCommits returns commit messages
|
||||||
|
func (g *GitManager) getCommits(ctx context.Context, repoPath string) ([]string, error) {
|
||||||
|
cmd := exec.CommandContext(ctx, "git", "log", "--pretty=format:%s")
|
||||||
|
cmd.Dir = repoPath
|
||||||
|
|
||||||
|
output, err := cmd.Output()
|
||||||
|
if err != nil {
|
||||||
|
// If there are no commits, git log will fail
|
||||||
|
// Use CombinedOutput to get both stdout and stderr to check the error message
|
||||||
|
cmd = exec.CommandContext(ctx, "git", "log", "--pretty=format:%s")
|
||||||
|
cmd.Dir = repoPath
|
||||||
|
combinedOutput, _ := cmd.CombinedOutput()
|
||||||
|
errStr := string(combinedOutput)
|
||||||
|
|
||||||
|
if strings.Contains(errStr, "does not have any commits yet") ||
|
||||||
|
strings.Contains(errStr, "bad default revision") ||
|
||||||
|
strings.Contains(errStr, "unknown revision") ||
|
||||||
|
strings.Contains(errStr, "ambiguous argument") {
|
||||||
|
return []string{}, nil
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("failed to get commits: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
outputStr := strings.TrimSpace(string(output))
|
||||||
|
if outputStr == "" {
|
||||||
|
return []string{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
commitMessages := strings.Split(outputStr, "\n")
|
||||||
|
return commitMessages, nil
|
||||||
|
}
|
307
internal/git/gitmanager_test.go
Normal file
307
internal/git/gitmanager_test.go
Normal file
@@ -0,0 +1,307 @@
|
|||||||
|
package git
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/suite"
|
||||||
|
)
|
||||||
|
|
||||||
|
type GitManagerTestSuite struct {
|
||||||
|
suite.Suite
|
||||||
|
tempDir string
|
||||||
|
gitManager *GitManager
|
||||||
|
ctx context.Context
|
||||||
|
}
|
||||||
|
|
||||||
|
func (suite *GitManagerTestSuite) SetupTest() {
|
||||||
|
// Create temp directory for testing
|
||||||
|
tempDir, err := os.MkdirTemp("", "lnk_git_test_*")
|
||||||
|
suite.Require().NoError(err)
|
||||||
|
suite.tempDir = tempDir
|
||||||
|
|
||||||
|
// Create git manager
|
||||||
|
suite.gitManager = New()
|
||||||
|
|
||||||
|
// Create context
|
||||||
|
suite.ctx = context.Background()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (suite *GitManagerTestSuite) TearDownTest() {
|
||||||
|
err := os.RemoveAll(suite.tempDir)
|
||||||
|
suite.Require().NoError(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper function to check if file exists
|
||||||
|
func (suite *GitManagerTestSuite) fileExists(path string) bool {
|
||||||
|
_, err := os.Stat(path)
|
||||||
|
return err == nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (suite *GitManagerTestSuite) TestInit() {
|
||||||
|
repoPath := filepath.Join(suite.tempDir, "test-repo")
|
||||||
|
|
||||||
|
// Create the directory
|
||||||
|
err := os.MkdirAll(repoPath, 0755)
|
||||||
|
suite.Require().NoError(err)
|
||||||
|
|
||||||
|
// Test init
|
||||||
|
err = suite.gitManager.Init(suite.ctx, repoPath)
|
||||||
|
suite.NoError(err)
|
||||||
|
|
||||||
|
// Verify repository was created
|
||||||
|
isRepo, err := suite.gitManager.IsRepository(suite.ctx, repoPath)
|
||||||
|
suite.NoError(err)
|
||||||
|
suite.True(isRepo)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (suite *GitManagerTestSuite) TestAddCommit() {
|
||||||
|
repoPath := filepath.Join(suite.tempDir, "test-repo")
|
||||||
|
|
||||||
|
// Create and initialize repository
|
||||||
|
err := os.MkdirAll(repoPath, 0755)
|
||||||
|
suite.Require().NoError(err)
|
||||||
|
|
||||||
|
err = suite.gitManager.Init(suite.ctx, repoPath)
|
||||||
|
suite.Require().NoError(err)
|
||||||
|
|
||||||
|
// Create a test file
|
||||||
|
testFile := filepath.Join(repoPath, "test.txt")
|
||||||
|
err = os.WriteFile(testFile, []byte("test content"), 0644)
|
||||||
|
suite.Require().NoError(err)
|
||||||
|
|
||||||
|
// Test adding file
|
||||||
|
err = suite.gitManager.Add(suite.ctx, repoPath, "test.txt")
|
||||||
|
suite.NoError(err)
|
||||||
|
|
||||||
|
// Test commit
|
||||||
|
err = suite.gitManager.Commit(suite.ctx, repoPath, "lnk: test commit")
|
||||||
|
suite.NoError(err)
|
||||||
|
|
||||||
|
// Verify no uncommitted changes
|
||||||
|
hasChanges, err := suite.gitManager.HasChanges(suite.ctx, repoPath)
|
||||||
|
suite.NoError(err)
|
||||||
|
suite.False(hasChanges)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (suite *GitManagerTestSuite) TestStatus() {
|
||||||
|
repoPath := filepath.Join(suite.tempDir, "test-repo")
|
||||||
|
|
||||||
|
// Create and initialize repository
|
||||||
|
err := os.MkdirAll(repoPath, 0755)
|
||||||
|
suite.Require().NoError(err)
|
||||||
|
|
||||||
|
err = suite.gitManager.Init(suite.ctx, repoPath)
|
||||||
|
suite.Require().NoError(err)
|
||||||
|
|
||||||
|
// Test status on empty repository should fail with no remote configured
|
||||||
|
_, err = suite.gitManager.Status(suite.ctx, repoPath)
|
||||||
|
suite.Error(err)
|
||||||
|
suite.Contains(err.Error(), "no remote configured")
|
||||||
|
|
||||||
|
// Add a remote to make status work
|
||||||
|
testURL := "https://github.com/test/repo.git"
|
||||||
|
err = suite.gitManager.AddRemote(suite.ctx, repoPath, "origin", testURL)
|
||||||
|
suite.Require().NoError(err)
|
||||||
|
|
||||||
|
// Test status with remote configured but no commits
|
||||||
|
status, err := suite.gitManager.Status(suite.ctx, repoPath)
|
||||||
|
suite.NoError(err)
|
||||||
|
|
||||||
|
suite.Equal("main", status.CurrentBranch)
|
||||||
|
suite.False(status.Dirty)
|
||||||
|
suite.True(status.HasRemote)
|
||||||
|
|
||||||
|
// Create and commit a file
|
||||||
|
testFile := filepath.Join(repoPath, "test.txt")
|
||||||
|
err = os.WriteFile(testFile, []byte("test content"), 0644)
|
||||||
|
suite.Require().NoError(err)
|
||||||
|
|
||||||
|
// Test dirty status
|
||||||
|
status, err = suite.gitManager.Status(suite.ctx, repoPath)
|
||||||
|
suite.NoError(err)
|
||||||
|
suite.True(status.Dirty)
|
||||||
|
|
||||||
|
// Add and commit
|
||||||
|
err = suite.gitManager.Add(suite.ctx, repoPath, "test.txt")
|
||||||
|
suite.Require().NoError(err)
|
||||||
|
|
||||||
|
err = suite.gitManager.Commit(suite.ctx, repoPath, "lnk: test commit")
|
||||||
|
suite.Require().NoError(err)
|
||||||
|
|
||||||
|
// Test clean status
|
||||||
|
status, err = suite.gitManager.Status(suite.ctx, repoPath)
|
||||||
|
suite.NoError(err)
|
||||||
|
suite.False(status.Dirty)
|
||||||
|
suite.NotEmpty(status.LastCommitHash)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (suite *GitManagerTestSuite) TestRemoteOperations() {
|
||||||
|
repoPath := filepath.Join(suite.tempDir, "test-repo")
|
||||||
|
|
||||||
|
// Create and initialize repository
|
||||||
|
err := os.MkdirAll(repoPath, 0755)
|
||||||
|
suite.Require().NoError(err)
|
||||||
|
|
||||||
|
err = suite.gitManager.Init(suite.ctx, repoPath)
|
||||||
|
suite.Require().NoError(err)
|
||||||
|
|
||||||
|
// Test adding remote
|
||||||
|
testURL := "https://github.com/test/repo.git"
|
||||||
|
err = suite.gitManager.AddRemote(suite.ctx, repoPath, "origin", testURL)
|
||||||
|
suite.NoError(err)
|
||||||
|
|
||||||
|
// Test getting remote URL
|
||||||
|
remoteURL, err := suite.gitManager.GetRemoteURL(suite.ctx, repoPath, "origin")
|
||||||
|
suite.NoError(err)
|
||||||
|
suite.Equal(testURL, remoteURL)
|
||||||
|
|
||||||
|
// Test idempotent add (same URL)
|
||||||
|
err = suite.gitManager.AddRemote(suite.ctx, repoPath, "origin", testURL)
|
||||||
|
suite.NoError(err)
|
||||||
|
|
||||||
|
// Test adding remote with different URL should fail
|
||||||
|
err = suite.gitManager.AddRemote(suite.ctx, repoPath, "origin", "https://github.com/different/repo.git")
|
||||||
|
suite.Error(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (suite *GitManagerTestSuite) TestIsLnkRepository() {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
setup func(string) error
|
||||||
|
expected bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "not_a_repository",
|
||||||
|
setup: func(path string) error {
|
||||||
|
return os.MkdirAll(path, 0755)
|
||||||
|
},
|
||||||
|
expected: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "empty_git_repository",
|
||||||
|
setup: func(path string) error {
|
||||||
|
if err := os.MkdirAll(path, 0755); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return suite.gitManager.Init(suite.ctx, path)
|
||||||
|
},
|
||||||
|
expected: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "repository_with_lnk_commits",
|
||||||
|
setup: func(path string) error {
|
||||||
|
if err := os.MkdirAll(path, 0755); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := suite.gitManager.Init(suite.ctx, path); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create and commit a file with lnk prefix
|
||||||
|
testFile := filepath.Join(path, "test.txt")
|
||||||
|
if err := os.WriteFile(testFile, []byte("test"), 0644); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := suite.gitManager.Add(suite.ctx, path, "test.txt"); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return suite.gitManager.Commit(suite.ctx, path, "lnk: add test file")
|
||||||
|
},
|
||||||
|
expected: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "repository_with_non-lnk_commits",
|
||||||
|
setup: func(path string) error {
|
||||||
|
if err := os.MkdirAll(path, 0755); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := suite.gitManager.Init(suite.ctx, path); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create and commit a file without lnk prefix
|
||||||
|
testFile := filepath.Join(path, "test.txt")
|
||||||
|
if err := os.WriteFile(testFile, []byte("test"), 0644); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := suite.gitManager.Add(suite.ctx, path, "test.txt"); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return suite.gitManager.Commit(suite.ctx, path, "regular commit")
|
||||||
|
},
|
||||||
|
expected: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
suite.Run(tt.name, func() {
|
||||||
|
repoPath := filepath.Join(suite.tempDir, tt.name)
|
||||||
|
err := tt.setup(repoPath)
|
||||||
|
suite.Require().NoError(err)
|
||||||
|
|
||||||
|
isLnk, err := suite.gitManager.IsLnkRepository(suite.ctx, repoPath)
|
||||||
|
suite.NoError(err)
|
||||||
|
suite.Equal(tt.expected, isLnk)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (suite *GitManagerTestSuite) TestContextCancellation() {
|
||||||
|
repoPath := filepath.Join(suite.tempDir, "test-repo")
|
||||||
|
|
||||||
|
err := os.MkdirAll(repoPath, 0755)
|
||||||
|
suite.Require().NoError(err)
|
||||||
|
|
||||||
|
// Test context cancellation
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 1*time.Nanosecond)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
// This should fail due to context timeout
|
||||||
|
err = suite.gitManager.Init(ctx, repoPath)
|
||||||
|
suite.Error(err)
|
||||||
|
|
||||||
|
// Verify the error is context-related
|
||||||
|
suite.NotNil(ctx.Err())
|
||||||
|
}
|
||||||
|
|
||||||
|
func (suite *GitManagerTestSuite) TestRemove() {
|
||||||
|
repoPath := filepath.Join(suite.tempDir, "test-repo")
|
||||||
|
|
||||||
|
// Create and initialize repository
|
||||||
|
err := os.MkdirAll(repoPath, 0755)
|
||||||
|
suite.Require().NoError(err)
|
||||||
|
|
||||||
|
err = suite.gitManager.Init(suite.ctx, repoPath)
|
||||||
|
suite.Require().NoError(err)
|
||||||
|
|
||||||
|
// Create and add files
|
||||||
|
testFile := filepath.Join(repoPath, "test.txt")
|
||||||
|
err = os.WriteFile(testFile, []byte("test content"), 0644)
|
||||||
|
suite.Require().NoError(err)
|
||||||
|
|
||||||
|
err = suite.gitManager.Add(suite.ctx, repoPath, "test.txt")
|
||||||
|
suite.Require().NoError(err)
|
||||||
|
|
||||||
|
err = suite.gitManager.Commit(suite.ctx, repoPath, "lnk: add test file")
|
||||||
|
suite.Require().NoError(err)
|
||||||
|
|
||||||
|
// Test removing file
|
||||||
|
err = suite.gitManager.Remove(suite.ctx, repoPath, "test.txt")
|
||||||
|
suite.NoError(err)
|
||||||
|
|
||||||
|
// Verify file is removed from git but still exists on fs
|
||||||
|
suite.True(suite.fileExists(testFile))
|
||||||
|
|
||||||
|
// Verify repository has changes
|
||||||
|
hasChanges, err := suite.gitManager.HasChanges(suite.ctx, repoPath)
|
||||||
|
suite.NoError(err)
|
||||||
|
suite.True(hasChanges)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGitManagerSuite(t *testing.T) {
|
||||||
|
suite.Run(t, new(GitManagerTestSuite))
|
||||||
|
}
|
108
internal/models/models.go
Normal file
108
internal/models/models.go
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
package models
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ManagedFile represents a file or directory managed by lnk
|
||||||
|
type ManagedFile struct {
|
||||||
|
// ID for potential future database use
|
||||||
|
ID string `json:"id,omitempty"`
|
||||||
|
|
||||||
|
// OriginalPath is the original absolute path where the file was located
|
||||||
|
OriginalPath string `json:"original_path"`
|
||||||
|
|
||||||
|
// RepoPath is the path within the lnk repository
|
||||||
|
RepoPath string `json:"repo_path"`
|
||||||
|
|
||||||
|
// RelativePath is the path relative to the home directory (or absolute for files outside home)
|
||||||
|
RelativePath string `json:"relative_path"`
|
||||||
|
|
||||||
|
// Host is the hostname where this file is managed
|
||||||
|
Host string `json:"host"`
|
||||||
|
|
||||||
|
// IsDirectory indicates whether this is a directory
|
||||||
|
IsDirectory bool `json:"is_directory"`
|
||||||
|
|
||||||
|
// SymlinkTarget is the current symlink target (if the original location is now a symlink)
|
||||||
|
SymlinkTarget string `json:"symlink_target,omitempty"`
|
||||||
|
|
||||||
|
// AddedAt is when the file was first added to lnk
|
||||||
|
AddedAt time.Time `json:"added_at,omitempty"`
|
||||||
|
|
||||||
|
// UpdatedAt is when the file was last updated
|
||||||
|
UpdatedAt time.Time `json:"updated_at,omitempty"`
|
||||||
|
|
||||||
|
// Mode stores the file permissions
|
||||||
|
Mode os.FileMode `json:"mode,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// RepositoryConfig represents the lnk repository settings
|
||||||
|
type RepositoryConfig struct {
|
||||||
|
// Path is the absolute path to the lnk repository
|
||||||
|
Path string `json:"path"`
|
||||||
|
|
||||||
|
// DefaultRemote is the default Git remote for sync operations
|
||||||
|
DefaultRemote string `json:"default_remote,omitempty"`
|
||||||
|
|
||||||
|
// Created is when the repository was created
|
||||||
|
Created time.Time `json:"created,omitempty"`
|
||||||
|
|
||||||
|
// LastSync is when the repository was last synced
|
||||||
|
LastSync time.Time `json:"last_sync,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// HostConfig represents configuration specific to a host
|
||||||
|
type HostConfig struct {
|
||||||
|
// Name is the hostname
|
||||||
|
Name string `json:"name"`
|
||||||
|
|
||||||
|
// ManagedFiles is the list of files managed on this host
|
||||||
|
ManagedFiles []ManagedFile `json:"managed_files"`
|
||||||
|
|
||||||
|
// LastUpdate is when this host configuration was last updated
|
||||||
|
LastUpdate time.Time `json:"last_update,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// SyncStatus represents Git repository sync status
|
||||||
|
type SyncStatus struct {
|
||||||
|
// Ahead is the number of commits ahead of remote
|
||||||
|
Ahead int `json:"ahead"`
|
||||||
|
|
||||||
|
// Behind is the number of commits behind remote
|
||||||
|
Behind int `json:"behind"`
|
||||||
|
|
||||||
|
// CurrentBranch is the currently checked out branch
|
||||||
|
CurrentBranch string `json:"current_branch"`
|
||||||
|
|
||||||
|
// RemoteBranch is the remote tracking branch
|
||||||
|
RemoteBranch string `json:"remote_branch"`
|
||||||
|
|
||||||
|
// RemoteURL is the URL of the remote repository
|
||||||
|
RemoteURL string `json:"remote_url"`
|
||||||
|
|
||||||
|
// Dirty indicates if there are uncommitted changes
|
||||||
|
Dirty bool `json:"dirty"`
|
||||||
|
|
||||||
|
// LastCommitHash is the hash of the last commit
|
||||||
|
LastCommitHash string `json:"last_commit_hash"`
|
||||||
|
|
||||||
|
// HasRemote indicates if a remote is configured
|
||||||
|
HasRemote bool `json:"has_remote"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsClean returns true if the repository is clean (no uncommitted changes)
|
||||||
|
func (s *SyncStatus) IsClean() bool {
|
||||||
|
return !s.Dirty
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsSynced returns true if the repository is in sync with remote (ahead=0, behind=0)
|
||||||
|
func (s *SyncStatus) IsSynced() bool {
|
||||||
|
return s.Ahead == 0 && s.Behind == 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// NeedsSync returns true if the repository needs to be synced with remote
|
||||||
|
func (s *SyncStatus) NeedsSync() bool {
|
||||||
|
return s.Ahead > 0 || s.Behind > 0
|
||||||
|
}
|
185
internal/models/models_test.go
Normal file
185
internal/models/models_test.go
Normal file
@@ -0,0 +1,185 @@
|
|||||||
|
package models
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/suite"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ModelsTestSuite struct {
|
||||||
|
suite.Suite
|
||||||
|
}
|
||||||
|
|
||||||
|
func (suite *ModelsTestSuite) TestManagedFile() {
|
||||||
|
now := time.Now()
|
||||||
|
|
||||||
|
file := ManagedFile{
|
||||||
|
ID: "test-id",
|
||||||
|
OriginalPath: "/home/user/.vimrc",
|
||||||
|
RepoPath: "/home/user/.config/lnk/.vimrc",
|
||||||
|
RelativePath: ".vimrc",
|
||||||
|
Host: "workstation",
|
||||||
|
IsDirectory: false,
|
||||||
|
AddedAt: now,
|
||||||
|
UpdatedAt: now,
|
||||||
|
}
|
||||||
|
|
||||||
|
suite.Equal("test-id", file.ID)
|
||||||
|
suite.Equal("/home/user/.vimrc", file.OriginalPath)
|
||||||
|
suite.Equal("workstation", file.Host)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (suite *ModelsTestSuite) TestRepositoryConfig() {
|
||||||
|
now := time.Now()
|
||||||
|
|
||||||
|
config := RepositoryConfig{
|
||||||
|
Path: "/home/user/.config/lnk",
|
||||||
|
DefaultRemote: "origin",
|
||||||
|
Created: now,
|
||||||
|
LastSync: now,
|
||||||
|
}
|
||||||
|
|
||||||
|
suite.Equal("/home/user/.config/lnk", config.Path)
|
||||||
|
suite.Equal("origin", config.DefaultRemote)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (suite *ModelsTestSuite) TestHostConfig() {
|
||||||
|
now := time.Now()
|
||||||
|
|
||||||
|
managedFile := ManagedFile{
|
||||||
|
RelativePath: ".vimrc",
|
||||||
|
Host: "workstation",
|
||||||
|
}
|
||||||
|
|
||||||
|
config := HostConfig{
|
||||||
|
Name: "workstation",
|
||||||
|
ManagedFiles: []ManagedFile{managedFile},
|
||||||
|
LastUpdate: now,
|
||||||
|
}
|
||||||
|
|
||||||
|
suite.Equal("workstation", config.Name)
|
||||||
|
suite.Len(config.ManagedFiles, 1)
|
||||||
|
suite.Equal(".vimrc", config.ManagedFiles[0].RelativePath)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (suite *ModelsTestSuite) TestSyncStatusIsClean() {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
dirty bool
|
||||||
|
expected bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "clean_repository",
|
||||||
|
dirty: false,
|
||||||
|
expected: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "dirty_repository",
|
||||||
|
dirty: true,
|
||||||
|
expected: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
suite.Run(tt.name, func() {
|
||||||
|
status := SyncStatus{Dirty: tt.dirty}
|
||||||
|
result := status.IsClean()
|
||||||
|
suite.Equal(tt.expected, result)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (suite *ModelsTestSuite) TestSyncStatusIsSynced() {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
ahead int
|
||||||
|
behind int
|
||||||
|
expected bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "fully_synced",
|
||||||
|
ahead: 0,
|
||||||
|
behind: 0,
|
||||||
|
expected: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "ahead_of_remote",
|
||||||
|
ahead: 2,
|
||||||
|
behind: 0,
|
||||||
|
expected: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "behind_remote",
|
||||||
|
ahead: 0,
|
||||||
|
behind: 3,
|
||||||
|
expected: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "diverged",
|
||||||
|
ahead: 1,
|
||||||
|
behind: 2,
|
||||||
|
expected: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
suite.Run(tt.name, func() {
|
||||||
|
status := SyncStatus{
|
||||||
|
Ahead: tt.ahead,
|
||||||
|
Behind: tt.behind,
|
||||||
|
}
|
||||||
|
result := status.IsSynced()
|
||||||
|
suite.Equal(tt.expected, result)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (suite *ModelsTestSuite) TestSyncStatusNeedsSync() {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
ahead int
|
||||||
|
behind int
|
||||||
|
expected bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "fully_synced",
|
||||||
|
ahead: 0,
|
||||||
|
behind: 0,
|
||||||
|
expected: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "ahead_of_remote",
|
||||||
|
ahead: 2,
|
||||||
|
behind: 0,
|
||||||
|
expected: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "behind_remote",
|
||||||
|
ahead: 0,
|
||||||
|
behind: 3,
|
||||||
|
expected: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "diverged",
|
||||||
|
ahead: 1,
|
||||||
|
behind: 2,
|
||||||
|
expected: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
suite.Run(tt.name, func() {
|
||||||
|
status := SyncStatus{
|
||||||
|
Ahead: tt.ahead,
|
||||||
|
Behind: tt.behind,
|
||||||
|
}
|
||||||
|
result := status.NeedsSync()
|
||||||
|
suite.Equal(tt.expected, result)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestModelsSuite(t *testing.T) {
|
||||||
|
suite.Run(t, new(ModelsTestSuite))
|
||||||
|
}
|
153
internal/pathresolver/resolver.go
Normal file
153
internal/pathresolver/resolver.go
Normal file
@@ -0,0 +1,153 @@
|
|||||||
|
package pathresolver
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Resolver implements the models.PathResolver interface
|
||||||
|
type Resolver struct{}
|
||||||
|
|
||||||
|
// New creates a new PathResolver instance
|
||||||
|
func New() *Resolver {
|
||||||
|
return &Resolver{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetRepoStoragePath returns the base path where lnk repositories are stored
|
||||||
|
// This is based on XDG Base Directory specification
|
||||||
|
func (r *Resolver) GetRepoStoragePath() (string, error) {
|
||||||
|
xdgConfig := os.Getenv("XDG_CONFIG_HOME")
|
||||||
|
if xdgConfig == "" {
|
||||||
|
homeDir, err := os.UserHomeDir()
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to get home directory: %w", err)
|
||||||
|
}
|
||||||
|
xdgConfig = filepath.Join(homeDir, ".config")
|
||||||
|
}
|
||||||
|
return filepath.Join(xdgConfig, "lnk"), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetFileStoragePathInRepo returns the path where a file should be stored in the repository
|
||||||
|
func (r *Resolver) GetFileStoragePathInRepo(repoPath, host, relativePath string) (string, error) {
|
||||||
|
hostPath, err := r.GetHostStoragePath(repoPath, host)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return filepath.Join(hostPath, relativePath), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetTrackingFilePath returns the path to the tracking file for a host
|
||||||
|
func (r *Resolver) GetTrackingFilePath(repoPath, host string) (string, error) {
|
||||||
|
var fileName string
|
||||||
|
if host == "" {
|
||||||
|
// Common configuration
|
||||||
|
fileName = ".lnk"
|
||||||
|
} else {
|
||||||
|
// Host-specific configuration
|
||||||
|
fileName = ".lnk." + host
|
||||||
|
}
|
||||||
|
return filepath.Join(repoPath, fileName), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetHomePath returns the user's home directory path
|
||||||
|
func (r *Resolver) GetHomePath() (string, error) {
|
||||||
|
homeDir, err := os.UserHomeDir()
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to get home directory: %w", err)
|
||||||
|
}
|
||||||
|
return homeDir, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetRelativePathFromHome converts an absolute path to relative from home directory
|
||||||
|
// This is migrated from the original getRelativePath function
|
||||||
|
func (r *Resolver) GetRelativePathFromHome(absPath string) (string, error) {
|
||||||
|
homeDir, err := r.GetHomePath()
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if the file is under home directory
|
||||||
|
relPath, err := filepath.Rel(homeDir, absPath)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to get relative path: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// If the relative path starts with "..", the file is outside home directory
|
||||||
|
// In this case, use the absolute path as relative (without the leading slash)
|
||||||
|
if strings.HasPrefix(relPath, "..") {
|
||||||
|
// Use absolute path but remove leading slash and drive letter (for cross-platform)
|
||||||
|
cleanPath := strings.TrimPrefix(absPath, "/")
|
||||||
|
return cleanPath, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return relPath, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetAbsolutePathInHome converts a relative path to absolute within home directory
|
||||||
|
func (r *Resolver) GetAbsolutePathInHome(relPath string) (string, error) {
|
||||||
|
homeDir, err := r.GetHomePath()
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
// If the relative path looks like an absolute path (starts with / or drive letter),
|
||||||
|
// it's probably a file outside home directory
|
||||||
|
if filepath.IsAbs(relPath) {
|
||||||
|
return relPath, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// If it starts with a drive letter on Windows or looks like an absolute path,
|
||||||
|
// treat it as absolute
|
||||||
|
if len(relPath) > 0 && !strings.HasPrefix(relPath, ".") {
|
||||||
|
// Check if it looks like an absolute path stored without leading slash
|
||||||
|
// This handles paths like "etc/hosts" which should become "/etc/hosts"
|
||||||
|
if strings.HasPrefix(relPath, "etc/") ||
|
||||||
|
strings.HasPrefix(relPath, "usr/") ||
|
||||||
|
strings.HasPrefix(relPath, "var/") ||
|
||||||
|
strings.HasPrefix(relPath, "opt/") ||
|
||||||
|
strings.HasPrefix(relPath, "tmp/") {
|
||||||
|
// Reconstruct the absolute path
|
||||||
|
return "/" + relPath, nil
|
||||||
|
}
|
||||||
|
// Windows drive patterns like "C:" or contains drive separator
|
||||||
|
if strings.Contains(relPath, ":") {
|
||||||
|
return relPath, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return filepath.Join(homeDir, relPath), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetHostStoragePath returns the directory where files for a host are stored
|
||||||
|
// This is migrated from the original getHostStoragePath method
|
||||||
|
func (r *Resolver) GetHostStoragePath(repoPath, host string) (string, error) {
|
||||||
|
if host == "" {
|
||||||
|
// Common configuration - store in root of repo
|
||||||
|
return repoPath, nil
|
||||||
|
}
|
||||||
|
// Host-specific configuration - store in host subdirectory
|
||||||
|
return filepath.Join(repoPath, host+".lnk"), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsUnderHome checks if a path is under the home directory
|
||||||
|
func (r *Resolver) IsUnderHome(path string) (bool, error) {
|
||||||
|
homeDir, err := r.GetHomePath()
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean both paths to handle relative components like .. and .
|
||||||
|
cleanPath := filepath.Clean(path)
|
||||||
|
cleanHome := filepath.Clean(homeDir)
|
||||||
|
|
||||||
|
// Get relative path
|
||||||
|
relPath, err := filepath.Rel(cleanHome, cleanPath)
|
||||||
|
if err != nil {
|
||||||
|
return false, nil // If we can't get relative path, assume not under home
|
||||||
|
}
|
||||||
|
|
||||||
|
// If relative path starts with "..", it's outside home directory
|
||||||
|
return !strings.HasPrefix(relPath, ".."), nil
|
||||||
|
}
|
250
internal/pathresolver/resolver_test.go
Normal file
250
internal/pathresolver/resolver_test.go
Normal file
@@ -0,0 +1,250 @@
|
|||||||
|
package pathresolver
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/suite"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ResolverTestSuite struct {
|
||||||
|
suite.Suite
|
||||||
|
resolver *Resolver
|
||||||
|
}
|
||||||
|
|
||||||
|
func (suite *ResolverTestSuite) SetupTest() {
|
||||||
|
suite.resolver = New()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (suite *ResolverTestSuite) TestGetRepoStoragePath() {
|
||||||
|
// Test with XDG_CONFIG_HOME set
|
||||||
|
originalXDG := os.Getenv("XDG_CONFIG_HOME")
|
||||||
|
defer os.Setenv("XDG_CONFIG_HOME", originalXDG)
|
||||||
|
|
||||||
|
suite.Run("with_XDG_CONFIG_HOME_set", func() {
|
||||||
|
testXDG := "/test/config"
|
||||||
|
os.Setenv("XDG_CONFIG_HOME", testXDG)
|
||||||
|
|
||||||
|
path, err := suite.resolver.GetRepoStoragePath()
|
||||||
|
suite.NoError(err)
|
||||||
|
|
||||||
|
expected := filepath.Join(testXDG, "lnk")
|
||||||
|
suite.Equal(expected, path)
|
||||||
|
})
|
||||||
|
|
||||||
|
suite.Run("without_XDG_CONFIG_HOME", func() {
|
||||||
|
os.Unsetenv("XDG_CONFIG_HOME")
|
||||||
|
|
||||||
|
path, err := suite.resolver.GetRepoStoragePath()
|
||||||
|
suite.NoError(err)
|
||||||
|
|
||||||
|
homeDir, _ := os.UserHomeDir()
|
||||||
|
expected := filepath.Join(homeDir, ".config", "lnk")
|
||||||
|
suite.Equal(expected, path)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (suite *ResolverTestSuite) TestGetTrackingFilePath() {
|
||||||
|
repoPath := "/test/repo"
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
host string
|
||||||
|
expected string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "common_config",
|
||||||
|
host: "",
|
||||||
|
expected: filepath.Join(repoPath, ".lnk"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "host-specific_config",
|
||||||
|
host: "myhost",
|
||||||
|
expected: filepath.Join(repoPath, ".lnk.myhost"),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
suite.Run(tt.name, func() {
|
||||||
|
path, err := suite.resolver.GetTrackingFilePath(repoPath, tt.host)
|
||||||
|
suite.NoError(err)
|
||||||
|
suite.Equal(tt.expected, path)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (suite *ResolverTestSuite) TestGetHostStoragePath() {
|
||||||
|
repoPath := "/test/repo"
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
host string
|
||||||
|
expected string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "common_config",
|
||||||
|
host: "",
|
||||||
|
expected: repoPath,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "host-specific_config",
|
||||||
|
host: "myhost",
|
||||||
|
expected: filepath.Join(repoPath, "myhost.lnk"),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
suite.Run(tt.name, func() {
|
||||||
|
path, err := suite.resolver.GetHostStoragePath(repoPath, tt.host)
|
||||||
|
suite.NoError(err)
|
||||||
|
suite.Equal(tt.expected, path)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (suite *ResolverTestSuite) TestGetRelativePathFromHome() {
|
||||||
|
homeDir, err := os.UserHomeDir()
|
||||||
|
suite.Require().NoError(err)
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
absPath string
|
||||||
|
expected string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "file_in_home",
|
||||||
|
absPath: filepath.Join(homeDir, "Documents", "test.txt"),
|
||||||
|
expected: filepath.Join("Documents", "test.txt"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "file_outside_home",
|
||||||
|
absPath: "/etc/hosts",
|
||||||
|
expected: "etc/hosts",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "home_directory_itself",
|
||||||
|
absPath: homeDir,
|
||||||
|
expected: ".",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
suite.Run(tt.name, func() {
|
||||||
|
result, err := suite.resolver.GetRelativePathFromHome(tt.absPath)
|
||||||
|
suite.NoError(err)
|
||||||
|
suite.Equal(tt.expected, result)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (suite *ResolverTestSuite) TestGetAbsolutePathInHome() {
|
||||||
|
homeDir, err := os.UserHomeDir()
|
||||||
|
suite.Require().NoError(err)
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
relPath string
|
||||||
|
expected string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "relative_path_in_home",
|
||||||
|
relPath: filepath.Join("Documents", "test.txt"),
|
||||||
|
expected: filepath.Join(homeDir, "Documents", "test.txt"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "already_absolute_path",
|
||||||
|
relPath: "/etc/hosts",
|
||||||
|
expected: "/etc/hosts",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "absolute-like_path_without_leading_slash",
|
||||||
|
relPath: "etc/hosts",
|
||||||
|
expected: "/etc/hosts",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
suite.Run(tt.name, func() {
|
||||||
|
result, err := suite.resolver.GetAbsolutePathInHome(tt.relPath)
|
||||||
|
suite.NoError(err)
|
||||||
|
suite.Equal(tt.expected, result)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (suite *ResolverTestSuite) TestIsUnderHome() {
|
||||||
|
homeDir, err := os.UserHomeDir()
|
||||||
|
suite.Require().NoError(err)
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
path string
|
||||||
|
expected bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "file_in_home",
|
||||||
|
path: filepath.Join(homeDir, "Documents", "test.txt"),
|
||||||
|
expected: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "file_outside_home",
|
||||||
|
path: "/etc/hosts",
|
||||||
|
expected: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "home_directory_itself",
|
||||||
|
path: homeDir,
|
||||||
|
expected: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "parent_of_home",
|
||||||
|
path: filepath.Dir(homeDir),
|
||||||
|
expected: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
suite.Run(tt.name, func() {
|
||||||
|
result, err := suite.resolver.IsUnderHome(tt.path)
|
||||||
|
suite.NoError(err)
|
||||||
|
suite.Equal(tt.expected, result)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (suite *ResolverTestSuite) TestGetFileStoragePathInRepo() {
|
||||||
|
repoPath := "/test/repo"
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
host string
|
||||||
|
relativePath string
|
||||||
|
expected string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "common_config_file",
|
||||||
|
host: "",
|
||||||
|
relativePath: "Documents/test.txt",
|
||||||
|
expected: filepath.Join(repoPath, "Documents", "test.txt"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "host-specific_file",
|
||||||
|
host: "myhost",
|
||||||
|
relativePath: "Documents/test.txt",
|
||||||
|
expected: filepath.Join(repoPath, "myhost.lnk", "Documents", "test.txt"),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
suite.Run(tt.name, func() {
|
||||||
|
result, err := suite.resolver.GetFileStoragePathInRepo(repoPath, tt.host, tt.relativePath)
|
||||||
|
suite.NoError(err)
|
||||||
|
suite.Equal(tt.expected, result)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolverSuite(t *testing.T) {
|
||||||
|
suite.Run(t, new(ResolverTestSuite))
|
||||||
|
}
|
823
internal/service/service.go
Normal file
823
internal/service/service.go
Normal file
@@ -0,0 +1,823 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/yarlson/lnk/internal/config"
|
||||||
|
"github.com/yarlson/lnk/internal/errors"
|
||||||
|
"github.com/yarlson/lnk/internal/fs"
|
||||||
|
"github.com/yarlson/lnk/internal/git"
|
||||||
|
"github.com/yarlson/lnk/internal/models"
|
||||||
|
"github.com/yarlson/lnk/internal/pathresolver"
|
||||||
|
)
|
||||||
|
|
||||||
|
// FileManager handles file system operations
|
||||||
|
type FileManager interface {
|
||||||
|
Exists(ctx context.Context, path string) (bool, error)
|
||||||
|
Move(ctx context.Context, src, dst string) error
|
||||||
|
CreateSymlink(ctx context.Context, target, linkPath string) error
|
||||||
|
Remove(ctx context.Context, path string) error
|
||||||
|
MkdirAll(ctx context.Context, path string, perm os.FileMode) error
|
||||||
|
Readlink(ctx context.Context, path string) (string, error)
|
||||||
|
Lstat(ctx context.Context, path string) (os.FileInfo, error)
|
||||||
|
Stat(ctx context.Context, path string) (os.FileInfo, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ConfigManager handles configuration persistence (reading and writing .lnk files)
|
||||||
|
type ConfigManager interface {
|
||||||
|
AddManagedFileToHost(ctx context.Context, repoPath, host string, file models.ManagedFile) error
|
||||||
|
RemoveManagedFileFromHost(ctx context.Context, repoPath, host, relativePath string) error
|
||||||
|
ListManagedFiles(ctx context.Context, repoPath, host string) ([]models.ManagedFile, error)
|
||||||
|
GetManagedFile(ctx context.Context, repoPath, host, relativePath string) (*models.ManagedFile, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GitManager handles Git operations
|
||||||
|
type GitManager interface {
|
||||||
|
Init(ctx context.Context, repoPath string) error
|
||||||
|
Clone(ctx context.Context, repoPath, url string) error
|
||||||
|
Add(ctx context.Context, repoPath string, files ...string) error
|
||||||
|
Remove(ctx context.Context, repoPath string, files ...string) error
|
||||||
|
Commit(ctx context.Context, repoPath, message string) error
|
||||||
|
Push(ctx context.Context, repoPath string) error
|
||||||
|
Pull(ctx context.Context, repoPath string) error
|
||||||
|
Status(ctx context.Context, repoPath string) (*models.SyncStatus, error)
|
||||||
|
IsRepository(ctx context.Context, repoPath string) (bool, error)
|
||||||
|
HasChanges(ctx context.Context, repoPath string) (bool, error)
|
||||||
|
IsLnkRepository(ctx context.Context, repoPath string) (bool, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// PathResolver handles path resolution and manipulation
|
||||||
|
type PathResolver interface {
|
||||||
|
GetFileStoragePathInRepo(repoPath, host, relativePath string) (string, error)
|
||||||
|
GetTrackingFilePath(repoPath, host string) (string, error)
|
||||||
|
GetHomePath() (string, error)
|
||||||
|
GetRelativePathFromHome(absPath string) (string, error)
|
||||||
|
GetAbsolutePathInHome(relPath string) (string, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Service encapsulates the business logic for lnk operations
|
||||||
|
type Service struct {
|
||||||
|
fileManager FileManager
|
||||||
|
gitManager GitManager // May be nil for some operations
|
||||||
|
configManager ConfigManager
|
||||||
|
pathResolver PathResolver
|
||||||
|
repoPath string
|
||||||
|
}
|
||||||
|
|
||||||
|
// New creates a new Service instance with default dependencies
|
||||||
|
func New() (*Service, error) {
|
||||||
|
// Initialize adapters
|
||||||
|
fileManager := fs.New()
|
||||||
|
gitManager := git.New()
|
||||||
|
pathResolver := pathresolver.New()
|
||||||
|
configManager := config.New(fileManager, pathResolver)
|
||||||
|
|
||||||
|
// Get repository path
|
||||||
|
repoPath, err := pathResolver.GetRepoStoragePath()
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.NewInvalidPathError("", "failed to determine repository storage path").
|
||||||
|
WithContext("error", err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
return &Service{
|
||||||
|
fileManager: fileManager,
|
||||||
|
gitManager: gitManager,
|
||||||
|
configManager: configManager,
|
||||||
|
pathResolver: pathResolver,
|
||||||
|
repoPath: repoPath,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewLnkServiceWithDeps creates a new Service instance with provided dependencies (for testing)
|
||||||
|
func NewLnkServiceWithDeps(
|
||||||
|
fileManager FileManager,
|
||||||
|
gitManager GitManager,
|
||||||
|
configManager ConfigManager,
|
||||||
|
pathResolver PathResolver,
|
||||||
|
repoPath string,
|
||||||
|
) *Service {
|
||||||
|
return &Service{
|
||||||
|
fileManager: fileManager,
|
||||||
|
gitManager: gitManager,
|
||||||
|
configManager: configManager,
|
||||||
|
pathResolver: pathResolver,
|
||||||
|
repoPath: repoPath,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListManagedFiles returns the list of files managed by lnk for a specific host
|
||||||
|
// If host is empty, returns common configuration files
|
||||||
|
func (s *Service) ListManagedFiles(ctx context.Context, host string) ([]models.ManagedFile, error) {
|
||||||
|
// Check if the repository exists
|
||||||
|
exists, err := s.fileManager.Exists(ctx, s.repoPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.NewFileSystemOperationError("check_repo_exists", s.repoPath, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !exists {
|
||||||
|
return nil, errors.NewRepoNotInitializedError(s.repoPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use the config manager to list managed files
|
||||||
|
managedFiles, err := s.configManager.ListManagedFiles(ctx, s.repoPath, host)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err // ConfigManager already returns properly typed errors
|
||||||
|
}
|
||||||
|
|
||||||
|
return managedFiles, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetStatus returns the Git repository status
|
||||||
|
// Returns an error if the repository is not initialized or GitManager is not available
|
||||||
|
func (s *Service) GetStatus(ctx context.Context) (*models.SyncStatus, error) {
|
||||||
|
// Check if GitManager is available
|
||||||
|
if s.gitManager == nil {
|
||||||
|
return nil, errors.NewGitOperationError("get_status",
|
||||||
|
fmt.Errorf("git manager not available"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if the repository exists
|
||||||
|
exists, err := s.fileManager.Exists(ctx, s.repoPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.NewFileSystemOperationError("check_repo_exists", s.repoPath, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !exists {
|
||||||
|
return nil, errors.NewRepoNotInitializedError(s.repoPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if it's a Git repository
|
||||||
|
isRepo, err := s.gitManager.IsRepository(ctx, s.repoPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.NewGitOperationError("check_git_repo", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !isRepo {
|
||||||
|
return nil, errors.NewRepoNotInitializedError(s.repoPath).
|
||||||
|
WithContext("reason", "directory exists but is not a git repository")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get Git status
|
||||||
|
status, err := s.gitManager.Status(ctx, s.repoPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err // GitManager already returns properly typed errors
|
||||||
|
}
|
||||||
|
|
||||||
|
return status, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetRepoPath returns the repository path
|
||||||
|
func (s *Service) GetRepoPath() string {
|
||||||
|
return s.repoPath
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsRepositoryInitialized checks if the lnk repository has been initialized
|
||||||
|
func (s *Service) IsRepositoryInitialized(ctx context.Context) (bool, error) {
|
||||||
|
// Check if repository directory exists
|
||||||
|
exists, err := s.fileManager.Exists(ctx, s.repoPath)
|
||||||
|
if err != nil {
|
||||||
|
return false, errors.NewFileSystemOperationError("check_repo_exists", s.repoPath, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !exists {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if it's a Git repository (if GitManager is available)
|
||||||
|
if s.gitManager != nil {
|
||||||
|
isGitRepo, err := s.gitManager.IsRepository(ctx, s.repoPath)
|
||||||
|
if err != nil {
|
||||||
|
return false, errors.NewGitOperationError("check_git_repo", err)
|
||||||
|
}
|
||||||
|
return isGitRepo, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// If no GitManager, just check if the directory exists
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// InitializeRepository initializes a new lnk repository, optionally cloning from a remote URL
|
||||||
|
func (s *Service) InitializeRepository(ctx context.Context, remoteURL string) error {
|
||||||
|
// Check if GitManager is available
|
||||||
|
if s.gitManager == nil {
|
||||||
|
return errors.NewGitOperationError("initialize_repository",
|
||||||
|
fmt.Errorf("git manager not available"))
|
||||||
|
}
|
||||||
|
|
||||||
|
if remoteURL != "" {
|
||||||
|
// Clone from remote
|
||||||
|
return s.cloneRepository(ctx, remoteURL)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize empty repository
|
||||||
|
return s.initEmptyRepository(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
// cloneRepository clones a repository from the given URL
|
||||||
|
func (s *Service) cloneRepository(ctx context.Context, remoteURL string) error {
|
||||||
|
// Clone using GitManager
|
||||||
|
if err := s.gitManager.Clone(ctx, s.repoPath, remoteURL); err != nil {
|
||||||
|
return errors.NewGitOperationError("clone_repository", err).
|
||||||
|
WithContext("remote_url", remoteURL).
|
||||||
|
WithContext("repo_path", s.repoPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// initEmptyRepository initializes an empty Git repository
|
||||||
|
func (s *Service) initEmptyRepository(ctx context.Context) error {
|
||||||
|
// Check if repository directory already exists
|
||||||
|
exists, err := s.fileManager.Exists(ctx, s.repoPath)
|
||||||
|
if err != nil {
|
||||||
|
return errors.NewFileSystemOperationError("check_repo_exists", s.repoPath, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if exists {
|
||||||
|
// Check if it's already a Git repository
|
||||||
|
isGitRepo, err := s.gitManager.IsRepository(ctx, s.repoPath)
|
||||||
|
if err != nil {
|
||||||
|
return errors.NewGitOperationError("check_git_repo", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if isGitRepo {
|
||||||
|
// Check if it's a lnk repository
|
||||||
|
isLnkRepo, err := s.gitManager.IsLnkRepository(ctx, s.repoPath)
|
||||||
|
if err != nil {
|
||||||
|
return errors.NewGitOperationError("check_lnk_repo", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if isLnkRepo {
|
||||||
|
// It's already a lnk repository, init is idempotent
|
||||||
|
return nil
|
||||||
|
} else {
|
||||||
|
// It's not a lnk repository, error to prevent data loss
|
||||||
|
return errors.NewRepoNotInitializedError(s.repoPath).
|
||||||
|
WithContext("reason", "directory contains an existing non-lnk Git repository")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create the repository directory if it doesn't exist
|
||||||
|
if !exists {
|
||||||
|
if err := s.fileManager.MkdirAll(ctx, s.repoPath, 0755); err != nil {
|
||||||
|
return errors.NewFileSystemOperationError("create_repo_dir", s.repoPath, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize Git repository
|
||||||
|
if err := s.gitManager.Init(ctx, s.repoPath); err != nil {
|
||||||
|
// Clean up directory if we created it
|
||||||
|
if !exists {
|
||||||
|
_ = s.fileManager.Remove(ctx, s.repoPath) // Ignore cleanup errors
|
||||||
|
}
|
||||||
|
return errors.NewGitOperationError("init_git_repo", err).
|
||||||
|
WithContext("repo_path", s.repoPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddFile adds a file or directory to lnk management for the specified host
|
||||||
|
// This involves moving the file to the repository, creating a symlink, updating tracking, and committing to Git
|
||||||
|
func (s *Service) AddFile(ctx context.Context, filePath, host string) (*models.ManagedFile, error) {
|
||||||
|
// Check if GitManager is available
|
||||||
|
if s.gitManager == nil {
|
||||||
|
return nil, errors.NewGitOperationError("add_file",
|
||||||
|
fmt.Errorf("git manager not available"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get absolute path
|
||||||
|
absPath, err := s.pathResolver.GetAbsolutePathInHome(filePath)
|
||||||
|
if err != nil {
|
||||||
|
// If it fails, try as-is (might be already absolute)
|
||||||
|
var pathErr error
|
||||||
|
absPath, pathErr = filepath.Abs(filePath)
|
||||||
|
if pathErr != nil {
|
||||||
|
return nil, errors.NewFileSystemOperationError("resolve_path", filePath, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate that the file exists and is accessible (check this FIRST like the old implementation)
|
||||||
|
exists, err := s.fileManager.Exists(ctx, absPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.NewFileSystemOperationError("check_file_exists", absPath, err)
|
||||||
|
}
|
||||||
|
if !exists {
|
||||||
|
return nil, errors.NewFileNotFoundError(absPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if repository is initialized (after file existence check)
|
||||||
|
initialized, err := s.IsRepositoryInitialized(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if !initialized {
|
||||||
|
return nil, errors.NewRepoNotInitializedError(s.repoPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get file information to determine if it's a directory
|
||||||
|
fileInfo, err := s.fileManager.Stat(ctx, absPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.NewFileSystemOperationError("stat_file", absPath, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get relative path for tracking
|
||||||
|
relativePath, err := s.pathResolver.GetRelativePathFromHome(absPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.NewFileSystemOperationError("get_relative_path", absPath, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if file is already managed
|
||||||
|
existingFile, err := s.configManager.GetManagedFile(ctx, s.repoPath, host, relativePath)
|
||||||
|
if err == nil && existingFile != nil {
|
||||||
|
return nil, errors.NewFileAlreadyManagedError(relativePath)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create managed file model
|
||||||
|
managedFile := models.ManagedFile{
|
||||||
|
OriginalPath: absPath,
|
||||||
|
RelativePath: relativePath,
|
||||||
|
Host: host,
|
||||||
|
IsDirectory: fileInfo.IsDir(),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get storage path in repository
|
||||||
|
storagePath, err := s.pathResolver.GetFileStoragePathInRepo(s.repoPath, host, relativePath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.NewFileSystemOperationError("get_storage_path", relativePath, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
managedFile.RepoPath = storagePath
|
||||||
|
|
||||||
|
// Execute the file addition with rollback support
|
||||||
|
if err := s.executeFileAddition(ctx, &managedFile); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return &managedFile, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// executeFileAddition performs the actual file addition with rollback logic
|
||||||
|
func (s *Service) executeFileAddition(ctx context.Context, file *models.ManagedFile) error {
|
||||||
|
var rollbackActions []func() error
|
||||||
|
|
||||||
|
// Helper function to add rollback action
|
||||||
|
addRollback := func(action func() error) {
|
||||||
|
rollbackActions = append([]func() error{action}, rollbackActions...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Execute rollback if any step fails
|
||||||
|
defer func() {
|
||||||
|
if len(rollbackActions) > 0 {
|
||||||
|
for _, action := range rollbackActions {
|
||||||
|
_ = action() // Ignore rollback errors
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Step 1: Create destination directory
|
||||||
|
destDir := filepath.Dir(file.RepoPath)
|
||||||
|
if err := s.fileManager.MkdirAll(ctx, destDir, 0755); err != nil {
|
||||||
|
return errors.NewFileSystemOperationError("create_dest_dir", destDir, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 2: Move file to repository
|
||||||
|
if err := s.fileManager.Move(ctx, file.OriginalPath, file.RepoPath); err != nil {
|
||||||
|
return errors.NewFileSystemOperationError("move_file", file.OriginalPath, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add rollback for move operation
|
||||||
|
addRollback(func() error {
|
||||||
|
return s.fileManager.Move(context.Background(), file.RepoPath, file.OriginalPath)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Step 3: Create symlink
|
||||||
|
if err := s.fileManager.CreateSymlink(ctx, file.RepoPath, file.OriginalPath); err != nil {
|
||||||
|
return errors.NewFileSystemOperationError("create_symlink", file.OriginalPath, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add rollback for symlink creation
|
||||||
|
addRollback(func() error {
|
||||||
|
return s.fileManager.Remove(context.Background(), file.OriginalPath)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Step 4: Add to config tracking
|
||||||
|
if err := s.configManager.AddManagedFileToHost(ctx, s.repoPath, file.Host, *file); err != nil {
|
||||||
|
return err // ConfigManager returns properly typed errors
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add rollback for config update
|
||||||
|
addRollback(func() error {
|
||||||
|
return s.configManager.RemoveManagedFileFromHost(context.Background(),
|
||||||
|
s.repoPath, file.Host, file.RelativePath)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Step 5: Add file to Git
|
||||||
|
gitPath := file.RelativePath
|
||||||
|
if file.Host != "" {
|
||||||
|
gitPath = filepath.Join(file.Host+".lnk", file.RelativePath)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.gitManager.Add(ctx, s.repoPath, gitPath); err != nil {
|
||||||
|
return errors.NewGitOperationError("add_file_to_git", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 6: Add config file to Git
|
||||||
|
trackingFile, err := s.pathResolver.GetTrackingFilePath(s.repoPath, file.Host)
|
||||||
|
if err != nil {
|
||||||
|
return errors.NewFileSystemOperationError("get_tracking_file", "", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get relative path of tracking file from repo root
|
||||||
|
trackingFileRel, err := filepath.Rel(s.repoPath, trackingFile)
|
||||||
|
if err != nil {
|
||||||
|
return errors.NewFileSystemOperationError("get_tracking_file_rel", trackingFile, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.gitManager.Add(ctx, s.repoPath, trackingFileRel); err != nil {
|
||||||
|
return errors.NewGitOperationError("add_tracking_file_to_git", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 7: Commit changes
|
||||||
|
basename := filepath.Base(file.RelativePath)
|
||||||
|
commitMessage := fmt.Sprintf("lnk: added %s", basename)
|
||||||
|
if err := s.gitManager.Commit(ctx, s.repoPath, commitMessage); err != nil {
|
||||||
|
return errors.NewGitOperationError("commit_changes", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we reach here, everything succeeded - clear rollback actions
|
||||||
|
rollbackActions = nil
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RemoveFile removes a file or directory from lnk management for the specified host
|
||||||
|
// This involves removing the symlink, restoring the original file, updating tracking, and committing to Git
|
||||||
|
func (s *Service) RemoveFile(ctx context.Context, filePath, host string) error {
|
||||||
|
// Check if GitManager is available
|
||||||
|
if s.gitManager == nil {
|
||||||
|
return errors.NewGitOperationError("remove_file",
|
||||||
|
fmt.Errorf("git manager not available"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if repository is initialized
|
||||||
|
initialized, err := s.IsRepositoryInitialized(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !initialized {
|
||||||
|
return errors.NewRepoNotInitializedError(s.repoPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get absolute path
|
||||||
|
absPath, err := s.pathResolver.GetAbsolutePathInHome(filePath)
|
||||||
|
if err != nil {
|
||||||
|
// If it fails, try as-is (might be already absolute)
|
||||||
|
var pathErr error
|
||||||
|
absPath, pathErr = filepath.Abs(filePath)
|
||||||
|
if pathErr != nil {
|
||||||
|
return errors.NewFileSystemOperationError("resolve_path", filePath, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate that this is a symlink
|
||||||
|
linkInfo, err := s.fileManager.Lstat(ctx, absPath)
|
||||||
|
if err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return errors.NewFileNotFoundError(absPath)
|
||||||
|
}
|
||||||
|
return errors.NewFileSystemOperationError("stat_symlink", absPath, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if linkInfo.Mode()&os.ModeSymlink == 0 {
|
||||||
|
return errors.NewNotSymlinkError(absPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get symlink target
|
||||||
|
target, err := s.fileManager.Readlink(ctx, absPath)
|
||||||
|
if err != nil {
|
||||||
|
return errors.NewFileSystemOperationError("read_symlink", absPath, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert relative symlink target to absolute path
|
||||||
|
if !filepath.IsAbs(target) {
|
||||||
|
target = filepath.Join(filepath.Dir(absPath), target)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate that the target exists in our repository
|
||||||
|
targetAbs, err := filepath.Abs(target)
|
||||||
|
if err != nil {
|
||||||
|
return errors.NewFileSystemOperationError("resolve_target", target, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
repoPathAbs, err := filepath.Abs(s.repoPath)
|
||||||
|
if err != nil {
|
||||||
|
return errors.NewFileSystemOperationError("resolve_repo_path", s.repoPath, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !strings.HasPrefix(targetAbs, repoPathAbs) {
|
||||||
|
return errors.NewInvalidPathError(targetAbs, "symlink target is not in lnk repository")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get relative path for tracking
|
||||||
|
relativePath, err := s.pathResolver.GetRelativePathFromHome(absPath)
|
||||||
|
if err != nil {
|
||||||
|
return errors.NewFileSystemOperationError("get_relative_path", absPath, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if this file is actually managed
|
||||||
|
managedFile, err := s.configManager.GetManagedFile(ctx, s.repoPath, host, relativePath)
|
||||||
|
if err != nil || managedFile == nil {
|
||||||
|
return errors.NewLnkError(errors.ErrorCodeFileNotFound, fmt.Sprintf("file is not managed by lnk: %s", relativePath))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get target file info to determine if it's a directory
|
||||||
|
targetInfo, err := s.fileManager.Stat(ctx, targetAbs)
|
||||||
|
if err != nil {
|
||||||
|
return errors.NewFileSystemOperationError("stat_target", targetAbs, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Execute the file removal with rollback support
|
||||||
|
return s.executeFileRemoval(ctx, absPath, targetAbs, relativePath, host, targetInfo.IsDir())
|
||||||
|
}
|
||||||
|
|
||||||
|
// executeFileRemoval performs the actual file removal with rollback logic
|
||||||
|
func (s *Service) executeFileRemoval(ctx context.Context, symlinkPath, targetPath, relativePath, host string, isDirectory bool) error {
|
||||||
|
var rollbackActions []func() error
|
||||||
|
|
||||||
|
// Helper function to add rollback action
|
||||||
|
addRollback := func(action func() error) {
|
||||||
|
rollbackActions = append([]func() error{action}, rollbackActions...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Execute rollback if any step fails
|
||||||
|
defer func() {
|
||||||
|
if len(rollbackActions) > 0 {
|
||||||
|
for _, action := range rollbackActions {
|
||||||
|
_ = action() // Ignore rollback errors
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Step 1: Remove the symlink
|
||||||
|
if err := s.fileManager.Remove(ctx, symlinkPath); err != nil {
|
||||||
|
return errors.NewFileSystemOperationError("remove_symlink", symlinkPath, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add rollback for symlink removal
|
||||||
|
addRollback(func() error {
|
||||||
|
return s.fileManager.CreateSymlink(context.Background(), targetPath, symlinkPath)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Step 2: Move file back from repository to original location
|
||||||
|
if err := s.fileManager.Move(ctx, targetPath, symlinkPath); err != nil {
|
||||||
|
return errors.NewFileSystemOperationError("restore_file", targetPath, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add rollback for file restoration
|
||||||
|
addRollback(func() error {
|
||||||
|
return s.fileManager.Move(context.Background(), symlinkPath, targetPath)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Step 3: Remove from config tracking
|
||||||
|
if err := s.configManager.RemoveManagedFileFromHost(ctx, s.repoPath, host, relativePath); err != nil {
|
||||||
|
return err // ConfigManager returns properly typed errors
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add rollback for config update
|
||||||
|
managedFile := models.ManagedFile{
|
||||||
|
OriginalPath: symlinkPath,
|
||||||
|
RelativePath: relativePath,
|
||||||
|
RepoPath: targetPath,
|
||||||
|
Host: host,
|
||||||
|
IsDirectory: isDirectory,
|
||||||
|
}
|
||||||
|
addRollback(func() error {
|
||||||
|
return s.configManager.AddManagedFileToHost(context.Background(), s.repoPath, host, managedFile)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Step 4: Remove file from Git
|
||||||
|
gitPath := relativePath
|
||||||
|
if host != "" {
|
||||||
|
gitPath = filepath.Join(host+".lnk", relativePath)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.gitManager.Remove(ctx, s.repoPath, gitPath); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 5: Add config file to Git (to commit the tracking change)
|
||||||
|
trackingFile, err := s.pathResolver.GetTrackingFilePath(s.repoPath, host)
|
||||||
|
if err != nil {
|
||||||
|
return errors.NewFileSystemOperationError("get_tracking_file", "", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get relative path of tracking file from repo root
|
||||||
|
trackingFileRel, err := filepath.Rel(s.repoPath, trackingFile)
|
||||||
|
if err != nil {
|
||||||
|
return errors.NewFileSystemOperationError("get_tracking_file_rel", trackingFile, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.gitManager.Add(ctx, s.repoPath, trackingFileRel); err != nil {
|
||||||
|
return errors.NewGitOperationError("add_tracking_file_to_git", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 6: Commit changes
|
||||||
|
basename := filepath.Base(relativePath)
|
||||||
|
commitMessage := fmt.Sprintf("lnk: removed %s", basename)
|
||||||
|
if err := s.gitManager.Commit(ctx, s.repoPath, commitMessage); err != nil {
|
||||||
|
return errors.NewGitOperationError("commit_changes", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we reach here, everything succeeded - clear rollback actions
|
||||||
|
rollbackActions = nil
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// PushChanges stages all changes and pushes to remote repository
|
||||||
|
func (s *Service) PushChanges(ctx context.Context, message string) error {
|
||||||
|
// Check if GitManager is available
|
||||||
|
if s.gitManager == nil {
|
||||||
|
return errors.NewGitOperationError("push_changes",
|
||||||
|
fmt.Errorf("git manager not available"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if repository is initialized
|
||||||
|
initialized, err := s.IsRepositoryInitialized(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !initialized {
|
||||||
|
return errors.NewRepoNotInitializedError(s.repoPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if there are any changes to commit
|
||||||
|
hasChanges, err := s.gitManager.HasChanges(ctx, s.repoPath)
|
||||||
|
if err != nil {
|
||||||
|
return errors.NewGitOperationError("check_changes", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if hasChanges {
|
||||||
|
// Add all changes (equivalent to git add .)
|
||||||
|
if err := s.gitManager.Add(ctx, s.repoPath, "."); err != nil {
|
||||||
|
return errors.NewGitOperationError("stage_changes", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a sync commit
|
||||||
|
if err := s.gitManager.Commit(ctx, s.repoPath, message); err != nil {
|
||||||
|
return errors.NewGitOperationError("commit_changes", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Push to remote
|
||||||
|
if err := s.gitManager.Push(ctx, s.repoPath); err != nil {
|
||||||
|
return errors.NewGitOperationError("push_to_remote", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// PullChanges pulls changes from remote and restores symlinks for the specified host
|
||||||
|
func (s *Service) PullChanges(ctx context.Context, host string) ([]models.ManagedFile, error) {
|
||||||
|
// Check if GitManager is available
|
||||||
|
if s.gitManager == nil {
|
||||||
|
return nil, errors.NewGitOperationError("pull_changes",
|
||||||
|
fmt.Errorf("git manager not available"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if repository is initialized
|
||||||
|
initialized, err := s.IsRepositoryInitialized(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if !initialized {
|
||||||
|
return nil, errors.NewRepoNotInitializedError(s.repoPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pull changes from remote
|
||||||
|
if err := s.gitManager.Pull(ctx, s.repoPath); err != nil {
|
||||||
|
return nil, errors.NewGitOperationError("pull_from_remote", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Restore symlinks for the specified host
|
||||||
|
restored, err := s.RestoreSymlinksForHost(ctx, host)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return restored, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RestoreSymlinksForHost restores symlinks for all managed files for the specified host
|
||||||
|
func (s *Service) RestoreSymlinksForHost(ctx context.Context, host string) ([]models.ManagedFile, error) {
|
||||||
|
// Check if repository is initialized
|
||||||
|
initialized, err := s.IsRepositoryInitialized(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if !initialized {
|
||||||
|
return nil, errors.NewRepoNotInitializedError(s.repoPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get list of managed files for this host
|
||||||
|
managedFiles, err := s.configManager.ListManagedFiles(ctx, s.repoPath, host)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var restored []models.ManagedFile
|
||||||
|
homeDir, err := s.pathResolver.GetHomePath()
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.NewFileSystemOperationError("get_home_dir", "", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, managedFile := range managedFiles {
|
||||||
|
// Determine symlink path (where the symlink should be created)
|
||||||
|
symlinkPath := filepath.Join(homeDir, managedFile.RelativePath)
|
||||||
|
|
||||||
|
// Determine repository file path (what the symlink should point to)
|
||||||
|
repoFilePath, err := s.pathResolver.GetFileStoragePathInRepo(s.repoPath, host, managedFile.RelativePath)
|
||||||
|
if err != nil {
|
||||||
|
continue // Skip files with path resolution issues
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if repository file exists
|
||||||
|
repoExists, err := s.fileManager.Exists(ctx, repoFilePath)
|
||||||
|
if err != nil || !repoExists {
|
||||||
|
continue // Skip missing files
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if symlink already exists and is correct
|
||||||
|
if s.isValidSymlink(ctx, symlinkPath, repoFilePath) {
|
||||||
|
continue // Skip files that are already correctly symlinked
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure parent directory exists
|
||||||
|
symlinkDir := filepath.Dir(symlinkPath)
|
||||||
|
if err := s.fileManager.MkdirAll(ctx, symlinkDir, 0755); err != nil {
|
||||||
|
continue // Skip files with directory creation issues
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove existing file/symlink if it exists
|
||||||
|
exists, err := s.fileManager.Exists(ctx, symlinkPath)
|
||||||
|
if err == nil && exists {
|
||||||
|
if err := s.fileManager.Remove(ctx, symlinkPath); err != nil {
|
||||||
|
continue // Skip files that can't be removed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create symlink
|
||||||
|
if err := s.fileManager.CreateSymlink(ctx, repoFilePath, symlinkPath); err != nil {
|
||||||
|
continue // Skip files with symlink creation issues
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update the managed file with current paths
|
||||||
|
restoredFile := managedFile
|
||||||
|
restoredFile.OriginalPath = symlinkPath
|
||||||
|
restoredFile.RepoPath = repoFilePath
|
||||||
|
restored = append(restored, restoredFile)
|
||||||
|
}
|
||||||
|
|
||||||
|
return restored, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// isValidSymlink checks if the given path is a symlink pointing to the expected target
|
||||||
|
func (s *Service) isValidSymlink(ctx context.Context, symlinkPath, expectedTarget string) bool {
|
||||||
|
info, err := s.fileManager.Lstat(ctx, symlinkPath)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if it's a symlink
|
||||||
|
if info.Mode()&os.ModeSymlink == 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if it points to the correct target
|
||||||
|
target, err := s.fileManager.Readlink(ctx, symlinkPath)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert relative path to absolute if needed
|
||||||
|
if !filepath.IsAbs(target) {
|
||||||
|
target = filepath.Join(filepath.Dir(symlinkPath), target)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean both paths for comparison
|
||||||
|
targetAbs, err := filepath.Abs(target)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
expectedAbs, err := filepath.Abs(expectedTarget)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return targetAbs == expectedAbs
|
||||||
|
}
|
1080
internal/service/service_test.go
Normal file
1080
internal/service/service_test.go
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user