From e733dff818bd9668bac4804f975cbe24318217b8 Mon Sep 17 00:00:00 2001 From: Micheal Wilkinson Date: Thu, 19 Mar 2026 13:46:48 +0000 Subject: [PATCH] feat(go): implement link with subdir and force handling --- internal/homesick/cli/cli.go | 12 ++- internal/homesick/core/core.go | 162 ++++++++++++++++++++++++++++++++- 2 files changed, 170 insertions(+), 4 deletions(-) diff --git a/internal/homesick/cli/cli.go b/internal/homesick/cli/cli.go index ea4cf80..61f4542 100644 --- a/internal/homesick/cli/cli.go +++ b/internal/homesick/cli/cli.go @@ -74,7 +74,14 @@ func Run(args []string, stdout io.Writer, stderr io.Writer) int { return 1 } return 0 - case "link", "unlink", "track", "pull", "push", "commit", "destroy", "cd", "open", "exec", "exec_all", "rc", "generate": + case "link": + castle := defaultCastle(args) + if err := app.LinkCastle(castle); err != nil { + _, _ = fmt.Fprintf(stderr, "error: %v\n", err) + return 1 + } + return 0 + case "unlink", "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: @@ -100,10 +107,11 @@ func printHelp(w io.Writer) { _, _ = fmt.Fprintln(w, " show_path [CASTLE]") _, _ = fmt.Fprintln(w, " status [CASTLE]") _, _ = fmt.Fprintln(w, " diff [CASTLE]") + _, _ = fmt.Fprintln(w, " link [CASTLE]") _, _ = fmt.Fprintln(w, " version | -v | --version") _, _ = fmt.Fprintln(w, "") _, _ = fmt.Fprintln(w, "Not implemented yet:") - _, _ = fmt.Fprintln(w, " link, unlink, track, pull, push, commit, destroy, cd, open, exec, exec_all, rc, generate") + _, _ = fmt.Fprintln(w, " unlink, 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 fb0de16..1c14a7f 100644 --- a/internal/homesick/core/core.go +++ b/internal/homesick/core/core.go @@ -18,6 +18,7 @@ type App struct { Stdout io.Writer Stderr io.Writer Verbose bool + Force bool } func New(stdout io.Writer, stderr io.Writer) (*App, error) { @@ -126,8 +127,44 @@ func (a *App) Diff(castle string) error { return runGit(filepath.Join(a.ReposDir, castle), "diff") } -func (a *App) Link(string) error { - return errors.New("link is not implemented in Go yet") +func (a *App) Link(castle string) error { + if strings.TrimSpace(castle) == "" { + castle = "dotfiles" + } + return a.LinkCastle(castle) +} + +func (a *App) LinkCastle(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.linkEach(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.linkEach(castleHome, base, subdirs); err != nil { + return err + } + } + + return nil } func (a *App) Unlink(string) error { @@ -138,6 +175,127 @@ func (a *App) Track(string, string) error { return errors.New("track is not implemented in Go yet") } +func (a *App) linkEach(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 := a.linkPath(source, destination); err != nil { + return err + } + } + + return nil +} + +func (a *App) linkPath(source string, destination string) error { + absSource, err := filepath.Abs(source) + if err != nil { + return err + } + + if err := os.MkdirAll(filepath.Dir(destination), 0o755); err != nil { + return err + } + + info, err := os.Lstat(destination) + if err == nil { + if info.Mode()&os.ModeSymlink != 0 { + target, readErr := os.Readlink(destination) + if readErr == nil && target == absSource { + return nil + } + } + + if !a.Force { + return fmt.Errorf("%s exists", destination) + } + + if rmErr := os.RemoveAll(destination); rmErr != nil { + return rmErr + } + } else if !errors.Is(err, os.ErrNotExist) { + return err + } + + if err := os.Symlink(absSource, destination); err != nil { + return err + } + + return nil +} + +func readSubdirs(path string) ([]string, error) { + data, err := os.ReadFile(path) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return []string{}, nil + } + return nil, err + } + + lines := strings.Split(string(data), "\n") + result := make([]string, 0, len(lines)) + for _, line := range lines { + trimmed := strings.TrimSpace(line) + if trimmed == "" { + continue + } + result = append(result, filepath.Clean(trimmed)) + } + + return result, nil +} + +func matchesIgnoredDir(castleHome string, candidate string, subdirs []string) (bool, error) { + absCandidate, err := filepath.Abs(candidate) + if err != nil { + return false, err + } + + ignoreSet := map[string]struct{}{} + for _, subdir := range subdirs { + clean := filepath.Clean(subdir) + for clean != "." && clean != string(filepath.Separator) { + ignoreSet[filepath.Join(castleHome, clean)] = struct{}{} + next := filepath.Dir(clean) + if next == clean { + break + } + clean = next + } + } + + _, ok := ignoreSet[absCandidate] + return ok, nil +} + func runGit(dir string, args ...string) error { cmd := exec.Command("git", args...) cmd.Dir = dir