diff --git a/internal/homesick/cli/cli.go b/internal/homesick/cli/cli.go index 61f4542..f5a38cb 100644 --- a/internal/homesick/cli/cli.go +++ b/internal/homesick/cli/cli.go @@ -81,7 +81,14 @@ func Run(args []string, stdout io.Writer, stderr io.Writer) int { return 1 } return 0 - case "unlink", "track", "pull", "push", "commit", "destroy", "cd", "open", "exec", "exec_all", "rc", "generate": + case "unlink": + castle := defaultCastle(args) + if err := app.Unlink(castle); err != nil { + _, _ = fmt.Fprintf(stderr, "error: %v\n", err) + return 1 + } + return 0 + case "track", "pull", "push", "commit", "destroy", "cd", "open", "exec", "exec_all", "rc", "generate": _, _ = fmt.Fprintf(stderr, "error: %s is not implemented in Go yet\n", command) return 2 default: @@ -108,10 +115,11 @@ func printHelp(w io.Writer) { _, _ = fmt.Fprintln(w, " status [CASTLE]") _, _ = fmt.Fprintln(w, " diff [CASTLE]") _, _ = fmt.Fprintln(w, " link [CASTLE]") + _, _ = fmt.Fprintln(w, " unlink [CASTLE]") _, _ = fmt.Fprintln(w, " version | -v | --version") _, _ = fmt.Fprintln(w, "") _, _ = fmt.Fprintln(w, "Not implemented yet:") - _, _ = fmt.Fprintln(w, " unlink, track, pull, push, commit, destroy, cd, open, exec, exec_all, rc, generate") + _, _ = fmt.Fprintln(w, " track, pull, push, commit, destroy, cd, open, exec, exec_all, rc, generate") _, _ = fmt.Fprintln(w, "") _, _ = fmt.Fprintln(w, "Build: go build -o dist/homesick-go ./cmd/homesick") } diff --git a/internal/homesick/core/core.go b/internal/homesick/core/core.go index d115de9..734f477 100644 --- a/internal/homesick/core/core.go +++ b/internal/homesick/core/core.go @@ -173,8 +173,44 @@ func (a *App) LinkCastle(castle string) error { return nil } -func (a *App) Unlink(string) error { - return errors.New("unlink is not implemented in Go yet") +func (a *App) Unlink(castle string) error { + if strings.TrimSpace(castle) == "" { + castle = "dotfiles" + } + return a.UnlinkCastle(castle) +} + +func (a *App) UnlinkCastle(castle string) error { + castleHome := filepath.Join(a.ReposDir, castle, "home") + info, err := os.Stat(castleHome) + if err != nil || !info.IsDir() { + return fmt.Errorf("could not symlink %s, expected %s to exist and contain dotfiles", castle, castleHome) + } + + subdirs, err := readSubdirs(filepath.Join(a.ReposDir, castle, ".homesick_subdir")) + if err != nil { + return err + } + + if err := a.unlinkEach(castleHome, castleHome, subdirs); err != nil { + return err + } + + for _, subdir := range subdirs { + base := filepath.Join(castleHome, subdir) + if _, err := os.Stat(base); err != nil { + if errors.Is(err, os.ErrNotExist) { + continue + } + return err + } + + if err := a.unlinkEach(castleHome, base, subdirs); err != nil { + return err + } + } + + return nil } func (a *App) Track(string, string) error { @@ -220,6 +256,61 @@ func (a *App) linkEach(castleHome string, baseDir string, subdirs []string) erro return nil } +func (a *App) unlinkEach(castleHome string, baseDir string, subdirs []string) error { + entries, err := os.ReadDir(baseDir) + if err != nil { + return err + } + + for _, entry := range entries { + name := entry.Name() + if name == "." || name == ".." { + continue + } + + source := filepath.Join(baseDir, name) + ignore, err := matchesIgnoredDir(castleHome, source, subdirs) + if err != nil { + return err + } + if ignore { + continue + } + + relDir, err := filepath.Rel(castleHome, baseDir) + if err != nil { + return err + } + + destination := filepath.Join(a.HomeDir, relDir, name) + if relDir == "." { + destination = filepath.Join(a.HomeDir, name) + } + + if err := unlinkPath(destination); err != nil { + return err + } + } + + return nil +} + +func unlinkPath(destination string) error { + info, err := os.Lstat(destination) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return nil + } + return err + } + + if info.Mode()&os.ModeSymlink == 0 { + return nil + } + + return os.Remove(destination) +} + func (a *App) linkPath(source string, destination string) error { absSource, err := filepath.Abs(source) if err != nil { diff --git a/internal/homesick/core/unlink_test.go b/internal/homesick/core/unlink_test.go new file mode 100644 index 0000000..28d0d09 --- /dev/null +++ b/internal/homesick/core/unlink_test.go @@ -0,0 +1,106 @@ +package core_test + +import ( + "io" + "os" + "path/filepath" + "testing" + + "git.hrafn.xyz/aether/gosick/internal/homesick/core" + "github.com/stretchr/testify/require" + "github.com/stretchr/testify/suite" +) + +type UnlinkSuite struct { + suite.Suite + tmpDir string + homeDir string + reposDir string + app *core.App +} + +func TestUnlinkSuite(t *testing.T) { + suite.Run(t, new(UnlinkSuite)) +} + +func (s *UnlinkSuite) SetupTest() { + s.tmpDir = s.T().TempDir() + s.homeDir = filepath.Join(s.tmpDir, "home") + s.reposDir = filepath.Join(s.homeDir, ".homesick", "repos") + + require.NoError(s.T(), os.MkdirAll(s.reposDir, 0o755)) + + s.app = &core.App{ + HomeDir: s.homeDir, + ReposDir: s.reposDir, + Stdout: io.Discard, + Stderr: io.Discard, + } +} + +func (s *UnlinkSuite) createCastle(castle string) string { + castleHome := filepath.Join(s.reposDir, castle, "home") + require.NoError(s.T(), os.MkdirAll(castleHome, 0o755)) + return castleHome +} + +func (s *UnlinkSuite) writeFile(path string, content string) { + require.NoError(s.T(), os.MkdirAll(filepath.Dir(path), 0o755)) + require.NoError(s.T(), os.WriteFile(path, []byte(content), 0o644)) +} + +func (s *UnlinkSuite) TestUnlink_RemovesTopLevelSymlinks() { + castleHome := s.createCastle("glencairn") + dotfile := filepath.Join(castleHome, ".vimrc") + s.writeFile(dotfile, "set number\n") + + require.NoError(s.T(), s.app.Link("glencairn")) + require.NoError(s.T(), s.app.Unlink("glencairn")) + + require.NoFileExists(s.T(), filepath.Join(s.homeDir, ".vimrc")) +} + +func (s *UnlinkSuite) TestUnlink_RemovesNonDotfileSymlinks() { + castleHome := s.createCastle("glencairn") + binFile := filepath.Join(castleHome, "bin") + s.writeFile(binFile, "#!/usr/bin/env bash\n") + + require.NoError(s.T(), s.app.Link("glencairn")) + require.NoError(s.T(), s.app.Unlink("glencairn")) + + require.NoFileExists(s.T(), filepath.Join(s.homeDir, "bin")) +} + +func (s *UnlinkSuite) TestUnlink_RespectsHomesickSubdir() { + castleHome := s.createCastle("glencairn") + appDir := filepath.Join(castleHome, ".config", "myapp") + s.writeFile(filepath.Join(appDir, "config.toml"), "ok=true\n") + s.writeFile(filepath.Join(s.reposDir, "glencairn", ".homesick_subdir"), ".config\n") + + require.NoError(s.T(), s.app.Link("glencairn")) + require.NoError(s.T(), s.app.Unlink("glencairn")) + + require.DirExists(s.T(), filepath.Join(s.homeDir, ".config")) + require.NoFileExists(s.T(), filepath.Join(s.homeDir, ".config", "myapp")) +} + +func (s *UnlinkSuite) TestUnlink_DefaultCastleName() { + castleHome := s.createCastle("dotfiles") + dotfile := filepath.Join(castleHome, ".zshrc") + s.writeFile(dotfile, "export EDITOR=vim\n") + + require.NoError(s.T(), s.app.Link("dotfiles")) + require.NoError(s.T(), s.app.Unlink("")) + + require.NoFileExists(s.T(), filepath.Join(s.homeDir, ".zshrc")) +} + +func (s *UnlinkSuite) TestUnlink_IgnoresNonSymlinkDestination() { + castleHome := s.createCastle("glencairn") + dotfile := filepath.Join(castleHome, ".gitconfig") + s.writeFile(dotfile, "[user]\n") + s.writeFile(filepath.Join(s.homeDir, ".gitconfig"), "local\n") + + require.NoError(s.T(), s.app.Unlink("glencairn")) + require.FileExists(s.T(), filepath.Join(s.homeDir, ".gitconfig")) +}