4 Commits

15 changed files with 664 additions and 286 deletions

View File

@@ -1,7 +1,6 @@
package cmd package cmd
import ( import (
"fmt"
"path/filepath" "path/filepath"
"github.com/spf13/cobra" "github.com/spf13/cobra"
@@ -10,24 +9,20 @@ import (
func newAddCmd() *cobra.Command { func newAddCmd() *cobra.Command {
cmd := &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.",
Args: cobra.ExactArgs(1), Args: cobra.ExactArgs(1),
SilenceUsage: true, SilenceUsage: true,
SilenceErrors: 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") host, _ := cmd.Flags().GetString("host")
var lnk *core.Lnk lnk := core.NewLnk(core.WithHost(host))
if host != "" {
lnk = core.NewLnkWithHost(host)
} else {
lnk = core.NewLnk()
}
if err := lnk.Add(filePath); err != nil { if err := lnk.Add(filePath); err != nil {
return fmt.Errorf("failed to add file: %w", err) return err
} }
basename := filepath.Base(filePath) basename := filepath.Base(filePath)

View File

@@ -1,24 +1,23 @@
package cmd package cmd
import ( import (
"fmt"
"github.com/spf13/cobra" "github.com/spf13/cobra"
"github.com/yarlson/lnk/internal/core" "github.com/yarlson/lnk/internal/core"
) )
func newInitCmd() *cobra.Command { func newInitCmd() *cobra.Command {
cmd := &cobra.Command{ cmd := &cobra.Command{
Use: "init", Use: "init",
Short: "🎯 Initialize a new lnk repository", Short: "🎯 Initialize a new lnk repository",
Long: "Creates the lnk directory and initializes a Git repository for managing dotfiles.", Long: "Creates the lnk directory and initializes a Git repository for managing dotfiles.",
SilenceUsage: true, SilenceUsage: true,
SilenceErrors: true,
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() lnk := core.NewLnk()
if err := lnk.InitWithRemote(remote); err != nil { if err := lnk.InitWithRemote(remote); err != nil {
return fmt.Errorf("failed to initialize lnk: %w", err) return err
} }
if remote != "" { if remote != "" {

View File

@@ -1,7 +1,6 @@
package cmd package cmd
import ( import (
"fmt"
"os" "os"
"path/filepath" "path/filepath"
"strings" "strings"
@@ -12,10 +11,11 @@ import (
func newListCmd() *cobra.Command { func newListCmd() *cobra.Command {
cmd := &cobra.Command{ cmd := &cobra.Command{
Use: "list", Use: "list",
Short: "📋 List files managed by lnk", Short: "📋 List files managed by lnk",
Long: "Display all files and directories currently managed by lnk.", Long: "Display all files and directories currently managed by lnk.",
SilenceUsage: true, SilenceUsage: true,
SilenceErrors: true,
RunE: func(cmd *cobra.Command, args []string) error { RunE: func(cmd *cobra.Command, args []string) error {
host, _ := cmd.Flags().GetString("host") host, _ := cmd.Flags().GetString("host")
all, _ := cmd.Flags().GetBool("all") all, _ := cmd.Flags().GetBool("all")
@@ -44,7 +44,7 @@ func listCommonConfig(cmd *cobra.Command) error {
lnk := core.NewLnk() lnk := core.NewLnk()
managedItems, err := lnk.List() managedItems, err := lnk.List()
if err != nil { if err != nil {
return fmt.Errorf("failed to list managed items: %w", err) return err
} }
if len(managedItems) == 0 { if len(managedItems) == 0 {
@@ -68,10 +68,10 @@ func listCommonConfig(cmd *cobra.Command) error {
} }
func listHostConfig(cmd *cobra.Command, host string) error { func listHostConfig(cmd *cobra.Command, host string) error {
lnk := core.NewLnkWithHost(host) lnk := core.NewLnk(core.WithHost(host))
managedItems, err := lnk.List() managedItems, err := lnk.List()
if err != nil { if err != nil {
return fmt.Errorf("failed to list managed items for host %s: %w", host, err) return err
} }
if len(managedItems) == 0 { if len(managedItems) == 0 {
@@ -101,7 +101,7 @@ func listAllConfigs(cmd *cobra.Command) error {
lnk := core.NewLnk() lnk := core.NewLnk()
commonItems, err := lnk.List() commonItems, err := lnk.List()
if err != nil { if err != nil {
return fmt.Errorf("failed to list common managed items: %w", err) return err
} }
printf(cmd, "🌐 \033[1mCommon configuration\033[0m (\033[36m%d item", len(commonItems)) printf(cmd, "🌐 \033[1mCommon configuration\033[0m (\033[36m%d item", len(commonItems))
@@ -121,13 +121,13 @@ func listAllConfigs(cmd *cobra.Command) error {
// Find all host-specific configurations // Find all host-specific configurations
hosts, err := findHostConfigs() hosts, err := findHostConfigs()
if err != nil { if err != nil {
return fmt.Errorf("failed to find host configurations: %w", err) return err
} }
for _, host := range hosts { for _, host := range hosts {
printf(cmd, "\n🖥 \033[1mHost: %s\033[0m", host) printf(cmd, "\n🖥 \033[1mHost: %s\033[0m", host)
hostLnk := core.NewLnkWithHost(host) hostLnk := core.NewLnk(core.WithHost(host))
hostItems, err := hostLnk.List() hostItems, err := hostLnk.List()
if err != nil { if err != nil {
printf(cmd, " \033[31m(error: %v)\033[0m\n", err) printf(cmd, " \033[31m(error: %v)\033[0m\n", err)
@@ -163,7 +163,7 @@ func findHostConfigs() ([]string, error) {
entries, err := os.ReadDir(repoPath) entries, err := os.ReadDir(repoPath)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to read repository directory: %w", err) return nil, err
} }
var hosts []string var hosts []string

View File

@@ -1,31 +1,25 @@
package cmd package cmd
import ( import (
"fmt"
"github.com/spf13/cobra" "github.com/spf13/cobra"
"github.com/yarlson/lnk/internal/core" "github.com/yarlson/lnk/internal/core"
) )
func newPullCmd() *cobra.Command { func newPullCmd() *cobra.Command {
cmd := &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,
SilenceErrors: true,
RunE: func(cmd *cobra.Command, args []string) error { RunE: func(cmd *cobra.Command, args []string) error {
host, _ := cmd.Flags().GetString("host") host, _ := cmd.Flags().GetString("host")
var lnk *core.Lnk lnk := core.NewLnk(core.WithHost(host))
if host != "" {
lnk = core.NewLnkWithHost(host)
} else {
lnk = core.NewLnk()
}
restored, err := lnk.Pull() restored, err := lnk.Pull()
if err != nil { if err != nil {
return fmt.Errorf("failed to pull changes: %w", err) return err
} }
if len(restored) > 0 { if len(restored) > 0 {

View File

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

View File

@@ -1,7 +1,6 @@
package cmd package cmd
import ( import (
"fmt"
"path/filepath" "path/filepath"
"github.com/spf13/cobra" "github.com/spf13/cobra"
@@ -10,24 +9,20 @@ import (
func newRemoveCmd() *cobra.Command { func newRemoveCmd() *cobra.Command {
cmd := &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.",
Args: cobra.ExactArgs(1), Args: cobra.ExactArgs(1),
SilenceUsage: true, SilenceUsage: true,
SilenceErrors: 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") host, _ := cmd.Flags().GetString("host")
var lnk *core.Lnk lnk := core.NewLnk(core.WithHost(host))
if host != "" {
lnk = core.NewLnkWithHost(host)
} else {
lnk = core.NewLnk()
}
if err := lnk.Remove(filePath); err != nil { if err := lnk.Remove(filePath); err != nil {
return fmt.Errorf("failed to remove file: %w", err) return err
} }
basename := filepath.Base(filePath) basename := filepath.Base(filePath)

View File

@@ -32,8 +32,9 @@ Supports both common configurations and host-specific setups.
lnk push "setup complete" # Sync to remote lnk push "setup complete" # Sync to remote
🎯 Simple, fast, Git-native, and multi-host ready.`, 🎯 Simple, fast, Git-native, and multi-host ready.`,
SilenceUsage: true, SilenceUsage: true,
Version: fmt.Sprintf("%s (built %s)", version, buildTime), SilenceErrors: true,
Version: fmt.Sprintf("%s (built %s)", version, buildTime),
} }
// Add subcommands // Add subcommands
@@ -57,7 +58,7 @@ 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) _, _ = fmt.Fprintln(os.Stderr, err)
os.Exit(1) os.Exit(1)
} }
} }

View File

@@ -31,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{}
@@ -66,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,9 +103,20 @@ func (suite *CLITestSuite) TestAddCommand() {
suite.Equal(os.ModeSymlink, info.Mode()&os.ModeSymlink) suite.Equal(os.ModeSymlink, info.Mode()&os.ModeSymlink)
// Verify the file exists in repo with preserved directory structure // Verify the file exists in repo with preserved directory structure
lnkDir := filepath.Join(suite.tempDir, "lnk") lnkDir := filepath.Join(suite.tempDir, ".config", "lnk")
repoFile := filepath.Join(lnkDir, suite.tempDir, ".bashrc") repoFile := filepath.Join(lnkDir, ".bashrc")
suite.FileExists(repoFile) suite.FileExists(repoFile)
// Verify content is preserved in storage
storedContent, err := os.ReadFile(repoFile)
suite.NoError(err)
suite.Equal("export PATH=/usr/local/bin:$PATH", string(storedContent))
// Verify .lnk file contains the correct entry
lnkFile := filepath.Join(lnkDir, ".lnk")
lnkContent, err := os.ReadFile(lnkFile)
suite.NoError(err)
suite.Equal(".bashrc\n", string(lnkContent))
} }
func (suite *CLITestSuite) TestRemoveCommand() { func (suite *CLITestSuite) TestRemoveCommand() {
@@ -150,7 +157,7 @@ func (suite *CLITestSuite) TestStatusCommand() {
// Test status without remote - should fail // Test status without remote - should fail
err = suite.runCommand("status") err = suite.runCommand("status")
suite.Error(err) suite.Error(err)
suite.Contains(err.Error(), "no remote configured") suite.Contains(err.Error(), "No remote repository is configured")
} }
func (suite *CLITestSuite) TestListCommand() { func (suite *CLITestSuite) TestListCommand() {
@@ -205,6 +212,27 @@ func (suite *CLITestSuite) TestListCommand() {
suite.Contains(output, "2 items") suite.Contains(output, "2 items")
suite.Contains(output, ".bashrc") suite.Contains(output, ".bashrc")
suite.Contains(output, ".vimrc") 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() {
@@ -219,7 +247,7 @@ func (suite *CLITestSuite) TestErrorHandling() {
name: "add nonexistent file", name: "add nonexistent file",
args: []string{"add", "/nonexistent/file"}, args: []string{"add", "/nonexistent/file"},
wantErr: true, wantErr: true,
errContains: "File does not exist", errContains: "File or directory not found",
}, },
{ {
name: "status without init", name: "status without init",
@@ -300,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")
}, },
@@ -369,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)
@@ -380,7 +436,7 @@ 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)
@@ -388,9 +444,22 @@ func (suite *CLITestSuite) TestAddDirectory() {
suite.Equal(os.ModeSymlink, info.Mode()&os.ModeSymlink) suite.Equal(os.ModeSymlink, info.Mode()&os.ModeSymlink)
// Verify the directory exists in repo with preserved directory structure // Verify the directory exists in repo with preserved directory structure
lnkDir := filepath.Join(suite.tempDir, "lnk") lnkDir := filepath.Join(suite.tempDir, ".config", "lnk")
repoDir := filepath.Join(lnkDir, suite.tempDir, ".config") repoDir := filepath.Join(lnkDir, ".ssh")
suite.DirExists(repoDir) suite.DirExists(repoDir)
// Verify directory content is preserved
repoConfigFile := filepath.Join(repoDir, "config")
suite.FileExists(repoConfigFile)
storedContent, err := os.ReadFile(repoConfigFile)
suite.NoError(err)
suite.Equal("Host example.com", string(storedContent))
// Verify .lnk file contains the directory entry
lnkFile := filepath.Join(lnkDir, ".lnk")
lnkContent, err := os.ReadFile(lnkFile)
suite.NoError(err)
suite.Equal(".ssh\n", string(lnkContent))
} }
func (suite *CLITestSuite) TestSameBasenameFilesBug() { func (suite *CLITestSuite) TestSameBasenameFilesBug() {
@@ -441,6 +510,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)
@@ -481,8 +571,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()
@@ -540,6 +643,33 @@ func (suite *CLITestSuite) TestMultihostCommands() {
suite.Contains(output, "workstation.lnk") suite.Contains(output, "workstation.lnk")
suite.stdout.Reset() 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 // Test list command - common only
err = suite.runCommand("list") err = suite.runCommand("list")
suite.NoError(err) suite.NoError(err)

View File

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

View File

@@ -19,26 +19,30 @@ type Lnk struct {
fs *fs.FileSystem fs *fs.FileSystem
} }
// NewLnk creates a new Lnk instance for common configuration type Option func(*Lnk)
func NewLnk() *Lnk {
repoPath := getRepoPath() // WithHost sets the host for host-specific configuration
return &Lnk{ func WithHost(host string) Option {
repoPath: repoPath, return func(l *Lnk) {
host: "", // Empty host means common configuration l.host = host
git: git.New(repoPath),
fs: fs.New(),
} }
} }
// NewLnkWithHost creates a new Lnk instance for host-specific configuration // NewLnk creates a new Lnk instance with optional configuration
func NewLnkWithHost(host string) *Lnk { func NewLnk(opts ...Option) *Lnk {
repoPath := getRepoPath() repoPath := getRepoPath()
return &Lnk{ lnk := &Lnk{
repoPath: repoPath, repoPath: repoPath,
host: host, host: "",
git: git.New(repoPath), git: git.New(repoPath),
fs: fs.New(), fs: fs.New(),
} }
for _, opt := range opts {
opt(lnk)
}
return lnk
} }
// GetCurrentHostname returns the current system hostname // GetCurrentHostname returns the current system hostname
@@ -65,13 +69,6 @@ func getRepoPath() string {
return filepath.Join(xdgConfig, "lnk") return filepath.Join(xdgConfig, "lnk")
} }
// generateRepoName creates a repository path from a relative path
func generateRepoName(relativePath string, host string) string {
// Always preserve the directory structure for consistency
// Both common and host-specific files should maintain their path structure
return relativePath
}
// getHostStoragePath returns the storage path for host-specific or common files // getHostStoragePath returns the storage path for host-specific or common files
func (l *Lnk) getHostStoragePath() string { func (l *Lnk) getHostStoragePath() string {
if l.host == "" { if l.host == "" {
@@ -144,27 +141,17 @@ func (l *Lnk) InitWithRemote(remoteURL string) error {
} }
// No existing repository, initialize Git repository // No existing repository, initialize Git repository
if err := l.git.Init(); err != nil { return l.git.Init()
return fmt.Errorf("failed to initialize git repository: %w", err)
}
return nil
} }
// Clone clones a repository from the given URL // Clone clones a repository from the given URL
func (l *Lnk) Clone(url string) error { func (l *Lnk) Clone(url string) error {
if err := l.git.Clone(url); err != nil { return l.git.Clone(url)
return fmt.Errorf("failed to clone repository: %w", err)
}
return nil
} }
// AddRemote adds a remote to the repository // AddRemote adds a remote to the repository
func (l *Lnk) AddRemote(name, url string) error { func (l *Lnk) AddRemote(name, url string) error {
if err := l.git.AddRemote(name, url); err != nil { return l.git.AddRemote(name, url)
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 // Add moves a file or directory to the repository and creates a symlink
@@ -187,9 +174,8 @@ func (l *Lnk) Add(filePath string) error {
} }
// Generate repository path from relative path // Generate repository path from relative path
repoName := generateRepoName(relativePath, l.host)
storagePath := l.getHostStoragePath() storagePath := l.getHostStoragePath()
destPath := filepath.Join(storagePath, repoName) destPath := filepath.Join(storagePath, relativePath)
// Ensure destination directory exists (including parent directories for host-specific files) // Ensure destination directory exists (including parent directories for host-specific files)
destDir := filepath.Dir(destPath) destDir := filepath.Dir(destPath)
@@ -215,82 +201,56 @@ func (l *Lnk) Add(filePath string) error {
} }
// Move to repository (handles both files and directories) // Move to repository (handles both files and directories)
if info.IsDir() { if err := l.fs.Move(absPath, destPath, info); err != nil {
if err := l.fs.MoveDirectory(absPath, destPath); err != nil { return err
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 // Create symlink
if err := l.fs.CreateSymlink(destPath, absPath); err != nil { if err := l.fs.CreateSymlink(destPath, absPath); err != nil {
// Try to restore the original if symlink creation fails // Try to restore the original if symlink creation fails
if info.IsDir() { _ = l.fs.Move(destPath, absPath, info)
_ = l.fs.MoveDirectory(destPath, absPath) // Ignore error in cleanup return err
} 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 // Add to .lnk tracking file using relative path
if err := l.addManagedItem(relativePath); err != nil { if err := l.addManagedItem(relativePath); err != nil {
// Try to restore the original state if tracking fails // Try to restore the original state if tracking fails
_ = os.Remove(absPath) // Ignore error in cleanup _ = os.Remove(absPath)
if info.IsDir() { _ = l.fs.Move(destPath, absPath, info)
_ = 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) return fmt.Errorf("failed to update tracking file: %w", err)
} }
// Add both the item and .lnk file to git in a single commit // Add both the item and .lnk file to git in a single commit
// For host-specific files, we need to add the relative path from repo root // For host-specific files, we need to add the relative path from repo root
gitPath := repoName gitPath := relativePath
if l.host != "" { if l.host != "" {
gitPath = filepath.Join(l.host+".lnk", repoName) gitPath = filepath.Join(l.host+".lnk", relativePath)
} }
if err := l.git.Add(gitPath); err != nil { if err := l.git.Add(gitPath); err != nil {
// Try to restore the original state if git add fails // Try to restore the original state if git add fails
_ = os.Remove(absPath) // Ignore error in cleanup _ = os.Remove(absPath)
_ = l.removeManagedItem(relativePath) // Ignore error in cleanup _ = l.removeManagedItem(relativePath)
if info.IsDir() { _ = l.fs.Move(destPath, absPath, info)
_ = l.fs.MoveDirectory(destPath, absPath) // Ignore error in cleanup return err
} 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 // Add .lnk file to the same commit
if err := l.git.Add(l.getLnkFileName()); err != nil { if err := l.git.Add(l.getLnkFileName()); err != nil {
// Try to restore the original state if git add fails // Try to restore the original state if git add fails
_ = os.Remove(absPath) // Ignore error in cleanup _ = os.Remove(absPath)
_ = l.removeManagedItem(relativePath) // Ignore error in cleanup _ = l.removeManagedItem(relativePath)
if info.IsDir() { _ = l.fs.Move(destPath, absPath, info)
_ = l.fs.MoveDirectory(destPath, absPath) // Ignore error in cleanup return err
} 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 // Commit both changes together
basename := filepath.Base(relativePath) basename := filepath.Base(relativePath)
if err := l.git.Commit(fmt.Sprintf("lnk: added %s", basename)); err != nil { if err := l.git.Commit(fmt.Sprintf("lnk: added %s", basename)); err != nil {
// Try to restore the original state if commit fails // Try to restore the original state if commit fails
_ = os.Remove(absPath) // Ignore error in cleanup _ = os.Remove(absPath)
_ = l.removeManagedItem(relativePath) // Ignore error in cleanup _ = l.removeManagedItem(relativePath)
if info.IsDir() { _ = l.fs.Move(destPath, absPath, info)
_ = l.fs.MoveDirectory(destPath, absPath) // Ignore error in cleanup return err
} else {
_ = l.fs.MoveFile(destPath, absPath) // Ignore error in cleanup
}
return fmt.Errorf("failed to commit changes: %w", err)
} }
return nil return nil
@@ -360,35 +320,28 @@ func (l *Lnk) Remove(filePath string) error {
} }
// Generate the correct git path for removal // Generate the correct git path for removal
repoName := generateRepoName(relativePath, l.host) gitPath := relativePath
gitPath := repoName
if l.host != "" { if l.host != "" {
gitPath = filepath.Join(l.host+".lnk", repoName) gitPath = filepath.Join(l.host+".lnk", relativePath)
} }
if err := l.git.Remove(gitPath); err != nil { if err := l.git.Remove(gitPath); err != nil {
return fmt.Errorf("failed to remove from git: %w", err) return err
} }
// Add .lnk file to the same commit // Add .lnk file to the same commit
if err := l.git.Add(l.getLnkFileName()); err != nil { if err := l.git.Add(l.getLnkFileName()); err != nil {
return fmt.Errorf("failed to add .lnk file to git: %w", err) return err
} }
// Commit both changes together // Commit both changes together
basename := filepath.Base(relativePath) basename := filepath.Base(relativePath)
if err := l.git.Commit(fmt.Sprintf("lnk: removed %s", basename)); err != nil { if err := l.git.Commit(fmt.Sprintf("lnk: removed %s", basename)); err != nil {
return fmt.Errorf("failed to commit changes: %w", err) return err
} }
// Move back from repository (handles both files and directories) // Move back from repository (handles both files and directories)
if info.IsDir() { if err := l.fs.Move(target, absPath, info); err != nil {
if err := l.fs.MoveDirectory(target, absPath); err != nil { return err
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 return nil
@@ -416,7 +369,7 @@ func (l *Lnk) Status() (*StatusInfo, error) {
gitStatus, err := l.git.GetStatus() gitStatus, err := l.git.GetStatus()
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to get repository status: %w", err) return nil, err
} }
return &StatusInfo{ return &StatusInfo{
@@ -437,28 +390,24 @@ func (l *Lnk) Push(message string) error {
// Check if there are any changes // Check if there are any changes
hasChanges, err := l.git.HasChanges() hasChanges, err := l.git.HasChanges()
if err != nil { if err != nil {
return fmt.Errorf("failed to check for changes: %w", err) return err
} }
if hasChanges { if hasChanges {
// Stage all changes // Stage all changes
if err := l.git.AddAll(); err != nil { if err := l.git.AddAll(); err != nil {
return fmt.Errorf("failed to stage changes: %w", err) return err
} }
// Create a sync commit // Create a sync commit
if err := l.git.Commit(message); err != nil { if err := l.git.Commit(message); err != nil {
return fmt.Errorf("failed to commit changes: %w", err) return err
} }
} }
// Push to remote (this will be a no-op in tests since we don't have real remotes) // 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 // In real usage, this would push to the actual remote repository
if err := l.git.Push(); err != nil { return l.git.Push()
return fmt.Errorf("failed to push to remote: %w", err)
}
return nil
} }
// Pull fetches changes from remote and restores symlinks as needed // Pull fetches changes from remote and restores symlinks as needed
@@ -470,7 +419,7 @@ func (l *Lnk) Pull() ([]string, error) {
// Pull changes from remote (this will be a no-op in tests since we don't have real remotes) // 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 { if err := l.git.Pull(); err != nil {
return nil, fmt.Errorf("failed to pull from remote: %w", err) return nil, err
} }
// Find all managed files in the repository and restore symlinks // Find all managed files in the repository and restore symlinks
@@ -515,9 +464,8 @@ func (l *Lnk) RestoreSymlinks() ([]string, error) {
for _, relativePath := range managedItems { for _, relativePath := range managedItems {
// Generate repository name from relative path // Generate repository name from relative path
repoName := generateRepoName(relativePath, l.host)
storagePath := l.getHostStoragePath() storagePath := l.getHostStoragePath()
repoItem := filepath.Join(storagePath, repoName) repoItem := filepath.Join(storagePath, relativePath)
// Check if item exists in repository // Check if item exists in repository
if _, err := os.Stat(repoItem); os.IsNotExist(err) { if _, err := os.Stat(repoItem); os.IsNotExist(err) {
@@ -547,7 +495,7 @@ func (l *Lnk) RestoreSymlinks() ([]string, error) {
// Create symlink // Create symlink
if err := l.fs.CreateSymlink(repoItem, symlinkPath); err != nil { if err := l.fs.CreateSymlink(repoItem, symlinkPath); err != nil {
return nil, fmt.Errorf("failed to create symlink for %s: %w", relativePath, err) return nil, err
} }
restored = append(restored, relativePath) restored = append(restored, relativePath)

View File

@@ -275,7 +275,7 @@ func (suite *CoreTestSuite) TestErrorConditions() {
err = suite.lnk.Add("/nonexistent/file") err = suite.lnk.Add("/nonexistent/file")
suite.Error(err) suite.Error(err)
suite.Contains(err.Error(), "File does not exist") suite.Contains(err.Error(), "File or directory not found")
// Test remove unmanaged file // Test remove unmanaged file
testFile := filepath.Join(suite.tempDir, ".regularfile") testFile := filepath.Join(suite.tempDir, ".regularfile")
@@ -289,7 +289,7 @@ func (suite *CoreTestSuite) TestErrorConditions() {
// Test status without remote // Test status without remote
_, err = suite.lnk.Status() _, err = suite.lnk.Status()
suite.Error(err) suite.Error(err)
suite.Contains(err.Error(), "no remote configured") suite.Contains(err.Error(), "No remote repository is configured")
} }
// Test git operations // Test git operations
@@ -592,7 +592,7 @@ func (suite *CoreTestSuite) TestMultihostFileOperations() {
suite.Require().NoError(err) suite.Require().NoError(err)
// Add file to host-specific configuration // Add file to host-specific configuration
hostLnk := NewLnkWithHost("workstation") hostLnk := NewLnk(WithHost("workstation"))
err = hostLnk.Add(testFile2) err = hostLnk.Add(testFile2)
suite.Require().NoError(err) suite.Require().NoError(err)
@@ -661,7 +661,7 @@ func (suite *CoreTestSuite) TestMultihostSymlinkRestoration() {
suite.Require().NoError(err) suite.Require().NoError(err)
// Create files directly in host-specific storage (simulating a pull) // Create files directly in host-specific storage (simulating a pull)
hostLnk := NewLnkWithHost("testhost") hostLnk := NewLnk(WithHost("testhost"))
// Ensure host storage directory exists // Ensure host storage directory exists
hostStoragePath := hostLnk.getHostStoragePath() hostStoragePath := hostLnk.getHostStoragePath()
@@ -729,7 +729,7 @@ func (suite *CoreTestSuite) TestMultihostIsolation() {
suite.Require().NoError(err) suite.Require().NoError(err)
// Add to host-specific // Add to host-specific
hostLnk := NewLnkWithHost("work") hostLnk := NewLnk(WithHost("work"))
err = hostLnk.Add(testFile) err = hostLnk.Add(testFile)
suite.Require().NoError(err) suite.Require().NoError(err)

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

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

View File

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

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

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

View File

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