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