From 41584dec6a357ee89d46e5489f145eb8f034524c Mon Sep 17 00:00:00 2001 From: Micheal Wilkinson Date: Thu, 19 Mar 2026 13:44:02 +0000 Subject: [PATCH] chore(go): scaffold module and add failing link tests --- .gitignore | 5 + README.md | 14 ++ cmd/homesick/main.go | 12 ++ go.mod | 13 ++ go.sum | 10 ++ internal/homesick/cli/cli.go | 113 ++++++++++++++++ internal/homesick/core/core.go | 185 +++++++++++++++++++++++++++ internal/homesick/core/core_test.go | 24 ++++ internal/homesick/core/link_test.go | 118 +++++++++++++++++ internal/homesick/version/version.go | 3 + justfile | 23 ++++ script/run-behavior-suite-docker.sh | 11 +- 12 files changed, 529 insertions(+), 2 deletions(-) create mode 100644 cmd/homesick/main.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 internal/homesick/cli/cli.go create mode 100644 internal/homesick/core/core.go create mode 100644 internal/homesick/core/core_test.go create mode 100644 internal/homesick/core/link_test.go create mode 100644 internal/homesick/version/version.go create mode 100644 justfile diff --git a/.gitignore b/.gitignore index bee7964..cfeea56 100644 --- a/.gitignore +++ b/.gitignore @@ -49,5 +49,10 @@ vendor/ homesick*.gem +# Go scaffolding artifacts +dist/ +*.test +*.out + # rbenv configuration .ruby-version diff --git a/README.md b/README.md index 68a398b..99cb8a2 100644 --- a/README.md +++ b/README.md @@ -111,6 +111,20 @@ Run against a future Go binary (example path): The command under test is controlled with the `HOMESICK_CMD` environment variable. By running the same suite for both implementations, you can verify parity at the behavior level. +## Go Scaffold + +Initial Go scaffolding now lives under [cmd/homesick](cmd/homesick) and [internal/homesick](internal/homesick). + +Build the Go binary: + + just go-build + +Run behavior validation against the Go binary: + + HOMESICK_CMD="/workspace/dist/homesick-go" just behavior-go + +At this stage, the Go implementation includes a subset of commands (`clone`, `list`, `show_path`, `status`, `diff`, `version`) and intentionally reports clear errors for commands that are not ported yet. + ## .homesick_subdir `homesick link` basically makes symlink to only first depth in `castle/home`. If you want to link nested files/directories, please use .homesick_subdir. diff --git a/cmd/homesick/main.go b/cmd/homesick/main.go new file mode 100644 index 0000000..3eea38d --- /dev/null +++ b/cmd/homesick/main.go @@ -0,0 +1,12 @@ +package main + +import ( + "os" + + "git.hrafn.xyz/aether/gosick/internal/homesick/cli" +) + +func main() { + exitCode := cli.Run(os.Args[1:], os.Stdout, os.Stderr) + os.Exit(exitCode) +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..bfa5452 --- /dev/null +++ b/go.mod @@ -0,0 +1,13 @@ +module git.hrafn.xyz/aether/gosick + +go 1.26 + +toolchain go1.26.1 + +require github.com/stretchr/testify v1.10.0 + +require ( + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..713a0b4 --- /dev/null +++ b/go.sum @@ -0,0 +1,10 @@ +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/homesick/cli/cli.go b/internal/homesick/cli/cli.go new file mode 100644 index 0000000..ea4cf80 --- /dev/null +++ b/internal/homesick/cli/cli.go @@ -0,0 +1,113 @@ +package cli + +import ( + "fmt" + "io" + "os" + "strings" + + "git.hrafn.xyz/aether/gosick/internal/homesick/core" + "git.hrafn.xyz/aether/gosick/internal/homesick/version" +) + +func Run(args []string, stdout io.Writer, stderr io.Writer) int { + app, err := core.New(stdout, stderr) + if err != nil { + _, _ = fmt.Fprintf(stderr, "error: %v\n", err) + return 1 + } + + if len(args) == 0 { + printHelp(stdout) + return 0 + } + + command := args[0] + switch command { + case "-v", "--version", "version": + if err := app.Version(version.String); err != nil { + _, _ = fmt.Fprintf(stderr, "error: %v\n", err) + return 1 + } + return 0 + case "help", "--help", "-h": + printHelp(stdout) + return 0 + case "clone": + if len(args) < 2 { + _, _ = fmt.Fprintln(stderr, "error: clone requires URI") + return 1 + } + destination := "" + if len(args) > 2 { + destination = args[2] + } + if err := app.Clone(args[1], destination); err != nil { + _, _ = fmt.Fprintf(stderr, "error: %v\n", err) + return 1 + } + return 0 + case "list": + if err := app.List(); err != nil { + _, _ = fmt.Fprintf(stderr, "error: %v\n", err) + return 1 + } + return 0 + case "show_path": + castle := defaultCastle(args) + if err := app.ShowPath(castle); err != nil { + _, _ = fmt.Fprintf(stderr, "error: %v\n", err) + return 1 + } + return 0 + case "status": + castle := defaultCastle(args) + if err := app.Status(castle); err != nil { + _, _ = fmt.Fprintf(stderr, "error: %v\n", err) + return 1 + } + return 0 + case "diff": + castle := defaultCastle(args) + if err := app.Diff(castle); err != nil { + _, _ = fmt.Fprintf(stderr, "error: %v\n", err) + return 1 + } + return 0 + case "link", "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: + _, _ = fmt.Fprintf(stderr, "error: unknown command %q\n\n", command) + printHelp(stderr) + return 1 + } +} + +func defaultCastle(args []string) string { + if len(args) > 1 && strings.TrimSpace(args[1]) != "" { + return args[1] + } + return "dotfiles" +} + +func printHelp(w io.Writer) { + _, _ = fmt.Fprintln(w, "homesick (Go scaffold)") + _, _ = fmt.Fprintln(w, "") + _, _ = fmt.Fprintln(w, "Implemented commands:") + _, _ = fmt.Fprintln(w, " clone URI [CASTLE_NAME]") + _, _ = fmt.Fprintln(w, " list") + _, _ = fmt.Fprintln(w, " show_path [CASTLE]") + _, _ = fmt.Fprintln(w, " status [CASTLE]") + _, _ = fmt.Fprintln(w, " diff [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, "") + _, _ = fmt.Fprintln(w, "Build: go build -o dist/homesick-go ./cmd/homesick") +} + +func init() { + _ = os.Setenv("GIT_TERMINAL_PROMPT", "0") +} diff --git a/internal/homesick/core/core.go b/internal/homesick/core/core.go new file mode 100644 index 0000000..fb0de16 --- /dev/null +++ b/internal/homesick/core/core.go @@ -0,0 +1,185 @@ +package core + +import ( + "errors" + "fmt" + "io" + "io/fs" + "os" + "os/exec" + "path/filepath" + "sort" + "strings" +) + +type App struct { + HomeDir string + ReposDir string + Stdout io.Writer + Stderr io.Writer + Verbose bool +} + +func New(stdout io.Writer, stderr io.Writer) (*App, error) { + home, err := os.UserHomeDir() + if err != nil { + return nil, fmt.Errorf("resolve home directory: %w", err) + } + + return &App{ + HomeDir: home, + ReposDir: filepath.Join(home, ".homesick", "repos"), + Stdout: stdout, + Stderr: stderr, + }, nil +} + +func (a *App) Version(version string) error { + _, err := fmt.Fprintln(a.Stdout, version) + return err +} + +func (a *App) ShowPath(castle string) error { + _, err := fmt.Fprintln(a.Stdout, filepath.Join(a.ReposDir, castle)) + return err +} + +func (a *App) Clone(uri string, destination string) error { + if uri == "" { + return errors.New("clone requires URI") + } + + if destination == "" { + destination = deriveDestination(uri) + } + if destination == "" { + return fmt.Errorf("unable to derive destination from uri %q", uri) + } + + if err := os.MkdirAll(a.ReposDir, 0o755); err != nil { + return fmt.Errorf("create repos directory: %w", err) + } + + destinationPath := filepath.Join(a.ReposDir, destination) + + if fi, err := os.Stat(uri); err == nil && fi.IsDir() { + if err := os.Symlink(uri, destinationPath); err != nil { + return fmt.Errorf("symlink local castle: %w", err) + } + return nil + } + + if err := runGit(a.ReposDir, "clone", "-q", "--config", "push.default=upstream", "--recursive", uri, destination); err != nil { + return err + } + + return nil +} + +func (a *App) List() error { + if err := os.MkdirAll(a.ReposDir, 0o755); err != nil { + return err + } + + var castles []string + err := filepath.WalkDir(a.ReposDir, func(path string, d fs.DirEntry, walkErr error) error { + if walkErr != nil { + return walkErr + } + if !d.IsDir() || d.Name() != ".git" { + return nil + } + + castleRoot := filepath.Dir(path) + rel, err := filepath.Rel(a.ReposDir, castleRoot) + if err != nil { + return err + } + castles = append(castles, rel) + return filepath.SkipDir + }) + if err != nil { + return err + } + + sort.Strings(castles) + for _, castle := range castles { + castleRoot := filepath.Join(a.ReposDir, castle) + remote, remoteErr := gitOutput(castleRoot, "config", "remote.origin.url") + if remoteErr != nil { + remote = "" + } + _, writeErr := fmt.Fprintf(a.Stdout, "%s %s\n", castle, strings.TrimSpace(remote)) + if writeErr != nil { + return writeErr + } + } + + return nil +} + +func (a *App) Status(castle string) error { + return runGit(filepath.Join(a.ReposDir, castle), "status") +} + +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) Unlink(string) error { + return errors.New("unlink is not implemented in Go yet") +} + +func (a *App) Track(string, string) error { + return errors.New("track is not implemented in Go yet") +} + +func runGit(dir string, args ...string) error { + cmd := exec.Command("git", args...) + cmd.Dir = dir + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + if err := cmd.Run(); err != nil { + return fmt.Errorf("git %s failed: %w", strings.Join(args, " "), err) + } + return nil +} + +func gitOutput(dir string, args ...string) (string, error) { + cmd := exec.Command("git", args...) + cmd.Dir = dir + out, err := cmd.Output() + if err != nil { + return "", err + } + return string(out), nil +} + +func deriveDestination(uri string) string { + candidate := strings.TrimSpace(uri) + candidate = strings.TrimPrefix(candidate, "https://github.com/") + candidate = strings.TrimPrefix(candidate, "http://github.com/") + candidate = strings.TrimPrefix(candidate, "git://github.com/") + + if strings.HasPrefix(candidate, "file://") { + candidate = strings.TrimPrefix(candidate, "file://") + } + + candidate = strings.TrimSuffix(candidate, ".git") + candidate = strings.TrimSuffix(candidate, "/") + if candidate == "" { + return "" + } + + parts := strings.Split(candidate, "/") + last := parts[len(parts)-1] + if strings.Contains(last, ":") { + a := strings.Split(last, ":") + last = a[len(a)-1] + } + return last +} diff --git a/internal/homesick/core/core_test.go b/internal/homesick/core/core_test.go new file mode 100644 index 0000000..9dcd918 --- /dev/null +++ b/internal/homesick/core/core_test.go @@ -0,0 +1,24 @@ +package core + +import "testing" + +func TestDeriveDestination(t *testing.T) { + tests := []struct { + name string + uri string + want string + }{ + {name: "github short", uri: "technicalpickles/pickled-vim", want: "pickled-vim"}, + {name: "https", uri: "https://github.com/technicalpickles/pickled-vim.git", want: "pickled-vim"}, + {name: "git ssh", uri: "git@github.com:technicalpickles/pickled-vim.git", want: "pickled-vim"}, + {name: "file", uri: "file:///tmp/dotfiles.git", want: "dotfiles"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := deriveDestination(tt.uri); got != tt.want { + t.Fatalf("deriveDestination(%q) = %q, want %q", tt.uri, got, tt.want) + } + }) + } +} diff --git a/internal/homesick/core/link_test.go b/internal/homesick/core/link_test.go new file mode 100644 index 0000000..066552d --- /dev/null +++ b/internal/homesick/core/link_test.go @@ -0,0 +1,118 @@ +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 LinkSuite struct { + suite.Suite + tmpDir string + homeDir string + reposDir string + app *core.App +} + +func TestLinkSuite(t *testing.T) { + suite.Run(t, new(LinkSuite)) +} + +func (s *LinkSuite) 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 *LinkSuite) createCastle(castle string) string { + castleHome := filepath.Join(s.reposDir, castle, "home") + require.NoError(s.T(), os.MkdirAll(castleHome, 0o755)) + return castleHome +} + +func (s *LinkSuite) 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 *LinkSuite) TestLink_SymlinksTopLevelFiles() { + castleHome := s.createCastle("glencairn") + dotfile := filepath.Join(castleHome, ".vimrc") + s.writeFile(dotfile, "set number\n") + + err := s.app.Link("glencairn") + require.NoError(s.T(), err) + + homePath := filepath.Join(s.homeDir, ".vimrc") + info, err := os.Lstat(homePath) + require.NoError(s.T(), err) + require.True(s.T(), info.Mode()&os.ModeSymlink != 0) + + target, err := os.Readlink(homePath) + require.NoError(s.T(), err) + require.Equal(s.T(), dotfile, target) +} + +func (s *LinkSuite) TestLink_RespectsHomesickSubdir() { + castleHome := s.createCastle("glencairn") + configDir := filepath.Join(castleHome, ".config") + appDir := filepath.Join(configDir, "myapp") + s.writeFile(filepath.Join(appDir, "config.toml"), "ok=true\n") + s.writeFile(filepath.Join(s.reposDir, "glencairn", ".homesick_subdir"), ".config\n") + + err := s.app.Link("glencairn") + require.NoError(s.T(), err) + + configInfo, err := os.Lstat(filepath.Join(s.homeDir, ".config")) + require.NoError(s.T(), err) + require.False(s.T(), configInfo.Mode()&os.ModeSymlink != 0) + + homeApp := filepath.Join(s.homeDir, ".config", "myapp") + appInfo, err := os.Lstat(homeApp) + require.NoError(s.T(), err) + require.True(s.T(), appInfo.Mode()&os.ModeSymlink != 0) + + target, err := os.Readlink(homeApp) + require.NoError(s.T(), err) + require.Equal(s.T(), appDir, target) +} + +func (s *LinkSuite) TestLink_ForceReplacesExistingFile() { + castleHome := s.createCastle("glencairn") + dotfile := filepath.Join(castleHome, ".zshrc") + s.writeFile(dotfile, "export EDITOR=vim\n") + s.writeFile(filepath.Join(s.homeDir, ".zshrc"), "existing\n") + + s.app.Force = true + err := s.app.Link("glencairn") + require.NoError(s.T(), err) + + homePath := filepath.Join(s.homeDir, ".zshrc") + info, err := os.Lstat(homePath) + require.NoError(s.T(), err) + require.True(s.T(), info.Mode()&os.ModeSymlink != 0) +} + +func (s *LinkSuite) TestLink_NoForceErrorsOnConflict() { + castleHome := s.createCastle("glencairn") + dotfile := filepath.Join(castleHome, ".gitconfig") + s.writeFile(dotfile, "[user]\n") + s.writeFile(filepath.Join(s.homeDir, ".gitconfig"), "local\n") + + err := s.app.Link("glencairn") + require.Error(s.T(), err) +} diff --git a/internal/homesick/version/version.go b/internal/homesick/version/version.go new file mode 100644 index 0000000..d0198e7 --- /dev/null +++ b/internal/homesick/version/version.go @@ -0,0 +1,3 @@ +package version + +const String = "1.1.6" diff --git a/justfile b/justfile new file mode 100644 index 0000000..6a3a4ac --- /dev/null +++ b/justfile @@ -0,0 +1,23 @@ +set shell := ["bash", "-eu", "-o", "pipefail", "-c"] + +default: + @just --list + +go-build: + @mkdir -p dist + go build -o dist/homesick-go ./cmd/homesick + +go-test: + go test ./... + +behavior-ruby: + ./script/run-behavior-suite-docker.sh + +behavior-go: go-build + HOMESICK_CMD="/workspace/dist/homesick-go" ./script/run-behavior-suite-docker.sh + +behavior-go-verbose: go-build + HOMESICK_CMD="/workspace/dist/homesick-go" ./script/run-behavior-suite-docker.sh --verbose + +behavior-ruby-verbose: + ./script/run-behavior-suite-docker.sh --verbose diff --git a/script/run-behavior-suite-docker.sh b/script/run-behavior-suite-docker.sh index af33205..e310595 100755 --- a/script/run-behavior-suite-docker.sh +++ b/script/run-behavior-suite-docker.sh @@ -25,14 +25,21 @@ repo_root="$(cd "$script_dir/.." && pwd)" run_docker_build() { echo "Building Docker image for behavior suite..." local build_log + local -a build_cmd + + if docker buildx version >/dev/null 2>&1; then + build_cmd=(docker buildx build --load -f "$repo_root/docker/behavior/Dockerfile" -t homesick-behavior:latest "$repo_root") + else + build_cmd=(docker build -f "$repo_root/docker/behavior/Dockerfile" -t homesick-behavior:latest "$repo_root") + fi if [[ "$behavior_verbose" == "1" ]]; then - docker-buildx build -f "$repo_root/docker/behavior/Dockerfile" -t homesick-behavior:latest "$repo_root" + "${build_cmd[@]}" return fi build_log="$(mktemp)" - if ! docker-buildx build -f "$repo_root/docker/behavior/Dockerfile" -t homesick-behavior:latest "$repo_root" >"$build_log" 2>&1; then + if ! "${build_cmd[@]}" >"$build_log" 2>&1; then cat "$build_log" >&2 rm -f "$build_log" exit 1