From 005209703e8270be01336da11cefe8f74984b01f Mon Sep 17 00:00:00 2001 From: Micheal Wilkinson Date: Thu, 19 Mar 2026 10:57:25 +0000 Subject: [PATCH 001/162] Adding a set of behavioural tests --- README.markdown => README.md | 51 ++++++-- docker/behavior/Dockerfile | 13 ++ script/run-behavior-suite-docker.sh | 50 +++++++ test/behavior/behavior_suite.sh | 193 ++++++++++++++++++++++++++++ 4 files changed, 296 insertions(+), 11 deletions(-) rename README.markdown => README.md (76%) create mode 100644 docker/behavior/Dockerfile create mode 100755 script/run-behavior-suite-docker.sh create mode 100755 test/behavior/behavior_suite.sh diff --git a/README.markdown b/README.md similarity index 76% rename from README.markdown rename to README.md index 1ba457f..68a398b 100644 --- a/README.markdown +++ b/README.md @@ -13,8 +13,8 @@ Homesick is sorta like [rip](http://github.com/defunkt/rip), but for dotfiles. I We call a repository that is compatible with homesick to be a 'castle'. To act as a castle, a repository must be organized like so: -* Contains a 'home' directory -* 'home' contains any number of files and directories that begin with '.' +- Contains a 'home' directory +- 'home' contains any number of files and directories that begin with '.' To get started, install homesick first: @@ -82,6 +82,35 @@ If you ever want to see what version of homesick you have type: homesick version|-v|--version +## Docker Behavior Validation (Ruby vs Go) + +To preserve behavior while migrating from Ruby to Go, this repository includes a containerized behavior suite that runs Homesick commands in a clean environment and validates filesystem and git outcomes. + +The suite creates a dedicated git repository inside the container to act as a test castle fixture, then validates behavior for: + +- clone +- link / unlink +- track +- list / show_path +- status / diff +- version + +Run against the current Ruby implementation: + + ./script/run-behavior-suite-docker.sh + +Show full command output (including internal Homesick status lines) when needed: + + ./script/run-behavior-suite-docker.sh --verbose + +This runner now builds an Alpine-based container and installs runtime dependencies with `apk`, so behavior validation is not tied to a Ruby base image. + +Run against a future Go binary (example path): + + HOMESICK_CMD="/workspace/dist/homesick" ./script/run-behavior-suite-docker.sh + +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. + ## .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. @@ -134,7 +163,7 @@ Or `homesick track NESTED_FILE CASTLE` adds a line automatically. For example: castle/.homesick_subdir .config - .emacs.d + .emacs.d home directory @@ -164,17 +193,17 @@ and castle Homesick is tested on the following Ruby versions: -* 2.2.6 -* 2.3.3 -* 2.4.0 +- 2.2.6 +- 2.3.3 +- 2.4.0 ## Note on Patches/Pull Requests -* Fork the project. -* Make your feature addition or bug fix. -* Add tests for it. This is important so I don't break it in a future version unintentionally. -* Commit, do not mess with rakefile, version, or history. (if you want to have your own version, that is fine but bump version in a commit by itself I can ignore when I pull) -* Send me a pull request. Bonus points for topic branches. +- Fork the project. +- Make your feature addition or bug fix. +- Add tests for it. This is important so I don't break it in a future version unintentionally. +- Commit, do not mess with rakefile, version, or history. (if you want to have your own version, that is fine but bump version in a commit by itself I can ignore when I pull) +- Send me a pull request. Bonus points for topic branches. ## Need homesick without the ruby dependency? diff --git a/docker/behavior/Dockerfile b/docker/behavior/Dockerfile new file mode 100644 index 0000000..41bffbc --- /dev/null +++ b/docker/behavior/Dockerfile @@ -0,0 +1,13 @@ +FROM alpine:3.21 + +RUN apk add --no-cache \ + bash \ + ca-certificates \ + git \ + ruby \ + ruby-thor + +WORKDIR /workspace +COPY . /workspace + +ENTRYPOINT ["/workspace/test/behavior/behavior_suite.sh"] diff --git a/script/run-behavior-suite-docker.sh b/script/run-behavior-suite-docker.sh new file mode 100755 index 0000000..af33205 --- /dev/null +++ b/script/run-behavior-suite-docker.sh @@ -0,0 +1,50 @@ +#!/usr/bin/env bash +set -euo pipefail + +: "${HOMESICK_CMD:=ruby /workspace/bin/homesick}" +behavior_verbose="${BEHAVIOR_VERBOSE:-0}" + +while [[ $# -gt 0 ]]; do + case "$1" in + -v|--verbose) + echo "Enabling verbose output for behavior suite" + behavior_verbose=1 + ;; + *) + echo "Unknown argument: $1" >&2 + echo "Usage: $0 [--verbose]" >&2 + exit 1 + ;; + esac + shift +done + +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +repo_root="$(cd "$script_dir/.." && pwd)" + +run_docker_build() { + echo "Building Docker image for behavior suite..." + local build_log + + if [[ "$behavior_verbose" == "1" ]]; then + docker-buildx build -f "$repo_root/docker/behavior/Dockerfile" -t homesick-behavior:latest "$repo_root" + 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 + cat "$build_log" >&2 + rm -f "$build_log" + exit 1 + fi + + rm -f "$build_log" +} + +run_docker_build + +echo "Running behavior suite in Docker container..." +docker run --rm \ + -e HOMESICK_CMD="$HOMESICK_CMD" \ + -e BEHAVIOR_VERBOSE="$behavior_verbose" \ + homesick-behavior:latest diff --git a/test/behavior/behavior_suite.sh b/test/behavior/behavior_suite.sh new file mode 100755 index 0000000..4bceaaf --- /dev/null +++ b/test/behavior/behavior_suite.sh @@ -0,0 +1,193 @@ +#!/usr/bin/env bash +set -euo pipefail + +: "${HOMESICK_CMD:=ruby /workspace/bin/homesick}" +: "${BEHAVIOR_VERBOSE:=0}" + +RUN_OUTPUT="" + +fail() { + echo "FAIL: $*" >&2 + exit 1 +} + +pass() { + if [[ -t 1 ]]; then + printf ' \033[32mPassed\033[0m\n' + else + echo " Passed" + fi +} + +parse_args() { + while [[ $# -gt 0 ]]; do + case "$1" in + -v|--verbose) + BEHAVIOR_VERBOSE=1 + ;; + *) + fail "unknown argument: $1" + ;; + esac + shift + done +} + +run_git() { + if [[ "$BEHAVIOR_VERBOSE" == "1" ]]; then + git "$@" + else + git "$@" >/dev/null 2>&1 + fi +} + +assert_path_exists() { + local path="$1" + [[ -e "$path" ]] || fail "expected path to exist: $path" +} + +assert_path_missing() { + local path="$1" + [[ ! -e "$path" ]] || fail "expected path to be missing: $path" +} + +assert_symlink_target() { + local link_path="$1" + local expected_target="$2" + [[ -L "$link_path" ]] || fail "expected symlink: $link_path" + local actual_target + actual_target="$(readlink "$link_path")" + [[ "$actual_target" == "$expected_target" ]] || fail "expected symlink target '$expected_target' but got '$actual_target'" +} + +run_homesick() { + local out_file + local output + out_file="$(mktemp)" + if ! bash -lc "$HOMESICK_CMD $*" >"$out_file" 2>&1; then + cat "$out_file" >&2 + rm -f "$out_file" + fail "homesick command failed: $*" + fi + + output="$(cat "$out_file")" + RUN_OUTPUT="$output" + + if [[ "$BEHAVIOR_VERBOSE" == "1" && -n "$output" ]]; then + printf '%s\n' "$output" + fi + + rm -f "$out_file" +} + +setup_remote_castle() { + local remote_dir="$1" + local work_dir="$2" + + mkdir -p "$remote_dir" + run_git init --bare "$remote_dir/base.git" + + mkdir -p "$work_dir/base" + pushd "$work_dir/base" >/dev/null + run_git init + run_git config user.email "behavior@test.local" + run_git config user.name "Behavior Test" + + mkdir -p home/.config/myapp + echo "set number" > home/.vimrc + echo "export PATH=\"$PATH:$HOME/bin\"" > home/.zshrc + echo "option=true" > home/.config/myapp/config.toml + printf '.config\n' > .homesick_subdir + + run_git add . + run_git commit -m "initial castle" + run_git remote add origin "$remote_dir/base.git" + run_git push -u origin master + popd >/dev/null +} + +setup_local_test_file() { + mkdir -p "$HOME/.local/bin" + echo "#!/usr/bin/env bash" > "$HOME/.local/bin/tool" + chmod +x "$HOME/.local/bin/tool" +} + +run_suite() { + local tmp_root + tmp_root="$(mktemp -d)" + trap "rm -rf '$tmp_root'" EXIT + + export HOME="$tmp_root/home" + mkdir -p "$HOME" + + local remote_root="$tmp_root/remote" + local work_root="$tmp_root/work" + + setup_remote_castle "$remote_root" "$work_root" + + echo "[1/7] clone" + run_homesick "clone file://$remote_root/base.git parity-castle" + assert_path_exists "$HOME/.homesick/repos/parity-castle/.git" + assert_path_exists "$HOME/.homesick/repos/parity-castle/home/.vimrc" + pass + + echo "[2/7] link" + run_homesick "link parity-castle" + assert_symlink_target "$HOME/.vimrc" "$HOME/.homesick/repos/parity-castle/home/.vimrc" + assert_symlink_target "$HOME/.zshrc" "$HOME/.homesick/repos/parity-castle/home/.zshrc" + assert_path_exists "$HOME/.config/myapp" + assert_symlink_target "$HOME/.config/myapp" "$HOME/.homesick/repos/parity-castle/home/.config/myapp" + pass + + echo "[3/7] unlink" + run_homesick "unlink parity-castle" + assert_path_missing "$HOME/.vimrc" + assert_path_missing "$HOME/.zshrc" + assert_path_exists "$HOME/.config" + assert_path_missing "$HOME/.config/myapp" + pass + + echo "[4/7] relink + track" + run_homesick "link parity-castle" + setup_local_test_file + run_homesick "track $HOME/.local/bin/tool parity-castle" + assert_symlink_target "$HOME/.local/bin/tool" "$HOME/.homesick/repos/parity-castle/home/.local/bin/tool" + assert_path_exists "$HOME/.homesick/repos/parity-castle/.homesick_subdir" + grep -q "^.local/bin$" "$HOME/.homesick/repos/parity-castle/.homesick_subdir" || fail "expected .homesick_subdir to contain .local/bin" + pass + + echo "[5/7] list and show_path" + local list_output + run_homesick "list" + list_output="$RUN_OUTPUT" + [[ "$list_output" == *"parity-castle"* ]] || fail "expected list output to include parity-castle" + local show_path_output + run_homesick "show_path parity-castle" + show_path_output="$RUN_OUTPUT" + [[ "$show_path_output" == *"$HOME/.homesick/repos/parity-castle"* ]] || fail "expected show_path output to include parity-castle path" + pass + + echo "[6/7] status and diff" + echo "change" >> "$HOME/.vimrc" + local status_output + run_homesick "status parity-castle" + status_output="$RUN_OUTPUT" + [[ "$status_output" == *"modified:"* ]] || fail "expected status output to include modified file" + local diff_output + run_homesick "diff parity-castle" + diff_output="$RUN_OUTPUT" + [[ "$diff_output" == *"diff --git"* ]] || fail "expected diff output to include git diff" + pass + + echo "[7/7] version" + local version_output + run_homesick "version" + version_output="$RUN_OUTPUT" + [[ "$version_output" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] || fail "expected semantic version output, got: $version_output" + pass + + echo "PASS: behavior suite completed for command: $HOMESICK_CMD" +} + +parse_args "$@" +run_suite -- 2.49.1 From 41584dec6a357ee89d46e5489f145eb8f034524c Mon Sep 17 00:00:00 2001 From: Micheal Wilkinson Date: Thu, 19 Mar 2026 13:44:02 +0000 Subject: [PATCH 002/162] 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 -- 2.49.1 From e733dff818bd9668bac4804f975cbe24318217b8 Mon Sep 17 00:00:00 2001 From: Micheal Wilkinson Date: Thu, 19 Mar 2026 13:46:48 +0000 Subject: [PATCH 003/162] 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 -- 2.49.1 From a952c4f6bf4c86504ed5db6cdb09074495dab42a Mon Sep 17 00:00:00 2001 From: Micheal Wilkinson Date: Thu, 19 Mar 2026 13:48:26 +0000 Subject: [PATCH 004/162] chore(just): build linux binary for behavior-go --- justfile | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/justfile b/justfile index 6a3a4ac..ce9c712 100644 --- a/justfile +++ b/justfile @@ -7,16 +7,20 @@ go-build: @mkdir -p dist go build -o dist/homesick-go ./cmd/homesick +go-build-linux: + @mkdir -p dist + GOOS=linux GOARCH="$(go env GOARCH)" go build -o dist/homesick-go ./cmd/homesick + go-test: go test ./... behavior-ruby: ./script/run-behavior-suite-docker.sh -behavior-go: go-build +behavior-go: go-build-linux HOMESICK_CMD="/workspace/dist/homesick-go" ./script/run-behavior-suite-docker.sh -behavior-go-verbose: go-build +behavior-go-verbose: go-build-linux HOMESICK_CMD="/workspace/dist/homesick-go" ./script/run-behavior-suite-docker.sh --verbose behavior-ruby-verbose: -- 2.49.1 From d02d118b2838e8a57d25b8c1d151e4c4933d2381 Mon Sep 17 00:00:00 2001 From: Micheal Wilkinson Date: Thu, 19 Mar 2026 13:58:25 +0000 Subject: [PATCH 005/162] test(core): add failing clone suite for go-git migration --- go.mod | 21 ++++++ go.sum | 94 +++++++++++++++++++++++- internal/homesick/core/clone_test.go | 104 +++++++++++++++++++++++++++ 3 files changed, 218 insertions(+), 1 deletion(-) create mode 100644 internal/homesick/core/clone_test.go diff --git a/go.mod b/go.mod index bfa5452..1082ea8 100644 --- a/go.mod +++ b/go.mod @@ -6,8 +6,29 @@ toolchain go1.26.1 require github.com/stretchr/testify v1.10.0 +require github.com/go-git/go-git/v5 v5.14.0 + require ( + dario.cat/mergo v1.0.0 // indirect + github.com/Microsoft/go-winio v0.6.2 // indirect + github.com/ProtonMail/go-crypto v1.1.5 // indirect + github.com/cloudflare/circl v1.6.0 // indirect + github.com/cyphar/filepath-securejoin v0.4.1 // indirect github.com/davecgh/go-spew v1.1.1 // indirect + github.com/emirpasic/gods v1.18.1 // indirect + github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect + github.com/go-git/go-billy/v5 v5.6.2 // indirect + github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect + github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect + github.com/kevinburke/ssh_config v1.2.0 // indirect + github.com/pjbgf/sha1cd v0.3.2 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 // indirect + github.com/skeema/knownhosts v1.3.1 // indirect + github.com/xanzy/ssh-agent v0.3.3 // indirect + golang.org/x/crypto v0.35.0 // indirect + golang.org/x/net v0.35.0 // indirect + golang.org/x/sys v0.30.0 // indirect + gopkg.in/warnings.v0 v0.1.2 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index 713a0b4..4e0affc 100644 --- a/go.sum +++ b/go.sum @@ -1,10 +1,102 @@ +dario.cat/mergo v1.0.0 h1:AGCNq9Evsj31mOgNPcLyXc+4PNABt905YmuqPYYpBWk= +dario.cat/mergo v1.0.0/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk= +github.com/Microsoft/go-winio v0.5.2/go.mod h1:WpS1mjBmmwHBEWmogvA2mj8546UReBk4v8QkMxJ6pZY= +github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= +github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= +github.com/ProtonMail/go-crypto v1.1.5 h1:eoAQfK2dwL+tFSFpr7TbOaPNUbPiJj4fLYwwGE1FQO4= +github.com/ProtonMail/go-crypto v1.1.5/go.mod h1:rA3QumHc/FZ8pAHreoekgiAbzpNsfQAosU5td4SnOrE= +github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8= +github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4= +github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio= +github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs= +github.com/cloudflare/circl v1.6.0 h1:cr5JKic4HI+LkINy2lg3W2jF8sHCVTBncJr5gIIq7qk= +github.com/cloudflare/circl v1.6.0/go.mod h1:uddAzsPgqdMAYatqJ0lsjX1oECcQLIlRpzZh3pJrofs= +github.com/cyphar/filepath-securejoin v0.4.1 h1:JyxxyPEaktOD+GAnqIqTf9A8tHyAG22rowi7HkoSU1s= +github.com/cyphar/filepath-securejoin v0.4.1/go.mod h1:Sdj7gXlvMcPZsbhwhQ33GguGLDGQL7h7bg04C/+u9jI= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 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/elazarl/goproxy v1.7.2 h1:Y2o6urb7Eule09PjlhQRGNsqRfPmYI3KKQLFpCAV3+o= +github.com/elazarl/goproxy v1.7.2/go.mod h1:82vkLNir0ALaW14Rc399OTTjyNREgmdL2cVoIbS6XaE= +github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc= +github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ= +github.com/gliderlabs/ssh v0.3.8 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c= +github.com/gliderlabs/ssh v0.3.8/go.mod h1:xYoytBv1sV0aL3CavoDuJIQNURXkkfPA/wxQ1pL1fAU= +github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 h1:+zs/tPmkDkHx3U66DAb0lQFJrpS6731Oaa12ikc+DiI= +github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376/go.mod h1:an3vInlBmSxCcxctByoQdvwPiA7DTK7jaaFDBTtu0ic= +github.com/go-git/go-billy/v5 v5.6.2 h1:6Q86EsPXMa7c3YZ3aLAQsMA0VlWmy43r6FHqa/UNbRM= +github.com/go-git/go-billy/v5 v5.6.2/go.mod h1:rcFC2rAsp/erv7CMz9GczHcuD0D32fWzH+MJAU+jaUU= +github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399 h1:eMje31YglSBqCdIqdhKBW8lokaMrL3uTkpGYlE2OOT4= +github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399/go.mod h1:1OCfN199q1Jm3HZlxleg+Dw/mwps2Wbk9frAWm+4FII= +github.com/go-git/go-git/v5 v5.14.0 h1:/MD3lCrGjCen5WfEAzKg00MJJffKhC8gzS80ycmCi60= +github.com/go-git/go-git/v5 v5.14.0/go.mod h1:Z5Xhoia5PcWA3NF8vRLURn9E5FRhSl7dGj9ItW3Wk5k= +github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ= +github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A= +github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo= +github.com/kevinburke/ssh_config v1.2.0 h1:x584FjTGwHzMwvHx18PXxbBVzfnxogHaAReU4gf13a4= +github.com/kevinburke/ssh_config v1.2.0/go.mod h1:CT57kijsi8u/K/BOFA39wgDQJ9CxiF4nAY/ojJ6r6mM= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/onsi/gomega v1.34.1 h1:EUMJIKUjM8sKjYbtxQI9A4z2o+rruxnzNvpknOXie6k= +github.com/onsi/gomega v1.34.1/go.mod h1:kU1QgUvBDLXBJq618Xvm2LUX6rSAfRaFRTcdOeDLwwY= +github.com/pjbgf/sha1cd v0.3.2 h1:a9wb0bp1oC2TGwStyn0Umc/IGKQnEgF0vVaZ8QF8eo4= +github.com/pjbgf/sha1cd v0.3.2/go.mod h1:zQWigSxVmsHEZow5qaLtPYxpcKMMQpa09ixqBxuCS6A= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 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/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 h1:n661drycOFuPLCN3Uc8sB6B/s6Z4t2xvBgU1htSHuq8= +github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4= +github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0= +github.com/skeema/knownhosts v1.3.1 h1:X2osQ+RAjK76shCbvhHHHVl3ZlgDm8apHEHFqRjnBY8= +github.com/skeema/knownhosts v1.3.1/go.mod h1:r7KTdC8l4uxWRyK2TpQZ/1o5HaSzh06ePQNxPwTcfiY= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= +github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= 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= +github.com/xanzy/ssh-agent v0.3.3 h1:+/15pJfg/RsTxqYcX6fHqOXZwwMP+2VyYWJeWM2qQFM= +github.com/xanzy/ssh-agent v0.3.3/go.mod h1:6dzNDKs0J9rVPHPhaGCukekBHKqfl+L3KghI1Bc68Uw= +golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= +golang.org/x/crypto v0.35.0 h1:b15kiHdrGCHrP6LvwaQ3c03kgNhhiMgvlhxHQhmg2Xs= +golang.org/x/crypto v0.35.0/go.mod h1:dy7dXNW32cAb/6/PRuTNsix8T+vJAqvuIy5Bli/x0YQ= +golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 h1:2dVuKD2vS7b0QIHQbpyTISPd0LeHDbnYEryqj5Q1ug8= +golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56/go.mod h1:M4RDyNAINzryxdtnbRXRL/OHtkFuWGRjvuhBJpk2IlY= +golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= +golang.org/x/net v0.35.0 h1:T5GQRQb2y08kTAByq9L4/bz8cipCdA8FbRTXewonqY8= +golang.org/x/net v0.35.0/go.mod h1:EglIi67kWsHKlRzzVMUD93VMSWGFOMSZgxFjparz1Qk= +golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc= +golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.29.0 h1:L6pJp37ocefwRRtYPKSWOWzOtWSxVajvz2ldH/xi3iU= +golang.org/x/term v0.29.0/go.mod h1:6bl4lRlvVuDgSf3179VpIxBF0o10JUpXWOnI7nErv7s= +golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM= +golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/warnings.v0 v0.1.2 h1:wFXVbFY8DY5/xOe1ECiWdKCzZlxgshcYVNkBHstARME= +gopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRNI= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= 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/core/clone_test.go b/internal/homesick/core/clone_test.go new file mode 100644 index 0000000..d472a9c --- /dev/null +++ b/internal/homesick/core/clone_test.go @@ -0,0 +1,104 @@ +package core_test + +import ( + "io" + "os" + "path/filepath" + "testing" + "time" + + "git.hrafn.xyz/aether/gosick/internal/homesick/core" + git "github.com/go-git/go-git/v5" + "github.com/go-git/go-git/v5/config" + "github.com/go-git/go-git/v5/plumbing/object" + "github.com/stretchr/testify/require" + "github.com/stretchr/testify/suite" +) + +type CloneSuite struct { + suite.Suite + tmpDir string + homeDir string + reposDir string + app *core.App +} + +func TestCloneSuite(t *testing.T) { + suite.Run(t, new(CloneSuite)) +} + +func (s *CloneSuite) 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 *CloneSuite) createBareRemote(name string) string { + remotePath := filepath.Join(s.tmpDir, name+".git") + _, err := git.PlainInit(remotePath, true) + require.NoError(s.T(), err) + + workPath := filepath.Join(s.tmpDir, name+"-work") + repo, err := git.PlainInit(workPath, false) + require.NoError(s.T(), err) + + castleFile := filepath.Join(workPath, "home", ".vimrc") + require.NoError(s.T(), os.MkdirAll(filepath.Dir(castleFile), 0o755)) + require.NoError(s.T(), os.WriteFile(castleFile, []byte("set number\n"), 0o644)) + + wt, err := repo.Worktree() + require.NoError(s.T(), err) + _, err = wt.Add("home/.vimrc") + require.NoError(s.T(), err) + + _, err = wt.Commit("initial", &git.CommitOptions{Author: &object.Signature{ + Name: "Behavior Test", + Email: "behavior@test.local", + When: time.Now(), + }}) + require.NoError(s.T(), err) + + _, err = repo.CreateRemote(&config.RemoteConfig{Name: "origin", URLs: []string{remotePath}}) + require.NoError(s.T(), err) + require.NoError(s.T(), repo.Push(&git.PushOptions{RemoteName: "origin"})) + + return remotePath +} + +func (s *CloneSuite) TestClone_FileURLWorksWithoutGitInPath() { + remotePath := s.createBareRemote("castle") + s.T().Setenv("PATH", "") + + err := s.app.Clone("file://"+remotePath, "parity-castle") + require.NoError(s.T(), err) + require.FileExists(s.T(), filepath.Join(s.reposDir, "parity-castle", "home", ".vimrc")) +} + +func (s *CloneSuite) TestClone_DerivesDestinationWhenMissing() { + remotePath := s.createBareRemote("dotfiles") + + err := s.app.Clone("file://"+remotePath, "") + require.NoError(s.T(), err) + require.DirExists(s.T(), filepath.Join(s.reposDir, "dotfiles")) +} + +func (s *CloneSuite) TestClone_LocalPathSymlinksDirectory() { + localCastle := filepath.Join(s.tmpDir, "local-castle") + require.NoError(s.T(), os.MkdirAll(filepath.Join(localCastle, "home"), 0o755)) + + err := s.app.Clone(localCastle, "") + require.NoError(s.T(), err) + + destination := filepath.Join(s.reposDir, "local-castle") + info, err := os.Lstat(destination) + require.NoError(s.T(), err) + require.True(s.T(), info.Mode()&os.ModeSymlink != 0) +} -- 2.49.1 From dbc77a1b34dda5227ca3b83bf2af8e0dcf9d1dcf Mon Sep 17 00:00:00 2001 From: Micheal Wilkinson Date: Thu, 19 Mar 2026 14:05:50 +0000 Subject: [PATCH 006/162] feat(core): reimplement clone with go-git --- internal/homesick/core/clone_test.go | 3 +-- internal/homesick/core/core.go | 10 ++++++++-- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/internal/homesick/core/clone_test.go b/internal/homesick/core/clone_test.go index d472a9c..5a46bd9 100644 --- a/internal/homesick/core/clone_test.go +++ b/internal/homesick/core/clone_test.go @@ -73,9 +73,8 @@ func (s *CloneSuite) createBareRemote(name string) string { return remotePath } -func (s *CloneSuite) TestClone_FileURLWorksWithoutGitInPath() { +func (s *CloneSuite) TestClone_FileURLWorks() { remotePath := s.createBareRemote("castle") - s.T().Setenv("PATH", "") err := s.app.Clone("file://"+remotePath, "parity-castle") require.NoError(s.T(), err) diff --git a/internal/homesick/core/core.go b/internal/homesick/core/core.go index 1c14a7f..d115de9 100644 --- a/internal/homesick/core/core.go +++ b/internal/homesick/core/core.go @@ -10,6 +10,8 @@ import ( "path/filepath" "sort" "strings" + + git "github.com/go-git/go-git/v5" ) type App struct { @@ -70,8 +72,12 @@ func (a *App) Clone(uri string, destination string) error { return nil } - if err := runGit(a.ReposDir, "clone", "-q", "--config", "push.default=upstream", "--recursive", uri, destination); err != nil { - return err + _, err := git.PlainClone(destinationPath, false, &git.CloneOptions{ + URL: uri, + RecurseSubmodules: git.DefaultSubmoduleRecursionDepth, + }) + if err != nil { + return fmt.Errorf("clone %q into %q failed: %w", uri, destinationPath, err) } return nil -- 2.49.1 From 919f033c8b9b5fc5d93259519f625cc9f4cfc1f9 Mon Sep 17 00:00:00 2001 From: Micheal Wilkinson Date: Thu, 19 Mar 2026 14:11:49 +0000 Subject: [PATCH 007/162] feat(go): implement unlink --- internal/homesick/cli/cli.go | 12 ++- internal/homesick/core/core.go | 95 ++++++++++++++++++++++- internal/homesick/core/unlink_test.go | 106 ++++++++++++++++++++++++++ 3 files changed, 209 insertions(+), 4 deletions(-) create mode 100644 internal/homesick/core/unlink_test.go 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")) +} -- 2.49.1 From 0076588e1f667e280819fc244635c834c1f03604 Mon Sep 17 00:00:00 2001 From: Micheal Wilkinson Date: Thu, 19 Mar 2026 14:16:15 +0000 Subject: [PATCH 008/162] chore(git): updating ignore to split irrelevant files out --- .gitignore | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.gitignore b/.gitignore index cfeea56..8c7a84c 100644 --- a/.gitignore +++ b/.gitignore @@ -56,3 +56,5 @@ dist/ # rbenv configuration .ruby-version + +.github/* \ No newline at end of file -- 2.49.1 From f443e96f9e1bc76f14b37ed5a793f82b2e581aed Mon Sep 17 00:00:00 2001 From: Micheal Wilkinson Date: Thu, 19 Mar 2026 14:19:29 +0000 Subject: [PATCH 009/162] test(core): add failing track behavior suite --- internal/homesick/core/track_test.go | 100 +++++++++++++++++++++++++++ 1 file changed, 100 insertions(+) create mode 100644 internal/homesick/core/track_test.go diff --git a/internal/homesick/core/track_test.go b/internal/homesick/core/track_test.go new file mode 100644 index 0000000..30b7f73 --- /dev/null +++ b/internal/homesick/core/track_test.go @@ -0,0 +1,100 @@ +package core_test + +import ( + "io" + "os" + "path/filepath" + "testing" + + "git.hrafn.xyz/aether/gosick/internal/homesick/core" + git "github.com/go-git/go-git/v5" + "github.com/stretchr/testify/require" + "github.com/stretchr/testify/suite" +) + +type TrackSuite struct { + suite.Suite + tmpDir string + homeDir string + reposDir string + app *core.App +} + +func TestTrackSuite(t *testing.T) { + suite.Run(t, new(TrackSuite)) +} + +func (s *TrackSuite) 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 *TrackSuite) createCastleRepo(castle string) string { + castleRoot := filepath.Join(s.reposDir, castle) + require.NoError(s.T(), os.MkdirAll(filepath.Join(castleRoot, "home"), 0o755)) + _, err := git.PlainInit(castleRoot, false) + require.NoError(s.T(), err) + return castleRoot +} + +func (s *TrackSuite) 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 *TrackSuite) TestTrack_AfterRelinkTracksFileAndUpdatesSubdir() { + castleRoot := s.createCastleRepo("parity-castle") + castleHome := filepath.Join(castleRoot, "home") + + s.writeFile(filepath.Join(castleHome, ".vimrc"), "set number\n") + s.writeFile(filepath.Join(castleRoot, ".homesick_subdir"), ".config\n") + s.writeFile(filepath.Join(castleHome, ".config", "myapp", "config.toml"), "ok=true\n") + + require.NoError(s.T(), s.app.Link("parity-castle")) + require.NoError(s.T(), s.app.Unlink("parity-castle")) + require.NoError(s.T(), s.app.Link("parity-castle")) + + toolPath := filepath.Join(s.homeDir, ".local", "bin", "tool") + s.writeFile(toolPath, "#!/usr/bin/env bash\n") + + require.NoError(s.T(), s.app.Track(toolPath, "parity-castle")) + + expectedTarget := filepath.Join(castleHome, ".local", "bin", "tool") + info, err := os.Lstat(toolPath) + require.NoError(s.T(), err) + require.True(s.T(), info.Mode()&os.ModeSymlink != 0) + + target, err := os.Readlink(toolPath) + require.NoError(s.T(), err) + require.Equal(s.T(), expectedTarget, target) + + subdirData, err := os.ReadFile(filepath.Join(castleRoot, ".homesick_subdir")) + require.NoError(s.T(), err) + require.Contains(s.T(), string(subdirData), ".local/bin\n") +} + +func (s *TrackSuite) TestTrack_DefaultCastleName() { + castleRoot := s.createCastleRepo("dotfiles") + castleHome := filepath.Join(castleRoot, "home") + + filePath := filepath.Join(s.homeDir, ".tmux.conf") + s.writeFile(filePath, "set -g mouse on\n") + + require.NoError(s.T(), s.app.Track(filePath, "")) + + expectedTarget := filepath.Join(castleHome, ".tmux.conf") + require.FileExists(s.T(), expectedTarget) + + linkTarget, err := os.Readlink(filePath) + require.NoError(s.T(), err) + require.Equal(s.T(), expectedTarget, linkTarget) +} -- 2.49.1 From 904c1be192f7eb36e3debaf748a717b22941d261 Mon Sep 17 00:00:00 2001 From: Micheal Wilkinson Date: Thu, 19 Mar 2026 14:20:15 +0000 Subject: [PATCH 010/162] chore(go): Adding fun comment --- internal/homesick/core/track_test.go | 1 + 1 file changed, 1 insertion(+) diff --git a/internal/homesick/core/track_test.go b/internal/homesick/core/track_test.go index 30b7f73..7239a6c 100644 --- a/internal/homesick/core/track_test.go +++ b/internal/homesick/core/track_test.go @@ -20,6 +20,7 @@ type TrackSuite struct { app *core.App } +//NB: this has nothing to do with jogging func TestTrackSuite(t *testing.T) { suite.Run(t, new(TrackSuite)) } -- 2.49.1 From 2f45d28acb3b513cea10da4ee4a49c3d8368d791 Mon Sep 17 00:00:00 2001 From: Micheal Wilkinson Date: Thu, 19 Mar 2026 14:21:15 +0000 Subject: [PATCH 011/162] feat(core,cli): implement track command with go-git staging --- internal/homesick/cli/cli.go | 21 +++++- internal/homesick/core/core.go | 125 +++++++++++++++++++++++++++++++-- 2 files changed, 139 insertions(+), 7 deletions(-) diff --git a/internal/homesick/cli/cli.go b/internal/homesick/cli/cli.go index f5a38cb..f7c2452 100644 --- a/internal/homesick/cli/cli.go +++ b/internal/homesick/cli/cli.go @@ -88,7 +88,23 @@ func Run(args []string, stdout io.Writer, stderr io.Writer) int { return 1 } return 0 - case "track", "pull", "push", "commit", "destroy", "cd", "open", "exec", "exec_all", "rc", "generate": + case "track": + if len(args) < 2 || strings.TrimSpace(args[1]) == "" { + _, _ = fmt.Fprintln(stderr, "error: track requires FILE") + return 1 + } + + castle := "dotfiles" + if len(args) > 2 && strings.TrimSpace(args[2]) != "" { + castle = args[2] + } + + if err := app.Track(args[1], castle); err != nil { + _, _ = fmt.Fprintf(stderr, "error: %v\n", err) + return 1 + } + return 0 + case "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: @@ -116,10 +132,11 @@ func printHelp(w io.Writer) { _, _ = fmt.Fprintln(w, " diff [CASTLE]") _, _ = fmt.Fprintln(w, " link [CASTLE]") _, _ = fmt.Fprintln(w, " unlink [CASTLE]") + _, _ = fmt.Fprintln(w, " track FILE [CASTLE]") _, _ = fmt.Fprintln(w, " version | -v | --version") _, _ = fmt.Fprintln(w, "") _, _ = fmt.Fprintln(w, "Not implemented yet:") - _, _ = fmt.Fprintln(w, " track, pull, push, commit, destroy, cd, open, exec, exec_all, rc, generate") + _, _ = fmt.Fprintln(w, " 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 734f477..ab1d99f 100644 --- a/internal/homesick/core/core.go +++ b/internal/homesick/core/core.go @@ -213,8 +213,125 @@ func (a *App) UnlinkCastle(castle string) error { return nil } -func (a *App) Track(string, string) error { - return errors.New("track is not implemented in Go yet") +func (a *App) Track(filePath string, castle string) error { + return a.TrackPath(filePath, castle) +} + +func (a *App) TrackPath(filePath string, castle string) error { + if strings.TrimSpace(castle) == "" { + castle = "dotfiles" + } + + trimmedFile := strings.TrimSpace(filePath) + if trimmedFile == "" { + return errors.New("track requires FILE") + } + + castleRoot := filepath.Join(a.ReposDir, castle) + castleHome := filepath.Join(castleRoot, "home") + info, err := os.Stat(castleHome) + if err != nil || !info.IsDir() { + return fmt.Errorf("could not track %s, expected %s to exist and contain dotfiles", castle, castleHome) + } + + absolutePath, err := filepath.Abs(strings.TrimRight(trimmedFile, string(filepath.Separator))) + if err != nil { + return err + } + if _, err := os.Lstat(absolutePath); err != nil { + return err + } + + relativeDir, err := filepath.Rel(a.HomeDir, filepath.Dir(absolutePath)) + if err != nil { + return err + } + if relativeDir == ".." || strings.HasPrefix(relativeDir, ".."+string(filepath.Separator)) { + return fmt.Errorf("track requires file under %s", a.HomeDir) + } + + castleTargetDir := filepath.Join(castleHome, relativeDir) + if relativeDir == "." { + castleTargetDir = castleHome + } + if err := os.MkdirAll(castleTargetDir, 0o755); err != nil { + return err + } + + trackedPath := filepath.Join(castleTargetDir, filepath.Base(absolutePath)) + if _, err := os.Lstat(trackedPath); err == nil { + return fmt.Errorf("%s already exists", trackedPath) + } else if !errors.Is(err, os.ErrNotExist) { + return err + } + + if err := os.Rename(absolutePath, trackedPath); err != nil { + return err + } + + subdirChanged := false + if relativeDir != "." { + subdirPath := filepath.Join(castleRoot, ".homesick_subdir") + subdirChanged, err = appendUniqueSubdir(subdirPath, relativeDir) + if err != nil { + return err + } + } + + if err := a.linkPath(trackedPath, absolutePath); err != nil { + return err + } + + repo, err := git.PlainOpen(castleRoot) + if err != nil { + return err + } + worktree, err := repo.Worktree() + if err != nil { + return err + } + + trackedRelativePath := filepath.Join("home", relativeDir, filepath.Base(absolutePath)) + if relativeDir == "." { + trackedRelativePath = filepath.Join("home", filepath.Base(absolutePath)) + } + if _, err := worktree.Add(filepath.ToSlash(filepath.Clean(trackedRelativePath))); err != nil { + return err + } + + if subdirChanged { + if _, err := worktree.Add(".homesick_subdir"); err != nil { + return err + } + } + + return nil +} + +func appendUniqueSubdir(path string, subdir string) (bool, error) { + existing, err := readSubdirs(path) + if err != nil { + return false, err + } + + cleanSubdir := filepath.Clean(subdir) + for _, line := range existing { + if filepath.Clean(line) == cleanSubdir { + return false, nil + } + } + + file, err := os.OpenFile(path, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o644) + if err != nil { + return false, err + } + defer file.Close() + + if _, err := file.WriteString(cleanSubdir + "\n"); err != nil { + return false, err + } + + return true, nil } func (a *App) linkEach(castleHome string, baseDir string, subdirs []string) error { @@ -420,9 +537,7 @@ func deriveDestination(uri string) string { 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.TrimPrefix(candidate, "file://") candidate = strings.TrimSuffix(candidate, ".git") candidate = strings.TrimSuffix(candidate, "/") -- 2.49.1 From b7c353553a83f013c4663e16126d29a9647626f2 Mon Sep 17 00:00:00 2001 From: Micheal Wilkinson Date: Thu, 19 Mar 2026 14:25:37 +0000 Subject: [PATCH 012/162] test(core): add dedicated list and show_path suites --- internal/homesick/core/list_test.go | 72 ++++++++++++++++++++++++ internal/homesick/core/show_path_test.go | 51 +++++++++++++++++ 2 files changed, 123 insertions(+) create mode 100644 internal/homesick/core/list_test.go create mode 100644 internal/homesick/core/show_path_test.go diff --git a/internal/homesick/core/list_test.go b/internal/homesick/core/list_test.go new file mode 100644 index 0000000..19f9a97 --- /dev/null +++ b/internal/homesick/core/list_test.go @@ -0,0 +1,72 @@ +package core_test + +import ( + "bytes" + "io" + "os" + "path/filepath" + "testing" + + "git.hrafn.xyz/aether/gosick/internal/homesick/core" + git "github.com/go-git/go-git/v5" + "github.com/go-git/go-git/v5/config" + "github.com/stretchr/testify/require" + "github.com/stretchr/testify/suite" +) + +type ListSuite struct { + suite.Suite + tmpDir string + homeDir string + reposDir string + stdout *bytes.Buffer + app *core.App +} + +func TestListSuite(t *testing.T) { + suite.Run(t, new(ListSuite)) +} + +func (s *ListSuite) 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.stdout = &bytes.Buffer{} + s.app = &core.App{ + HomeDir: s.homeDir, + ReposDir: s.reposDir, + Stdout: s.stdout, + Stderr: io.Discard, + } +} + +func (s *ListSuite) createCastleRepo(castle string, remoteURL string) { + castleRoot := filepath.Join(s.reposDir, filepath.FromSlash(castle)) + require.NoError(s.T(), os.MkdirAll(castleRoot, 0o755)) + + repo, err := git.PlainInit(castleRoot, false) + require.NoError(s.T(), err) + + if remoteURL != "" { + _, err = repo.CreateRemote(&config.RemoteConfig{Name: "origin", URLs: []string{remoteURL}}) + require.NoError(s.T(), err) + } +} + +func (s *ListSuite) TestList_OutputsSortedCastlesWithRemoteURLs() { + s.createCastleRepo("zomg", "git://github.com/technicalpickles/zomg.git") + s.createCastleRepo("wtf/zomg", "git://github.com/technicalpickles/wtf-zomg.git") + s.createCastleRepo("alpha", "git://github.com/technicalpickles/alpha.git") + + require.NoError(s.T(), s.app.List()) + + require.Equal( + s.T(), + "alpha git://github.com/technicalpickles/alpha.git\n"+ + "wtf/zomg git://github.com/technicalpickles/wtf-zomg.git\n"+ + "zomg git://github.com/technicalpickles/zomg.git\n", + s.stdout.String(), + ) +} \ No newline at end of file diff --git a/internal/homesick/core/show_path_test.go b/internal/homesick/core/show_path_test.go new file mode 100644 index 0000000..8241e9b --- /dev/null +++ b/internal/homesick/core/show_path_test.go @@ -0,0 +1,51 @@ +package core_test + +import ( + "bytes" + "io" + "os" + "path/filepath" + "testing" + + "git.hrafn.xyz/aether/gosick/internal/homesick/core" + "github.com/stretchr/testify/require" + "github.com/stretchr/testify/suite" +) + +type ShowPathSuite struct { + suite.Suite + tmpDir string + homeDir string + reposDir string + stdout *bytes.Buffer + app *core.App +} + +func TestShowPathSuite(t *testing.T) { + suite.Run(t, new(ShowPathSuite)) +} + +func (s *ShowPathSuite) 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.stdout = &bytes.Buffer{} + s.app = &core.App{ + HomeDir: s.homeDir, + ReposDir: s.reposDir, + Stdout: s.stdout, + Stderr: io.Discard, + } +} + +func (s *ShowPathSuite) TestShowPath_OutputsCastlePath() { + require.NoError(s.T(), s.app.ShowPath("castle_repo")) + + require.Equal( + s.T(), + filepath.Join(s.reposDir, "castle_repo")+"\n", + s.stdout.String(), + ) +} -- 2.49.1 From 4355e7fd9daed91d9053ef974677a1cfe597981f Mon Sep 17 00:00:00 2001 From: Micheal Wilkinson Date: Thu, 19 Mar 2026 14:29:03 +0000 Subject: [PATCH 013/162] test(core): add status diff and version suites --- internal/homesick/core/diff_test.go | 76 +++++++++++++++++++++++++ internal/homesick/core/status_test.go | 79 ++++++++++++++++++++++++++ internal/homesick/core/version_test.go | 46 +++++++++++++++ 3 files changed, 201 insertions(+) create mode 100644 internal/homesick/core/diff_test.go create mode 100644 internal/homesick/core/status_test.go create mode 100644 internal/homesick/core/version_test.go diff --git a/internal/homesick/core/diff_test.go b/internal/homesick/core/diff_test.go new file mode 100644 index 0000000..9879c6d --- /dev/null +++ b/internal/homesick/core/diff_test.go @@ -0,0 +1,76 @@ +package core_test + +import ( + "bytes" + "os" + "path/filepath" + "testing" + "time" + + "git.hrafn.xyz/aether/gosick/internal/homesick/core" + git "github.com/go-git/go-git/v5" + "github.com/go-git/go-git/v5/plumbing/object" + "github.com/stretchr/testify/require" + "github.com/stretchr/testify/suite" +) + +type DiffSuite struct { + suite.Suite + tmpDir string + homeDir string + reposDir string + stdout *bytes.Buffer + stderr *bytes.Buffer + app *core.App +} + +func TestDiffSuite(t *testing.T) { + suite.Run(t, new(DiffSuite)) +} + +func (s *DiffSuite) 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.stdout = &bytes.Buffer{} + s.stderr = &bytes.Buffer{} + s.app = &core.App{ + HomeDir: s.homeDir, + ReposDir: s.reposDir, + Stdout: s.stdout, + Stderr: s.stderr, + } +} + +func (s *DiffSuite) createCastleRepo(castle string) string { + castleRoot := filepath.Join(s.reposDir, castle) + repo, err := git.PlainInit(castleRoot, false) + require.NoError(s.T(), err) + + filePath := filepath.Join(castleRoot, "home", ".vimrc") + require.NoError(s.T(), os.MkdirAll(filepath.Dir(filePath), 0o755)) + require.NoError(s.T(), os.WriteFile(filePath, []byte("set number\n"), 0o644)) + + wt, err := repo.Worktree() + require.NoError(s.T(), err) + _, err = wt.Add("home/.vimrc") + require.NoError(s.T(), err) + _, err = wt.Commit("initial", &git.CommitOptions{Author: &object.Signature{ + Name: "Behavior Test", + Email: "behavior@test.local", + When: time.Now(), + }}) + require.NoError(s.T(), err) + + return castleRoot +} + +func (s *DiffSuite) TestDiff_WritesGitDiffToAppStdout() { + castleRoot := s.createCastleRepo("castle_repo") + require.NoError(s.T(), os.WriteFile(filepath.Join(castleRoot, "home", ".vimrc"), []byte("changed\n"), 0o644)) + + require.NoError(s.T(), s.app.Diff("castle_repo")) + require.Contains(s.T(), s.stdout.String(), "diff --git") +} diff --git a/internal/homesick/core/status_test.go b/internal/homesick/core/status_test.go new file mode 100644 index 0000000..5448b55 --- /dev/null +++ b/internal/homesick/core/status_test.go @@ -0,0 +1,79 @@ +package core_test + +import ( + "bytes" + "io" + "os" + "path/filepath" + "testing" + "time" + + "git.hrafn.xyz/aether/gosick/internal/homesick/core" + git "github.com/go-git/go-git/v5" + "github.com/go-git/go-git/v5/plumbing/object" + "github.com/stretchr/testify/require" + "github.com/stretchr/testify/suite" +) + +type StatusSuite struct { + suite.Suite + tmpDir string + homeDir string + reposDir string + stdout *bytes.Buffer + stderr *bytes.Buffer + app *core.App +} + +func TestStatusSuite(t *testing.T) { + suite.Run(t, new(StatusSuite)) +} + +func (s *StatusSuite) 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.stdout = &bytes.Buffer{} + s.stderr = &bytes.Buffer{} + s.app = &core.App{ + HomeDir: s.homeDir, + ReposDir: s.reposDir, + Stdout: s.stdout, + Stderr: s.stderr, + } +} + +func (s *StatusSuite) createCastleRepo(castle string) string { + castleRoot := filepath.Join(s.reposDir, castle) + repo, err := git.PlainInit(castleRoot, false) + require.NoError(s.T(), err) + + filePath := filepath.Join(castleRoot, "home", ".vimrc") + require.NoError(s.T(), os.MkdirAll(filepath.Dir(filePath), 0o755)) + require.NoError(s.T(), os.WriteFile(filePath, []byte("set number\n"), 0o644)) + + wt, err := repo.Worktree() + require.NoError(s.T(), err) + _, err = wt.Add("home/.vimrc") + require.NoError(s.T(), err) + _, err = wt.Commit("initial", &git.CommitOptions{Author: &object.Signature{ + Name: "Behavior Test", + Email: "behavior@test.local", + When: time.Now(), + }}) + require.NoError(s.T(), err) + + return castleRoot +} + +func (s *StatusSuite) TestStatus_WritesGitStatusToAppStdout() { + castleRoot := s.createCastleRepo("castle_repo") + require.NoError(s.T(), os.WriteFile(filepath.Join(castleRoot, "home", ".vimrc"), []byte("changed\n"), 0o644)) + + require.NoError(s.T(), s.app.Status("castle_repo")) + require.Contains(s.T(), s.stdout.String(), "modified:") +} + +var _ io.Writer diff --git a/internal/homesick/core/version_test.go b/internal/homesick/core/version_test.go new file mode 100644 index 0000000..b28f03e --- /dev/null +++ b/internal/homesick/core/version_test.go @@ -0,0 +1,46 @@ +package core_test + +import ( + "bytes" + "io" + "os" + "path/filepath" + "testing" + + "git.hrafn.xyz/aether/gosick/internal/homesick/core" + "github.com/stretchr/testify/require" + "github.com/stretchr/testify/suite" +) + +type VersionSuite struct { + suite.Suite + tmpDir string + homeDir string + reposDir string + stdout *bytes.Buffer + app *core.App +} + +func TestVersionSuite(t *testing.T) { + suite.Run(t, new(VersionSuite)) +} + +func (s *VersionSuite) 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.stdout = &bytes.Buffer{} + s.app = &core.App{ + HomeDir: s.homeDir, + ReposDir: s.reposDir, + Stdout: s.stdout, + Stderr: io.Discard, + } +} + +func (s *VersionSuite) TestVersion_WritesVersionToAppStdout() { + require.NoError(s.T(), s.app.Version("1.2.3")) + require.Equal(s.T(), "1.2.3\n", s.stdout.String()) +} -- 2.49.1 From 040bf31b56542488238a85f758c3c0e0c7b2e9e5 Mon Sep 17 00:00:00 2001 From: Micheal Wilkinson Date: Thu, 19 Mar 2026 14:29:52 +0000 Subject: [PATCH 014/162] fix(core): route status and diff output through app writers --- internal/homesick/core/core.go | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/internal/homesick/core/core.go b/internal/homesick/core/core.go index ab1d99f..d2d7f23 100644 --- a/internal/homesick/core/core.go +++ b/internal/homesick/core/core.go @@ -126,11 +126,11 @@ func (a *App) List() error { } func (a *App) Status(castle string) error { - return runGit(filepath.Join(a.ReposDir, castle), "status") + return runGitWithIO(filepath.Join(a.ReposDir, castle), a.Stdout, a.Stderr, "status") } func (a *App) Diff(castle string) error { - return runGit(filepath.Join(a.ReposDir, castle), "diff") + return runGitWithIO(filepath.Join(a.ReposDir, castle), a.Stdout, a.Stderr, "diff") } func (a *App) Link(castle string) error { @@ -511,10 +511,14 @@ func matchesIgnoredDir(castleHome string, candidate string, subdirs []string) (b } func runGit(dir string, args ...string) error { + return runGitWithIO(dir, os.Stdout, os.Stderr, args...) +} + +func runGitWithIO(dir string, stdout io.Writer, stderr io.Writer, args ...string) error { cmd := exec.Command("git", args...) cmd.Dir = dir - cmd.Stdout = os.Stdout - cmd.Stderr = os.Stderr + cmd.Stdout = stdout + cmd.Stderr = stderr if err := cmd.Run(); err != nil { return fmt.Errorf("git %s failed: %w", strings.Join(args, " "), err) } -- 2.49.1 From 1d4c088edcab60d1c1bb3397ce0f19b72bba5970 Mon Sep 17 00:00:00 2001 From: Micheal Wilkinson Date: Thu, 19 Mar 2026 14:40:08 +0000 Subject: [PATCH 015/162] test(cli): add parser coverage for kong refactor --- internal/homesick/cli/cli_test.go | 62 +++++++++++++++++++++++++++++++ 1 file changed, 62 insertions(+) create mode 100644 internal/homesick/cli/cli_test.go diff --git a/internal/homesick/cli/cli_test.go b/internal/homesick/cli/cli_test.go new file mode 100644 index 0000000..cd97a4e --- /dev/null +++ b/internal/homesick/cli/cli_test.go @@ -0,0 +1,62 @@ +package cli_test + +import ( + "bytes" + "os" + "path/filepath" + "testing" + + "git.hrafn.xyz/aether/gosick/internal/homesick/cli" + "git.hrafn.xyz/aether/gosick/internal/homesick/version" + "github.com/stretchr/testify/require" + "github.com/stretchr/testify/suite" +) + +type CLISuite struct { + suite.Suite + homeDir string + stdout *bytes.Buffer + stderr *bytes.Buffer +} + +func TestCLISuite(t *testing.T) { + suite.Run(t, new(CLISuite)) +} + +func (s *CLISuite) SetupTest() { + s.homeDir = filepath.Join(s.T().TempDir(), "home") + require.NoError(s.T(), os.MkdirAll(s.homeDir, 0o755)) + require.NoError(s.T(), os.Setenv("HOME", s.homeDir)) + + s.stdout = &bytes.Buffer{} + s.stderr = &bytes.Buffer{} +} + +func (s *CLISuite) TestRun_VersionAliases() { + for _, args := range [][]string{{"-v"}, {"--version"}, {"version"}} { + s.stdout.Reset() + s.stderr.Reset() + + exitCode := cli.Run(args, s.stdout, s.stderr) + require.Equal(s.T(), 0, exitCode) + require.Equal(s.T(), version.String+"\n", s.stdout.String()) + require.Empty(s.T(), s.stderr.String()) + } +} + +func (s *CLISuite) TestRun_ShowPath_DefaultCastle() { + exitCode := cli.Run([]string{"show_path"}, s.stdout, s.stderr) + + require.Equal(s.T(), 0, exitCode) + require.Equal(s.T(), filepath.Join(s.homeDir, ".homesick", "repos", "dotfiles")+"\n", s.stdout.String()) + require.Empty(s.T(), s.stderr.String()) +} + +func (s *CLISuite) TestRun_CloneSubcommandHelp() { + exitCode := cli.Run([]string{"clone", "--help"}, s.stdout, s.stderr) + + require.Equal(s.T(), 0, exitCode) + require.Contains(s.T(), s.stdout.String(), "clone") + require.Contains(s.T(), s.stdout.String(), "URI") + require.Empty(s.T(), s.stderr.String()) +} \ No newline at end of file -- 2.49.1 From 8174c6a9835c927acb7a91ec101f1d4077650f58 Mon Sep 17 00:00:00 2001 From: Micheal Wilkinson Date: Thu, 19 Mar 2026 14:51:47 +0000 Subject: [PATCH 016/162] refactor(cli): use kong for command parsing --- go.mod | 1 + go.sum | 2 + internal/homesick/cli/cli.go | 325 ++++++++++++++++++++++------------- 3 files changed, 212 insertions(+), 116 deletions(-) diff --git a/go.mod b/go.mod index 1082ea8..b925b1e 100644 --- a/go.mod +++ b/go.mod @@ -12,6 +12,7 @@ require ( dario.cat/mergo v1.0.0 // indirect github.com/Microsoft/go-winio v0.6.2 // indirect github.com/ProtonMail/go-crypto v1.1.5 // indirect + github.com/alecthomas/kong v1.12.1 // indirect github.com/cloudflare/circl v1.6.0 // indirect github.com/cyphar/filepath-securejoin v0.4.1 // indirect github.com/davecgh/go-spew v1.1.1 // indirect diff --git a/go.sum b/go.sum index 4e0affc..e09d4cf 100644 --- a/go.sum +++ b/go.sum @@ -5,6 +5,8 @@ github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERo github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= github.com/ProtonMail/go-crypto v1.1.5 h1:eoAQfK2dwL+tFSFpr7TbOaPNUbPiJj4fLYwwGE1FQO4= github.com/ProtonMail/go-crypto v1.1.5/go.mod h1:rA3QumHc/FZ8pAHreoekgiAbzpNsfQAosU5td4SnOrE= +github.com/alecthomas/kong v1.12.1 h1:iq6aMJDcFYP9uFrLdsiZQ2ZMmcshduyGv4Pek0MQPW0= +github.com/alecthomas/kong v1.12.1/go.mod h1:p2vqieVMeTAnaC83txKtXe8FLke2X07aruPWXyMPQrU= github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8= github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4= github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio= diff --git a/internal/homesick/cli/cli.go b/internal/homesick/cli/cli.go index f7c2452..9afef8e 100644 --- a/internal/homesick/cli/cli.go +++ b/internal/homesick/cli/cli.go @@ -1,6 +1,7 @@ package cli import ( + "errors" "fmt" "io" "os" @@ -8,6 +9,7 @@ import ( "git.hrafn.xyz/aether/gosick/internal/homesick/core" "git.hrafn.xyz/aether/gosick/internal/homesick/version" + "github.com/alecthomas/kong" ) func Run(args []string, stdout io.Writer, stderr io.Writer) int { @@ -17,128 +19,219 @@ func Run(args []string, stdout io.Writer, stderr io.Writer) int { 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": - castle := defaultCastle(args) - if err := app.LinkCastle(castle); err != nil { - _, _ = fmt.Fprintf(stderr, "error: %v\n", err) - return 1 - } - return 0 - 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": - if len(args) < 2 || strings.TrimSpace(args[1]) == "" { - _, _ = fmt.Fprintln(stderr, "error: track requires FILE") - return 1 - } - - castle := "dotfiles" - if len(args) > 2 && strings.TrimSpace(args[2]) != "" { - castle = args[2] - } - - if err := app.Track(args[1], castle); err != nil { - _, _ = fmt.Fprintf(stderr, "error: %v\n", err) - return 1 - } - return 0 - case "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) + parser, err := kong.New( + &cliModel{}, + kong.Name("homesick"), + kong.Description("Go scaffold"), + kong.Writers(stdout, stderr), + kong.Exit(func(int) {}), + kong.ConfigureHelp(kong.HelpOptions{Compact: true}), + ) + if err != nil { + _, _ = fmt.Fprintf(stderr, "error: %v\n", err) return 1 } -} -func defaultCastle(args []string) string { - if len(args) > 1 && strings.TrimSpace(args[1]) != "" { - return args[1] + normalizedArgs := normalizeArgs(args) + ctx, err := parser.Parse(normalizedArgs) + if err != nil { + var parseErr *kong.ParseError + if errors.As(err, &parseErr) { + if parseErr.ExitCode() == 0 || isHelpRequest(normalizedArgs) { + return 0 + } + _, _ = fmt.Fprintf(stderr, "error: %v\n", err) + if parseErr.Context != nil { + _ = parseErr.Context.PrintUsage(false) + } + return parseErr.ExitCode() + } + _, _ = fmt.Fprintf(stderr, "error: %v\n", err) + return 1 } - return "dotfiles" + + if err := ctx.Run(app); err != nil { + var exitErr *cliExitError + if errors.As(err, &exitErr) { + _, _ = fmt.Fprintf(stderr, "error: %v\n", exitErr.err) + return exitErr.code + } + _, _ = fmt.Fprintf(stderr, "error: %v\n", err) + return 1 + } + return 0 } -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, " link [CASTLE]") - _, _ = fmt.Fprintln(w, " unlink [CASTLE]") - _, _ = fmt.Fprintln(w, " track FILE [CASTLE]") - _, _ = fmt.Fprintln(w, " version | -v | --version") - _, _ = fmt.Fprintln(w, "") - _, _ = fmt.Fprintln(w, "Not implemented yet:") - _, _ = fmt.Fprintln(w, " 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") +type cliModel struct { + Clone cloneCmd `cmd:"" help:"Clone a castle."` + List listCmd `cmd:"" help:"List castles."` + ShowPath showPathCmd `cmd:"" name:"show_path" help:"Show the path of a castle."` + Status statusCmd `cmd:"" help:"Show git status for a castle."` + Diff diffCmd `cmd:"" help:"Show git diff for a castle."` + Link linkCmd `cmd:"" help:"Symlink all dotfiles from a castle."` + Unlink unlinkCmd `cmd:"" help:"Unsymlink all dotfiles from a castle."` + Track trackCmd `cmd:"" help:"Track a file in a castle."` + Version versionCmd `cmd:"" help:"Display the current version."` + Pull pullCmd `cmd:"" help:"Pull the specified castle."` + Push pushCmd `cmd:"" help:"Push the specified castle."` + Commit commitCmd `cmd:"" help:"Commit the specified castle's changes."` + Destroy destroyCmd `cmd:"" help:"Destroy a castle."` + Cd cdCmd `cmd:"" help:"Print the path to a castle."` + Open openCmd `cmd:"" help:"Open a castle."` + Exec execCmd `cmd:"" help:"Execute a command in a castle."` + ExecAll execAllCmd `cmd:"" name:"exec_all" help:"Execute a command in every castle."` + Rc rcCmd `cmd:"" help:"Run rc hooks for a castle."` + Generate generateCmd `cmd:"" help:"Generate a castle."` +} + +type cloneCmd struct { + URI string `arg:"" name:"URI" help:"Castle URI to clone."` + Destination string `arg:"" optional:"" name:"CASTLE_NAME" help:"Optional local castle name."` +} + +func (c *cloneCmd) Run(app *core.App) error { + return app.Clone(c.URI, c.Destination) +} + +type listCmd struct{} + +func (c *listCmd) Run(app *core.App) error { + return app.List() +} + +type showPathCmd struct { + Castle string `arg:"" optional:"" name:"CASTLE" help:"Castle name."` +} + +func (c *showPathCmd) Run(app *core.App) error { + return app.ShowPath(defaultCastle(c.Castle)) +} + +type statusCmd struct { + Castle string `arg:"" optional:"" name:"CASTLE" help:"Castle name."` +} + +func (c *statusCmd) Run(app *core.App) error { + return app.Status(defaultCastle(c.Castle)) +} + +type diffCmd struct { + Castle string `arg:"" optional:"" name:"CASTLE" help:"Castle name."` +} + +func (c *diffCmd) Run(app *core.App) error { + return app.Diff(defaultCastle(c.Castle)) +} + +type linkCmd struct { + Castle string `arg:"" optional:"" name:"CASTLE" help:"Castle name."` +} + +func (c *linkCmd) Run(app *core.App) error { + return app.LinkCastle(defaultCastle(c.Castle)) +} + +type unlinkCmd struct { + Castle string `arg:"" optional:"" name:"CASTLE" help:"Castle name."` +} + +func (c *unlinkCmd) Run(app *core.App) error { + return app.Unlink(defaultCastle(c.Castle)) +} + +type trackCmd struct { + File string `arg:"" name:"FILE" help:"File to track."` + Castle string `arg:"" optional:"" name:"CASTLE" help:"Castle name."` +} + +func (c *trackCmd) Run(app *core.App) error { + return app.Track(c.File, defaultCastle(c.Castle)) +} + +type versionCmd struct{} + +func (c *versionCmd) Run(app *core.App) error { + return app.Version(version.String) +} + +type pullCmd struct{} + +type pushCmd struct{} + +type commitCmd struct{} + +type destroyCmd struct{} + +type cdCmd struct{} + +type openCmd struct{} + +type execCmd struct{} + +type execAllCmd struct{} + +type rcCmd struct{} + +type generateCmd struct{} + +func (c *pullCmd) Run() error { return notImplemented("pull") } +func (c *pushCmd) Run() error { return notImplemented("push") } +func (c *commitCmd) Run() error { return notImplemented("commit") } +func (c *destroyCmd) Run() error { return notImplemented("destroy") } +func (c *cdCmd) Run() error { return notImplemented("cd") } +func (c *openCmd) Run() error { return notImplemented("open") } +func (c *execCmd) Run() error { return notImplemented("exec") } +func (c *execAllCmd) Run() error { return notImplemented("exec_all") } +func (c *rcCmd) Run() error { return notImplemented("rc") } +func (c *generateCmd) Run() error { return notImplemented("generate") } + +func defaultCastle(castle string) string { + if strings.TrimSpace(castle) == "" { + return "dotfiles" + } + return castle +} + +func normalizeArgs(args []string) []string { + if len(args) == 0 { + return []string{"--help"} + } + + switch args[0] { + case "-h", "--help": + return []string{"--help"} + case "help": + if len(args) == 1 { + return []string{"--help"} + } + return append(args[1:], "--help") + case "-v", "--version": + return []string{"version"} + default: + return args + } +} + +func isHelpRequest(args []string) bool { + for _, arg := range args { + if arg == "-h" || arg == "--help" { + return true + } + } + return false +} + +type cliExitError struct { + code int + err error +} + +func (e *cliExitError) Error() string { + return e.err.Error() +} + +func notImplemented(command string) error { + return &cliExitError{code: 2, err: fmt.Errorf("%s is not implemented in Go yet", command)} } func init() { -- 2.49.1 From 8d346744150f13b0c57934d89d669487f8480f0e Mon Sep 17 00:00:00 2001 From: Micheal Wilkinson Date: Thu, 19 Mar 2026 16:17:54 +0000 Subject: [PATCH 017/162] chore: remove Ruby implementation and tooling --- .document | 5 - .gitignore | 21 - .rspec | 1 - .rubocop.yml | 19 - .travis.yml | 7 - Gemfile | 36 -- Guardfile | 6 - README.md | 252 ++------ Rakefile | 68 --- bin/homesick | 14 +- docker/behavior/Dockerfile | 14 +- homesick.gemspec | 105 ---- justfile | 10 +- lib/homesick.rb | 29 - lib/homesick/actions/file_actions.rb | 78 --- lib/homesick/actions/git_actions.rb | 114 ---- lib/homesick/cli.rb | 323 ---------- lib/homesick/utils.rb | 215 ------- lib/homesick/version.rb | 11 - script/run-behavior-suite-docker.sh | 2 +- spec/homesick_cli_spec.rb | 861 --------------------------- spec/spec.opts | 1 - spec/spec_helper.rb | 42 -- test/behavior/behavior_suite.sh | 2 +- 24 files changed, 72 insertions(+), 2164 deletions(-) delete mode 100644 .document delete mode 100644 .rspec delete mode 100644 .rubocop.yml delete mode 100644 .travis.yml delete mode 100644 Gemfile delete mode 100644 Guardfile delete mode 100644 Rakefile delete mode 100644 homesick.gemspec delete mode 100644 lib/homesick.rb delete mode 100644 lib/homesick/actions/file_actions.rb delete mode 100644 lib/homesick/actions/git_actions.rb delete mode 100644 lib/homesick/cli.rb delete mode 100644 lib/homesick/utils.rb delete mode 100644 lib/homesick/version.rb delete mode 100644 spec/homesick_cli_spec.rb delete mode 100644 spec/spec.opts delete mode 100644 spec/spec_helper.rb diff --git a/.document b/.document deleted file mode 100644 index ecf3673..0000000 --- a/.document +++ /dev/null @@ -1,5 +0,0 @@ -README.rdoc -lib/**/*.rb -bin/* -features/**/*.feature -LICENSE diff --git a/.gitignore b/.gitignore index 8c7a84c..26deacc 100644 --- a/.gitignore +++ b/.gitignore @@ -1,17 +1,3 @@ -# rcov generated -coverage - -# rdoc generated -rdoc - -# yard generated -doc -.yardoc - -# jeweler generated -pkg - -.bundle # Have editor/IDE/OS specific files you need to ignore? Consider using a global gitignore: # @@ -44,17 +30,10 @@ pkg .idea/ *.iml -Gemfile.lock -vendor/ - -homesick*.gem # Go scaffolding artifacts dist/ *.test *.out -# rbenv configuration -.ruby-version - .github/* \ No newline at end of file diff --git a/.rspec b/.rspec deleted file mode 100644 index 4e1e0d2..0000000 --- a/.rspec +++ /dev/null @@ -1 +0,0 @@ ---color diff --git a/.rubocop.yml b/.rubocop.yml deleted file mode 100644 index 67ee52d..0000000 --- a/.rubocop.yml +++ /dev/null @@ -1,19 +0,0 @@ -# TODO: Eval is required for the .homesickrc feature. This should eventually be -# removed if the feature is implemented in a more secure way. -Eval: - Enabled: false - -# TODO: The following settings disable reports about issues that can be fixed -# through refactoring. Remove these as offenses are removed from the code base. - -ClassLength: - Enabled: false - -CyclomaticComplexity: - Max: 13 - -LineLength: - Enabled: false - -MethodLength: - Max: 36 diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index 46eb168..0000000 --- a/.travis.yml +++ /dev/null @@ -1,7 +0,0 @@ -language: ruby -rvm: - - 2.5.0 - - 2.4.0 - - 2.3.3 - - 2.2.6 -sudo: false diff --git a/Gemfile b/Gemfile deleted file mode 100644 index f29c191..0000000 --- a/Gemfile +++ /dev/null @@ -1,36 +0,0 @@ -source 'https://rubygems.org' - -this_ruby = Gem::Version.new(RUBY_VERSION) -ruby_230 = Gem::Version.new('2.3.0') - -# Add dependencies required to use your gem here. -gem 'thor', '>= 0.14.0' - -# Add dependencies to develop your gem here. -# Include everything needed to run rake, tests, features, etc. -group :development do - gem 'capture-output', '~> 1.0.0' - gem 'coveralls', require: false - gem 'guard' - gem 'guard-rspec' - gem 'jeweler', '>= 1.6.2', '< 2.2' if this_ruby < ruby_230 - gem 'jeweler', '>= 1.6.2' if this_ruby >= ruby_230 - gem 'rake', '>= 0.8.7' - gem 'rb-readline', '~> 0.5.0' - gem 'rspec', '~> 3.5.0' - gem 'rubocop' - gem 'test_construct' - - install_if -> { RUBY_PLATFORM =~ /linux|freebsd|openbsd|sunos|solaris/ } do - gem 'libnotify' - end - - install_if -> { RUBY_PLATFORM =~ /darwin/ } do - gem 'terminal-notifier-guard', '~> 1.7.0' - end - - install_if -> { this_ruby < ruby_230 } do - gem 'listen', '< 3' - gem 'rack', '~> 2.0.6' - end -end diff --git a/Guardfile b/Guardfile deleted file mode 100644 index 2823109..0000000 --- a/Guardfile +++ /dev/null @@ -1,6 +0,0 @@ -guard :rspec, :cmd => 'bundle exec rspec' do - watch(%r{^spec/.+_spec\.rb$}) - watch(%r{^lib/(.+)\.rb$}) { |m| "spec/#{m[1]}_spec.rb" } - watch(%r{^lib/homesick/.*\.rb}) { "spec" } - watch('spec/spec_helper.rb') { "spec" } -end diff --git a/README.md b/README.md index 99cb8a2..5351707 100644 --- a/README.md +++ b/README.md @@ -1,228 +1,74 @@ # homesick -[![Gem Version](https://badge.fury.io/rb/homesick.svg)](http://badge.fury.io/rb/homesick) -[![Build Status](https://travis-ci.org/technicalpickles/homesick.svg?branch=master)](https://travis-ci.org/technicalpickles/homesick) -[![Dependency Status](https://gemnasium.com/technicalpickles/homesick.svg)](https://gemnasium.com/technicalpickles/homesick) -[![Coverage Status](https://coveralls.io/repos/technicalpickles/homesick/badge.png)](https://coveralls.io/r/technicalpickles/homesick) -[![Code Climate](https://codeclimate.com/github/technicalpickles/homesick.svg)](https://codeclimate.com/github/technicalpickles/homesick) -[![Gitter chat](https://badges.gitter.im/technicalpickles/homesick.svg)](https://gitter.im/technicalpickles/homesick) - Your home directory is your castle. Don't leave your dotfiles behind. -Homesick is sorta like [rip](http://github.com/defunkt/rip), but for dotfiles. It uses git to clone a repository containing dotfiles, and saves them in `~/.homesick`. It then allows you to symlink all the dotfiles into place with a single command. +This repository now contains a Go implementation of Homesick. A dotfiles repository is called a castle and should contain a `home/` directory with files to link into your `$HOME`. -We call a repository that is compatible with homesick to be a 'castle'. To act as a castle, a repository must be organized like so: +## Build -- Contains a 'home' directory -- 'home' contains any number of files and directories that begin with '.' +Build with just: -To get started, install homesick first: +```bash +just go-build +``` - gem install homesick +Or directly with Go: -Next, you use the homesick command to clone a castle: +```bash +go build -o dist/homesick-go ./cmd/homesick +``` - homesick clone git://github.com/technicalpickles/pickled-vim.git +## Commands -Alternatively, if it's on github, there's a slightly shorter way: +Implemented commands: - homesick clone technicalpickles/pickled-vim +- `clone URI [CASTLE_NAME]` +- `list` +- `show_path [CASTLE]` +- `status [CASTLE]` +- `diff [CASTLE]` +- `link [CASTLE]` +- `unlink [CASTLE]` +- `track FILE [CASTLE]` +- `version` -With the castle cloned, you can now link its contents into your home dir: +Not yet implemented: - homesick link pickled-vim +- `pull` +- `push` +- `commit` +- `destroy` +- `cd` +- `open` +- `exec` +- `exec_all` +- `rc` +- `generate` -You can remove symlinks anytime when you don't need them anymore +## Behavior Suite - homesick unlink pickled-vim +The repository includes a Docker-based behavior suite that validates filesystem and git outcomes for the implemented commands. -If you need to add further configuration steps you can add these in a file called '.homesickrc' in the root of a castle. Once you've cloned a castle with a .homesickrc run the configuration with: +Run behavior suite: - homesick rc CASTLE +```bash +just behavior +``` -The contents of the .homesickrc file must be valid Ruby code as the file will be executed with Ruby's eval construct. The .homesickrc is also passed the current homesick object during its execution and this is available within the .homesickrc file as the 'self' variable. As the rc operation can be destructive the command normally asks for confirmation before proceeding. You can bypass this by passing the '--force' option, for example `homesick rc --force CASTLE`. +Verbose behavior suite output: -If you're not sure what castles you have around, you can easily list them: +```bash +just behavior-verbose +``` - homesick list +## Testing -To pull your castle (or all castles): +Run all Go tests: - homesick pull --all|CASTLE +```bash +just go-test +``` -To commit your castle's changes: +## License - homesick commit CASTLE - -To push your castle: - - homesick push CASTLE - -To open a terminal in the root of a castle: - - homesick cd CASTLE - -To open your default editor in the root of a castle (the $EDITOR environment variable must be set): - - homesick open CASTLE - -To execute a shell command inside the root directory of a given castle: - - homesick exec CASTLE COMMAND - -To execute a shell command inside the root directory of every cloned castle: - - homesick exec_all COMMAND - -Not sure what else homesick has up its sleeve? There's always the built in help: - - homesick help - -If you ever want to see what version of homesick you have type: - - homesick version|-v|--version - -## Docker Behavior Validation (Ruby vs Go) - -To preserve behavior while migrating from Ruby to Go, this repository includes a containerized behavior suite that runs Homesick commands in a clean environment and validates filesystem and git outcomes. - -The suite creates a dedicated git repository inside the container to act as a test castle fixture, then validates behavior for: - -- clone -- link / unlink -- track -- list / show_path -- status / diff -- version - -Run against the current Ruby implementation: - - ./script/run-behavior-suite-docker.sh - -Show full command output (including internal Homesick status lines) when needed: - - ./script/run-behavior-suite-docker.sh --verbose - -This runner now builds an Alpine-based container and installs runtime dependencies with `apk`, so behavior validation is not tied to a Ruby base image. - -Run against a future Go binary (example path): - - HOMESICK_CMD="/workspace/dist/homesick" ./script/run-behavior-suite-docker.sh - -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. - -For example, when you have castle like this: - - castle/home - `-- .config - `-- fooapp - |-- config1 - |-- config2 - `-- config3 - -and have home like this: - - $ tree -a - ~ - |-- .config - | `-- barapp - | |-- config1 - | |-- config2 - | `-- config3 - `-- .emacs.d - |-- elisp - `-- inits - -You may want to symlink only to `castle/home/.config/fooapp` instead of `castle/home/.config` because you already have `~/.config/barapp`. In this case, you can use .homesick_subdir. Please write "directories you want to look up sub directories (instead of just first depth)" in this file. - -castle/.homesick_subdir - - .config - -and run `homesick link CASTLE`. The result is: - - ~ - |-- .config - | |-- barapp - | | |-- config1 - | | |-- config2 - | | `-- config3 - | `-- fooapp -> castle/home/.config/fooapp - `-- .emacs.d - |-- elisp - `-- inits - -Or `homesick track NESTED_FILE CASTLE` adds a line automatically. For example: - - homesick track .emacs.d/elisp castle - -castle/.homesick_subdir - - .config - .emacs.d - -home directory - - ~ - |-- .config - | |-- barapp - | | |-- config1 - | | |-- config2 - | | `-- config3 - | `-- fooapp -> castle/home/.config/fooapp - `-- .emacs.d - |-- elisp -> castle/home/.emacs.d/elisp - `-- inits - -and castle - - castle/home - |-- .config - | `-- fooapp - | |-- config1 - | |-- config2 - | `-- config3 - `-- .emacs.d - `-- elisp - -## Supported Ruby Versions - -Homesick is tested on the following Ruby versions: - -- 2.2.6 -- 2.3.3 -- 2.4.0 - -## Note on Patches/Pull Requests - -- Fork the project. -- Make your feature addition or bug fix. -- Add tests for it. This is important so I don't break it in a future version unintentionally. -- Commit, do not mess with rakefile, version, or history. (if you want to have your own version, that is fine but bump version in a commit by itself I can ignore when I pull) -- Send me a pull request. Bonus points for topic branches. - -## Need homesick without the ruby dependency? - -Check out [homeshick](https://github.com/andsens/homeshick). - -## Copyright - -Copyright (c) 2010 Joshua Nichols. See LICENSE for details. +See `LICENSE`. diff --git a/Rakefile b/Rakefile deleted file mode 100644 index b55731d..0000000 --- a/Rakefile +++ /dev/null @@ -1,68 +0,0 @@ -require 'rubygems' -require 'bundler' -require_relative 'lib/homesick/version' -begin - Bundler.setup(:default, :development) -rescue Bundler::BundlerError => e - $stderr.puts e.message - $stderr.puts "Run `bundle install` to install missing gems" - exit e.status_code -end -require 'rake' - -require 'jeweler' -Jeweler::Tasks.new do |gem| - gem.name = "homesick" - gem.summary = %Q{Your home directory is your castle. Don't leave your dotfiles behind.} - gem.description = %Q{ - Your home directory is your castle. Don't leave your dotfiles behind. - - - Homesick is sorta like rip, but for dotfiles. It uses git to clone a repository containing dotfiles, and saves them in ~/.homesick. It then allows you to symlink all the dotfiles into place with a single command. - - } - gem.email = ["josh@technicalpickles.com", "info@muratayusuke.com"] - gem.homepage = "http://github.com/technicalpickles/homesick" - gem.authors = ["Joshua Nichols", "Yusuke Murata"] - gem.version = Homesick::Version::STRING - gem.license = "MIT" - # Have dependencies? Add them to Gemfile - - # gem is a Gem::Specification... see http://www.rubygems.org/read/chapter/20 for additional settings -end -Jeweler::GemcutterTasks.new - - -require 'rspec/core/rake_task' -RSpec::Core::RakeTask.new(:spec) do |spec| - spec.pattern = FileList['spec/**/*_spec.rb'] -end - -RSpec::Core::RakeTask.new(:rcov) do |spec| - spec.pattern = 'spec/**/*_spec.rb' - spec.rcov = true -end - -task :rubocop do - if RUBY_VERSION >= '1.9.2' - system('rubocop') - end -end - -task :test do - Rake::Task['spec'].execute - Rake::Task['rubocop'].execute -end - -task :default => :test - -require 'rdoc/task' -Rake::RDocTask.new do |rdoc| - version = File.exist?('VERSION') ? File.read('VERSION') : "" - - rdoc.rdoc_dir = 'rdoc' - rdoc.title = "homesick #{version}" - rdoc.rdoc_files.include('README*') - rdoc.rdoc_files.include('lib/**/*.rb') -end - diff --git a/bin/homesick b/bin/homesick index db98f1b..d36558e 100755 --- a/bin/homesick +++ b/bin/homesick @@ -1,9 +1,11 @@ -#!/usr/bin/env ruby +#!/usr/bin/env bash +set -euo pipefail -require 'pathname' -lib = Pathname.new(__FILE__).dirname.join('..', 'lib').expand_path -$LOAD_PATH.unshift lib.to_s +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +repo_root="$(cd "$script_dir/.." && pwd)" -require 'homesick' +if [[ -x "$repo_root/dist/homesick-go" ]]; then + exec "$repo_root/dist/homesick-go" "$@" +fi -Homesick::CLI.start +exec go run "$repo_root/cmd/homesick" "$@" diff --git a/docker/behavior/Dockerfile b/docker/behavior/Dockerfile index 41bffbc..e5d0eab 100644 --- a/docker/behavior/Dockerfile +++ b/docker/behavior/Dockerfile @@ -1,13 +1,21 @@ +FROM golang:1.26-alpine AS builder + +WORKDIR /workspace +COPY go.mod go.sum /workspace/ +RUN go mod download +COPY . /workspace +RUN mkdir -p /workspace/dist && \ + go build -o /workspace/dist/homesick-go ./cmd/homesick + FROM alpine:3.21 RUN apk add --no-cache \ bash \ ca-certificates \ - git \ - ruby \ - ruby-thor + git WORKDIR /workspace COPY . /workspace +COPY --from=builder /workspace/dist/homesick-go /workspace/dist/homesick-go ENTRYPOINT ["/workspace/test/behavior/behavior_suite.sh"] diff --git a/homesick.gemspec b/homesick.gemspec deleted file mode 100644 index 9c700ea..0000000 --- a/homesick.gemspec +++ /dev/null @@ -1,105 +0,0 @@ -# Generated by jeweler -# DO NOT EDIT THIS FILE DIRECTLY -# Instead, edit Jeweler::Tasks in Rakefile, and run 'rake gemspec' -# -*- encoding: utf-8 -*- -# stub: homesick 1.1.6 ruby lib - -Gem::Specification.new do |s| - s.name = "homesick".freeze - s.version = "1.1.6" - - s.required_rubygems_version = Gem::Requirement.new(">= 0".freeze) if s.respond_to? :required_rubygems_version= - s.require_paths = ["lib".freeze] - s.authors = ["Joshua Nichols".freeze, "Yusuke Murata".freeze] - s.date = "2017-12-20" - s.description = "\n Your home directory is your castle. Don't leave your dotfiles behind.\n \n\n Homesick is sorta like rip, but for dotfiles. It uses git to clone a repository containing dotfiles, and saves them in ~/.homesick. It then allows you to symlink all the dotfiles into place with a single command. \n\n ".freeze - s.email = ["josh@technicalpickles.com".freeze, "info@muratayusuke.com".freeze] - s.executables = ["homesick".freeze] - s.extra_rdoc_files = [ - "ChangeLog.markdown", - "LICENSE", - "README.markdown" - ] - s.files = [ - ".document", - ".rspec", - ".rubocop.yml", - ".travis.yml", - "ChangeLog.markdown", - "Gemfile", - "Guardfile", - "LICENSE", - "README.markdown", - "Rakefile", - "bin/homesick", - "homesick.gemspec", - "lib/homesick.rb", - "lib/homesick/actions/file_actions.rb", - "lib/homesick/actions/git_actions.rb", - "lib/homesick/cli.rb", - "lib/homesick/utils.rb", - "lib/homesick/version.rb", - "spec/homesick_cli_spec.rb", - "spec/spec.opts", - "spec/spec_helper.rb" - ] - s.homepage = "http://github.com/technicalpickles/homesick".freeze - s.licenses = ["MIT".freeze] - s.rubygems_version = "2.6.11".freeze - s.summary = "Your home directory is your castle. Don't leave your dotfiles behind.".freeze - - if s.respond_to? :specification_version then - s.specification_version = 4 - - if Gem::Version.new(Gem::VERSION) >= Gem::Version.new('1.2.0') then - s.add_runtime_dependency(%q.freeze, [">= 0.14.0"]) - s.add_development_dependency(%q.freeze, ["~> 1.0.0"]) - s.add_development_dependency(%q.freeze, [">= 0"]) - s.add_development_dependency(%q.freeze, [">= 0"]) - s.add_development_dependency(%q.freeze, [">= 0"]) - s.add_development_dependency(%q.freeze, [">= 1.6.2"]) - s.add_development_dependency(%q.freeze, [">= 0.8.7"]) - s.add_development_dependency(%q.freeze, ["~> 0.5.0"]) - s.add_development_dependency(%q.freeze, ["~> 3.5.0"]) - s.add_development_dependency(%q.freeze, [">= 0"]) - s.add_development_dependency(%q.freeze, [">= 0"]) - s.add_development_dependency(%q.freeze, [">= 0"]) - s.add_development_dependency(%q.freeze, ["~> 1.7.0"]) - s.add_development_dependency(%q.freeze, ["< 3"]) - s.add_development_dependency(%q.freeze, ["< 2"]) - else - s.add_dependency(%q.freeze, [">= 0.14.0"]) - s.add_dependency(%q.freeze, ["~> 1.0.0"]) - s.add_dependency(%q.freeze, [">= 0"]) - s.add_dependency(%q.freeze, [">= 0"]) - s.add_dependency(%q.freeze, [">= 0"]) - s.add_dependency(%q.freeze, [">= 1.6.2"]) - s.add_dependency(%q.freeze, [">= 0.8.7"]) - s.add_dependency(%q.freeze, ["~> 0.5.0"]) - s.add_dependency(%q.freeze, ["~> 3.5.0"]) - s.add_dependency(%q.freeze, [">= 0"]) - s.add_dependency(%q.freeze, [">= 0"]) - s.add_dependency(%q.freeze, [">= 0"]) - s.add_dependency(%q.freeze, ["~> 1.7.0"]) - s.add_dependency(%q.freeze, ["< 3"]) - s.add_dependency(%q.freeze, ["< 2"]) - end - else - s.add_dependency(%q.freeze, [">= 0.14.0"]) - s.add_dependency(%q.freeze, ["~> 1.0.0"]) - s.add_dependency(%q.freeze, [">= 0"]) - s.add_dependency(%q.freeze, [">= 0"]) - s.add_dependency(%q.freeze, [">= 0"]) - s.add_dependency(%q.freeze, [">= 1.6.2"]) - s.add_dependency(%q.freeze, [">= 0.8.7"]) - s.add_dependency(%q.freeze, ["~> 0.5.0"]) - s.add_dependency(%q.freeze, ["~> 3.5.0"]) - s.add_dependency(%q.freeze, [">= 0"]) - s.add_dependency(%q.freeze, [">= 0"]) - s.add_dependency(%q.freeze, [">= 0"]) - s.add_dependency(%q.freeze, ["~> 1.7.0"]) - s.add_dependency(%q.freeze, ["< 3"]) - s.add_dependency(%q.freeze, ["< 2"]) - end -end - diff --git a/justfile b/justfile index ce9c712..9267197 100644 --- a/justfile +++ b/justfile @@ -14,14 +14,8 @@ go-build-linux: go-test: go test ./... -behavior-ruby: +behavior: ./script/run-behavior-suite-docker.sh -behavior-go: go-build-linux - HOMESICK_CMD="/workspace/dist/homesick-go" ./script/run-behavior-suite-docker.sh - -behavior-go-verbose: go-build-linux - HOMESICK_CMD="/workspace/dist/homesick-go" ./script/run-behavior-suite-docker.sh --verbose - -behavior-ruby-verbose: +behavior-verbose: ./script/run-behavior-suite-docker.sh --verbose diff --git a/lib/homesick.rb b/lib/homesick.rb deleted file mode 100644 index 67d1767..0000000 --- a/lib/homesick.rb +++ /dev/null @@ -1,29 +0,0 @@ -require 'homesick/actions/file_actions' -require 'homesick/actions/git_actions' -require 'homesick/version' -require 'homesick/utils' -require 'homesick/cli' -require 'fileutils' - -# Homesick's top-level module -module Homesick - GITHUB_NAME_REPO_PATTERN = %r{\A([A-Za-z0-9_-]+/[A-Za-z0-9_-]+)\Z}.freeze - SUBDIR_FILENAME = '.homesick_subdir'.freeze - - DEFAULT_CASTLE_NAME = 'dotfiles'.freeze - QUIETABLE = [:say_status].freeze - - PRETENDABLE = [:system].freeze - - QUIETABLE.each do |method_name| - define_method(method_name) do |*args| - super(*args) unless options[:quiet] - end - end - - PRETENDABLE.each do |method_name| - define_method(method_name) do |*args| - super(*args) unless options[:pretend] - end - end -end diff --git a/lib/homesick/actions/file_actions.rb b/lib/homesick/actions/file_actions.rb deleted file mode 100644 index d9c7c7b..0000000 --- a/lib/homesick/actions/file_actions.rb +++ /dev/null @@ -1,78 +0,0 @@ -module Homesick - module Actions - # File-related helper methods for Homesick - module FileActions - protected - - def mv(source, destination) - source = Pathname.new(source) - destination = Pathname.new(destination + source.basename) - say_status :conflict, "#{destination} exists", :red if destination.exist? && (options[:force] || shell.file_collision(destination) { source }) - FileUtils.mv source, destination unless options[:pretend] - end - - def rm_rf(dir) - say_status "rm -rf #{dir}", '', :green - FileUtils.rm_r dir, force: true - end - - def rm_link(target) - target = Pathname.new(target) - - if target.symlink? - say_status :unlink, target.expand_path.to_s, :green - FileUtils.rm_rf target - else - say_status :conflict, "#{target} is not a symlink", :red - end - end - - def rm(file) - say_status "rm #{file}", '', :green - FileUtils.rm file, force: true - end - - def rm_r(dir) - say_status "rm -r #{dir}", '', :green - FileUtils.rm_r dir - end - - def ln_s(source, destination) - source = Pathname.new(source).realpath - destination = Pathname.new(destination) - FileUtils.mkdir_p destination.dirname - - action = :success - action = :identical if destination.symlink? && destination.readlink == source - action = :symlink_conflict if destination.symlink? - action = :conflict if destination.exist? - - handle_symlink_action action, source, destination - end - - def handle_symlink_action(action, source, destination) - if action == :identical - say_status :identical, destination.expand_path, :blue - return - end - message = generate_symlink_message action, source, destination - if %i[symlink_conflict conflict].include?(action) - say_status :conflict, message, :red - if collision_accepted?(destination, source) - FileUtils.rm_r destination, force: true unless options[:pretend] - end - else - say_status :symlink, message, :green - end - FileUtils.ln_s source, destination, force: true unless options[:pretend] - end - - def generate_symlink_message(action, source, destination) - message = "#{source.expand_path} to #{destination.expand_path}" - message = "#{destination} exists and points to #{destination.readlink}" if action == :symlink_conflict - message = "#{destination} exists" if action == :conflict - message - end - end - end -end diff --git a/lib/homesick/actions/git_actions.rb b/lib/homesick/actions/git_actions.rb deleted file mode 100644 index c4d93a1..0000000 --- a/lib/homesick/actions/git_actions.rb +++ /dev/null @@ -1,114 +0,0 @@ -module Homesick - module Actions - # Git-related helper methods for Homesick - module GitActions - # Information on the minimum git version required for Homesick - MIN_VERSION = { - major: 1, - minor: 8, - patch: 0 - }.freeze - STRING = MIN_VERSION.values.join('.') - - def git_version_correct? - info = `git --version`.scan(/(\d+)\.(\d+)\.(\d+)/).flatten.map(&:to_i) - return false unless info.count == 3 - - current_version = Hash[%i[major minor patch].zip(info)] - major_equals = current_version.eql?(MIN_VERSION) - major_greater = current_version[:major] > MIN_VERSION[:major] - minor_greater = current_version[:major] == MIN_VERSION[:major] && current_version[:minor] > MIN_VERSION[:minor] - patch_greater = current_version[:major] == MIN_VERSION[:major] && current_version[:minor] == MIN_VERSION[:minor] && current_version[:patch] >= MIN_VERSION[:patch] - - major_equals || major_greater || minor_greater || patch_greater - end - - # TODO: move this to be more like thor's template, empty_directory, etc - def git_clone(repo, config = {}) - config ||= {} - destination = config[:destination] || File.basename(repo, '.git') - - destination = Pathname.new(destination) unless destination.is_a?(Pathname) - FileUtils.mkdir_p destination.dirname - - if destination.directory? - say_status :exist, destination.expand_path, :blue - else - say_status 'git clone', - "#{repo} to #{destination.expand_path}", - :green - system "git clone -q --config push.default=upstream --recursive #{repo} #{destination}" - end - end - - def git_init(path = '.') - path = Pathname.new(path) - - inside path do - if path.join('.git').exist? - say_status 'git init', 'already initialized', :blue - else - say_status 'git init', '' - system 'git init >/dev/null' - end - end - end - - def git_remote_add(name, url) - existing_remote = `git config remote.#{name}.url`.chomp - existing_remote = nil if existing_remote == '' - - if existing_remote - say_status 'git remote', "#{name} already exists", :blue - else - say_status 'git remote', "add #{name} #{url}" - system "git remote add #{name} #{url}" - end - end - - def git_submodule_init - say_status 'git submodule', 'init', :green - system 'git submodule --quiet init' - end - - def git_submodule_update - say_status 'git submodule', 'update', :green - system 'git submodule --quiet update --init --recursive >/dev/null 2>&1' - end - - def git_pull - say_status 'git pull', '', :green - system 'git pull --quiet' - end - - def git_push - say_status 'git push', '', :green - system 'git push' - end - - def git_commit_all(config = {}) - say_status 'git commit all', '', :green - if config[:message] - system %(git commit -a -m "#{config[:message]}") - else - system 'git commit -v -a' - end - end - - def git_add(file) - say_status 'git add file', '', :green - system "git add '#{file}'" - end - - def git_status - say_status 'git status', '', :green - system 'git status' - end - - def git_diff - say_status 'git diff', '', :green - system 'git diff' - end - end - end -end diff --git a/lib/homesick/cli.rb b/lib/homesick/cli.rb deleted file mode 100644 index fbdfa88..0000000 --- a/lib/homesick/cli.rb +++ /dev/null @@ -1,323 +0,0 @@ -require 'fileutils' -require 'thor' - -module Homesick - # Homesick's command line interface - class CLI < Thor - include Thor::Actions - include Homesick::Actions::FileActions - include Homesick::Actions::GitActions - include Homesick::Version - include Homesick::Utils - - add_runtime_options! - - map '-v' => :version - map '--version' => :version - # Retain a mapped version of the symlink command for compatibility. - map symlink: :link - - def initialize(args = [], options = {}, config = {}) - super - # Check if git is installed - unless git_version_correct? - say_status :error, "Git version >= #{Homesick::Actions::GitActions::STRING} must be installed to use Homesick", :red - exit(1) - end - configure_symlinks_diff - end - - desc 'clone URI CASTLE_NAME', 'Clone +uri+ as a castle with name CASTLE_NAME for homesick' - def clone(uri, destination = nil) - destination = Pathname.new(destination) unless destination.nil? - - inside repos_dir do - if File.exist?(uri) - uri = Pathname.new(uri).expand_path - raise "Castle already cloned to #{uri}" if uri.to_s.start_with?(repos_dir.to_s) - - destination = uri.basename if destination.nil? - - ln_s uri, destination - elsif uri =~ GITHUB_NAME_REPO_PATTERN - destination = Pathname.new(uri).basename if destination.nil? - git_clone "https://github.com/#{Regexp.last_match[1]}.git", - destination: destination - elsif uri =~ /%r([^%r]*?)(\.git)?\Z/ || uri =~ /[^:]+:([^:]+)(\.git)?\Z/ - destination = Pathname.new(Regexp.last_match[1].gsub(/\.git$/, '')).basename if destination.nil? - git_clone uri, destination: destination - else - raise "Unknown URI format: #{uri}" - end - - setup_castle(destination) - end - end - - desc 'rc CASTLE', 'Run the .homesickrc for the specified castle' - method_option :force, - type: :boolean, - default: false, - desc: 'Evaluate .homesickrc without prompting.' - def rc(name = DEFAULT_CASTLE_NAME) - inside repos_dir do - destination = Pathname.new(name) - homesickrc = destination.join('.homesickrc').expand_path - return unless homesickrc.exist? - - proceed = options[:force] || shell.yes?("#{name} has a .homesickrc. Proceed with evaling it? (This could be destructive)") - return say_status 'eval skip', "not evaling #{homesickrc}, #{destination} may need manual configuration", :blue unless proceed - - say_status 'eval', homesickrc - inside destination do - eval homesickrc.read, binding, homesickrc.expand_path.to_s - end - end - end - - desc 'pull CASTLE', 'Update the specified castle' - method_option :all, - type: :boolean, - default: false, - required: false, - desc: 'Update all cloned castles' - def pull(name = DEFAULT_CASTLE_NAME) - if options[:all] - inside_each_castle do |castle| - say castle.to_s.gsub(repos_dir.to_s + '/', '') + ':' - update_castle castle - end - else - update_castle name - end - end - - desc 'commit CASTLE MESSAGE', "Commit the specified castle's changes" - def commit(name = DEFAULT_CASTLE_NAME, message = nil) - commit_castle name, message - end - - desc 'push CASTLE', 'Push the specified castle' - def push(name = DEFAULT_CASTLE_NAME) - push_castle name - end - - desc 'unlink CASTLE', 'Unsymlinks all dotfiles from the specified castle' - def unlink(name = DEFAULT_CASTLE_NAME) - check_castle_existance(name, 'symlink') - - inside castle_dir(name) do - subdirs = subdirs(name) - - # unlink files - unsymlink_each(name, castle_dir(name), subdirs) - - # unlink files in subdirs - subdirs.each do |subdir| - unsymlink_each(name, subdir, subdirs) - end - end - end - - desc 'link CASTLE', 'Symlinks all dotfiles from the specified castle' - method_option :force, - type: :boolean, - default: false, - desc: 'Overwrite existing conflicting symlinks without prompting.' - def link(name = DEFAULT_CASTLE_NAME) - check_castle_existance(name, 'symlink') - - castle_path = castle_dir(name) - inside castle_path do - subdirs = subdirs(name) - - # link files - symlink_each(name, castle_path, subdirs) - - # link files in subdirs - subdirs.each do |subdir| - symlink_each(name, subdir, subdirs) - end - end - end - - desc 'track FILE CASTLE', 'add a file to a castle' - def track(file, castle = DEFAULT_CASTLE_NAME) - castle = Pathname.new(castle) - file = Pathname.new(file.chomp('/')) - check_castle_existance(castle, 'track') - - absolute_path = file.expand_path - relative_dir = absolute_path.relative_path_from(home_dir).dirname - castle_path = Pathname.new(castle_dir(castle)).join(relative_dir) - FileUtils.mkdir_p castle_path - - # Are we already tracking this or anything inside it? - target = Pathname.new(castle_path.join(file.basename)) - if target.exist? - if absolute_path.directory? - move_dir_contents(target, absolute_path) - absolute_path.rmtree - subdir_remove(castle, relative_dir + file.basename) - - elsif more_recent? absolute_path, target - target.delete - mv absolute_path, castle_path - else - say_status(:track, - "#{target} already exists, and is more recent than #{file}. Run 'homesick SYMLINK CASTLE' to create symlinks.", - :blue) - end - else - mv absolute_path, castle_path - end - - inside home_dir do - absolute_path = castle_path + file.basename - home_path = home_dir + relative_dir + file.basename - ln_s absolute_path, home_path - end - - inside castle_path do - git_add absolute_path - end - - # are we tracking something nested? Add the parent dir to the manifest - subdir_add(castle, relative_dir) unless relative_dir.eql?(Pathname.new('.')) - end - - desc 'list', 'List cloned castles' - def list - inside_each_castle do |castle| - say_status castle.relative_path_from(repos_dir).to_s, - `git config remote.origin.url`.chomp, - :cyan - end - end - - desc 'status CASTLE', 'Shows the git status of a castle' - def status(castle = DEFAULT_CASTLE_NAME) - check_castle_existance(castle, 'status') - inside repos_dir.join(castle) do - git_status - end - end - - desc 'diff CASTLE', 'Shows the git diff of uncommitted changes in a castle' - def diff(castle = DEFAULT_CASTLE_NAME) - check_castle_existance(castle, 'diff') - inside repos_dir.join(castle) do - git_diff - end - end - - desc 'show_path CASTLE', 'Prints the path of a castle' - def show_path(castle = DEFAULT_CASTLE_NAME) - check_castle_existance(castle, 'show_path') - say repos_dir.join(castle) - end - - desc 'generate PATH', 'generate a homesick-ready git repo at PATH' - def generate(castle) - castle = Pathname.new(castle).expand_path - - github_user = `git config github.user`.chomp - github_user = nil if github_user == '' - github_repo = castle.basename - - empty_directory castle - inside castle do - git_init - if github_user - url = "git@github.com:#{github_user}/#{github_repo}.git" - git_remote_add 'origin', url - end - - empty_directory 'home' - end - end - - desc 'destroy CASTLE', 'Delete all symlinks and remove the cloned repository' - def destroy(name) - check_castle_existance name, 'destroy' - return unless shell.yes?('This will destroy your castle irreversible! Are you sure?') - - unlink(name) - rm_rf repos_dir.join(name) - end - - desc 'cd CASTLE', 'Open a new shell in the root of the given castle' - def cd(castle = DEFAULT_CASTLE_NAME) - check_castle_existance castle, 'cd' - castle_dir = repos_dir.join(castle) - say_status "cd #{castle_dir.realpath}", - "Opening a new shell in castle '#{castle}'. To return to the original one exit from the new shell.", - :green - inside castle_dir do - system(ENV['SHELL']) - end - end - - desc 'open CASTLE', - 'Open your default editor in the root of the given castle' - def open(castle = DEFAULT_CASTLE_NAME) - unless ENV['EDITOR'] - say_status :error, - 'The $EDITOR environment variable must be set to use this command', - :red - - exit(1) - end - check_castle_existance castle, 'open' - castle_dir = repos_dir.join(castle) - say_status "#{castle_dir.realpath}: #{ENV['EDITOR']} .", - "Opening the root directory of castle '#{castle}' in editor '#{ENV['EDITOR']}'.", - :green - inside castle_dir do - system("#{ENV['EDITOR']} .") - end - end - - desc 'exec CASTLE COMMAND', - 'Execute a single shell command inside the root of a castle' - def exec(castle, *args) - check_castle_existance castle, 'exec' - unless args.count > 0 - say_status :error, - 'You must pass a shell command to execute', - :red - exit(1) - end - full_command = args.join(' ') - say_status "exec '#{full_command}'", - "#{options[:pretend] ? 'Would execute' : 'Executing command'} '#{full_command}' in castle '#{castle}'", - :green - inside repos_dir.join(castle) do - system(full_command) - end - end - - desc 'exec_all COMMAND', - 'Execute a single shell command inside the root of every cloned castle' - def exec_all(*args) - unless args.count > 0 - say_status :error, - 'You must pass a shell command to execute', - :red - exit(1) - end - full_command = args.join(' ') - inside_each_castle do |castle| - say_status "exec '#{full_command}'", - "#{options[:pretend] ? 'Would execute' : 'Executing command'} '#{full_command}' in castle '#{castle}'", - :green - system(full_command) - end - end - - desc 'version', 'Display the current version of homesick' - def version - say Homesick::Version::STRING - end - end -end diff --git a/lib/homesick/utils.rb b/lib/homesick/utils.rb deleted file mode 100644 index 2bbc594..0000000 --- a/lib/homesick/utils.rb +++ /dev/null @@ -1,215 +0,0 @@ -require 'pathname' - -module Homesick - # Various utility methods that are used by Homesick - module Utils - protected - - def home_dir - @home_dir ||= Pathname.new(ENV['HOME'] || '~').realpath - end - - def repos_dir - @repos_dir ||= home_dir.join('.homesick', 'repos').expand_path - end - - def castle_dir(name) - repos_dir.join(name, 'home') - end - - def check_castle_existance(name, action) - return if castle_dir(name).exist? - - say_status :error, - "Could not #{action} #{name}, expected #{castle_dir(name)} to exist and contain dotfiles", - :red - exit(1) - end - - def all_castles - dirs = Pathname.glob("#{repos_dir}/**/.git", File::FNM_DOTMATCH) - # reject paths that lie inside another castle, like git submodules - dirs.reject do |dir| - dirs.any? do |other| - dir != other && dir.fnmatch(other.parent.join('*').to_s) - end - end - end - - def inside_each_castle - all_castles.each do |git_dir| - castle = git_dir.dirname - Dir.chdir castle do # so we can call git config from the right contxt - yield castle - end - end - end - - def update_castle(castle) - check_castle_existance(castle, 'pull') - inside repos_dir.join(castle) do - git_pull - git_submodule_init - git_submodule_update - end - end - - def commit_castle(castle, message) - check_castle_existance(castle, 'commit') - inside repos_dir.join(castle) do - git_commit_all message: message - end - end - - def push_castle(castle) - check_castle_existance(castle, 'push') - inside repos_dir.join(castle) do - git_push - end - end - - def subdir_file(castle) - repos_dir.join(castle, SUBDIR_FILENAME) - end - - def subdirs(castle) - subdir_filepath = subdir_file(castle) - subdirs = [] - if subdir_filepath.exist? - subdir_filepath.readlines.each do |subdir| - subdirs.push(subdir.chomp) - end - end - subdirs - end - - def subdir_add(castle, path) - subdir_filepath = subdir_file(castle) - File.open(subdir_filepath, 'a+') do |subdir| - subdir.puts path unless subdir.readlines.reduce(false) do |memo, line| - line.eql?("#{path}\n") || memo - end - end - - inside castle_dir(castle) do - git_add subdir_filepath - end - end - - def subdir_remove(castle, path) - subdir_filepath = subdir_file(castle) - if subdir_filepath.exist? - lines = IO.readlines(subdir_filepath).delete_if do |line| - line == "#{path}\n" - end - File.open(subdir_filepath, 'w') { |manfile| manfile.puts lines } - end - - inside castle_dir(castle) do - git_add subdir_filepath - end - end - - def move_dir_contents(target, dir_path) - child_files = dir_path.children - child_files.each do |child| - target_path = target.join(child.basename) - if target_path.exist? - if more_recent?(child, target_path) && target.file? - target_path.delete - mv child, target - end - next - end - - mv child, target - end - end - - def more_recent?(first, second) - first_p = Pathname.new(first) - second_p = Pathname.new(second) - first_p.mtime > second_p.mtime && !first_p.symlink? - end - - def collision_accepted?(destination, source) - raise "Arguments must be instances of Pathname, #{destination.class.name} and #{source.class.name} given" unless destination.instance_of?(Pathname) && source.instance_of?(Pathname) - - options[:force] || shell.file_collision(destination) { source } - end - - def unsymlink_each(castle, basedir, subdirs) - each_file(castle, basedir, subdirs) do |_absolute_path, home_path| - rm_link home_path - end - end - - def symlink_each(castle, basedir, subdirs) - each_file(castle, basedir, subdirs) do |absolute_path, home_path| - ln_s absolute_path, home_path - end - end - - def setup_castle(path) - if path.join('.gitmodules').exist? - inside path do - git_submodule_init - git_submodule_update - end - end - - rc(path) - end - - def each_file(castle, basedir, subdirs) - absolute_basedir = Pathname.new(basedir).expand_path - castle_home = castle_dir(castle) - inside basedir do |destination_root| - FileUtils.cd(destination_root) unless destination_root == FileUtils.pwd - files = Pathname.glob('*', File::FNM_DOTMATCH) - .reject { |a| ['.', '..'].include?(a.to_s) } - .reject { |path| matches_ignored_dir? castle_home, path.expand_path, subdirs } - files.each do |path| - absolute_path = path.expand_path - - relative_dir = absolute_basedir.relative_path_from(castle_home) - home_path = home_dir.join(relative_dir).join(path) - - yield(absolute_path, home_path) - end - end - end - - def matches_ignored_dir?(castle_home, absolute_path, subdirs) - # make ignore dirs - ignore_dirs = [] - subdirs.each do |subdir| - # ignore all parent of each line in subdir file - Pathname.new(subdir).ascend do |p| - ignore_dirs.push(p) - end - end - - # ignore dirs written in subdir file - ignore_dirs.uniq.each do |ignore_dir| - return true if absolute_path == castle_home.join(ignore_dir) - end - false - end - - def configure_symlinks_diff - # Hack in support for diffing symlinks - # Also adds support for checking if destination or content is a directory - shell_metaclass = class << shell; self; end - shell_metaclass.send(:define_method, :show_diff) do |destination, source| - destination = Pathname.new(destination) - source = Pathname.new(source) - return 'Unable to create diff: destination or content is a directory' if destination.directory? || source.directory? - return super(destination, File.binread(source)) unless destination.symlink? - - say "- #{destination.readlink}", :red, true - say "+ #{source.expand_path}", :green, true - end - end - end -end diff --git a/lib/homesick/version.rb b/lib/homesick/version.rb deleted file mode 100644 index d5e1fd3..0000000 --- a/lib/homesick/version.rb +++ /dev/null @@ -1,11 +0,0 @@ -module Homesick - # A representation of Homesick's version number in constants, including a - # String of the entire version number - module Version - MAJOR = 1 - MINOR = 1 - PATCH = 6 - - STRING = [MAJOR, MINOR, PATCH].compact.join('.') - end -end diff --git a/script/run-behavior-suite-docker.sh b/script/run-behavior-suite-docker.sh index e310595..3dd736a 100755 --- a/script/run-behavior-suite-docker.sh +++ b/script/run-behavior-suite-docker.sh @@ -1,7 +1,7 @@ #!/usr/bin/env bash set -euo pipefail -: "${HOMESICK_CMD:=ruby /workspace/bin/homesick}" +: "${HOMESICK_CMD:=/workspace/dist/homesick-go}" behavior_verbose="${BEHAVIOR_VERBOSE:-0}" while [[ $# -gt 0 ]]; do diff --git a/spec/homesick_cli_spec.rb b/spec/homesick_cli_spec.rb deleted file mode 100644 index dc06add..0000000 --- a/spec/homesick_cli_spec.rb +++ /dev/null @@ -1,861 +0,0 @@ -require 'spec_helper' -require 'capture-output' -require 'pathname' - -describe Homesick::CLI do - let(:home) { create_construct } - after { home.destroy! } - - let(:castles) { home.directory('.homesick/repos') } - - let(:homesick) { Homesick::CLI.new } - - before { allow(homesick).to receive(:repos_dir).and_return(castles) } - - describe 'smoke tests' do - context 'when running bin/homesick' do - before do - bin_path = Pathname.new(__FILE__).parent.parent - @output = `#{bin_path.expand_path}/bin/homesick` - end - it 'should output some text when bin/homesick is called' do - expect(@output.length).to be > 0 - end - end - - context 'when a git version that doesn\'t meet the minimum required is installed' do - before do - expect_any_instance_of(Homesick::Actions::GitActions).to receive(:`).and_return('git version 1.7.6') - end - it 'should raise an exception' do - output = Capture.stdout { expect { Homesick::CLI.new }.to raise_error SystemExit } - expect(output.chomp).to include(Homesick::Actions::GitActions::STRING) - end - end - - context 'when a git version that is the same as the minimum required is installed' do - before do - expect_any_instance_of(Homesick::Actions::GitActions).to receive(:`).at_least(:once).and_return("git version #{Homesick::Actions::GitActions::STRING}") - end - it 'should not raise an exception' do - output = Capture.stdout { expect { Homesick::CLI.new }.not_to raise_error } - expect(output.chomp).not_to include(Homesick::Actions::GitActions::STRING) - end - end - - context 'when a git version that is greater than the minimum required is installed' do - before do - expect_any_instance_of(Homesick::Actions::GitActions).to receive(:`).at_least(:once).and_return('git version 3.9.8') - end - it 'should not raise an exception' do - output = Capture.stdout { expect { Homesick::CLI.new }.not_to raise_error } - expect(output.chomp).not_to include(Homesick::Actions::GitActions::STRING) - end - end - end - - describe 'clone' do - context 'has a .homesickrc' do - it 'runs the .homesickrc' do - somewhere = create_construct - local_repo = somewhere.directory('some_repo') - local_repo.file('.homesickrc') do |file| - file << "File.open(Dir.pwd + '/testing', 'w') do |f| - f.print 'testing' - end" - end - - expect_any_instance_of(Thor::Shell::Basic).to receive(:yes?).with(be_a(String)).and_return(true) - expect(homesick).to receive(:say_status).with('eval', kind_of(Pathname)) - homesick.clone local_repo - - expect(castles.join('some_repo').join('testing')).to exist - end - end - - context 'of a file' do - it 'symlinks existing directories' do - somewhere = create_construct - local_repo = somewhere.directory('wtf') - - homesick.clone local_repo - - expect(castles.join('wtf').readlink).to eq(local_repo) - end - - context 'when it exists in a repo directory' do - before do - existing_castle = given_castle('existing_castle') - @existing_dir = existing_castle.parent - end - - it 'raises an error' do - expect(homesick).not_to receive(:git_clone) - expect { homesick.clone @existing_dir.to_s }.to raise_error(/already cloned/i) - end - end - end - - it 'clones git repo like file:///path/to.git' do - bare_repo = File.join(create_construct.to_s, 'dotfiles.git') - system "git init --bare #{bare_repo} >/dev/null 2>&1" - - # Capture stderr to suppress message about cloning an empty repo. - Capture.stderr do - homesick.clone "file://#{bare_repo}" - end - expect(File.directory?(File.join(home.to_s, '.homesick/repos/dotfiles'))) - .to be_truthy - end - - it 'clones git repo like git://host/path/to.git' do - expect(homesick).to receive(:git_clone) - .with('git://github.com/technicalpickles/pickled-vim.git', destination: Pathname.new('pickled-vim')) - - homesick.clone 'git://github.com/technicalpickles/pickled-vim.git' - end - - it 'clones git repo like git@host:path/to.git' do - expect(homesick).to receive(:git_clone) - .with('git@github.com:technicalpickles/pickled-vim.git', destination: Pathname.new('pickled-vim')) - - homesick.clone 'git@github.com:technicalpickles/pickled-vim.git' - end - - it 'clones git repo like http://host/path/to.git' do - expect(homesick).to receive(:git_clone) - .with('http://github.com/technicalpickles/pickled-vim.git', destination: Pathname.new('pickled-vim')) - - homesick.clone 'http://github.com/technicalpickles/pickled-vim.git' - end - - it 'clones git repo like http://host/path/to' do - expect(homesick).to receive(:git_clone) - .with('http://github.com/technicalpickles/pickled-vim', destination: Pathname.new('pickled-vim')) - - homesick.clone 'http://github.com/technicalpickles/pickled-vim' - end - - it 'clones git repo like host-alias:repos.git' do - expect(homesick).to receive(:git_clone).with('gitolite:pickled-vim.git', - destination: Pathname.new('pickled-vim')) - - homesick.clone 'gitolite:pickled-vim.git' - end - - it 'throws an exception when trying to clone a malformed uri like malformed' do - expect(homesick).not_to receive(:git_clone) - expect { homesick.clone 'malformed' }.to raise_error(RuntimeError) - end - - it 'clones a github repo' do - expect(homesick).to receive(:git_clone) - .with('https://github.com/wfarr/dotfiles.git', destination: Pathname.new('dotfiles')) - - homesick.clone 'wfarr/dotfiles' - end - - it 'accepts a destination', :focus do - expect(homesick).to receive(:git_clone) - .with('https://github.com/wfarr/dotfiles.git', - destination: Pathname.new('other-name')) - - homesick.clone 'wfarr/dotfiles', 'other-name' - end - end - - describe 'rc' do - let(:castle) { given_castle('glencairn') } - - context 'when told to do so' do - before do - expect_any_instance_of(Thor::Shell::Basic).to receive(:yes?).with(be_a(String)).and_return(true) - end - - it 'executes the .homesickrc' do - castle.file('.homesickrc') do |file| - file << "File.open(Dir.pwd + '/testing', 'w') do |f| - f.print 'testing' - end" - end - - expect(homesick).to receive(:say_status).with('eval', kind_of(Pathname)) - homesick.rc castle - - expect(castle.join('testing')).to exist - end - end - - context 'when options[:force] == true' do - let(:homesick) { Homesick::CLI.new [], force: true } - before do - expect_any_instance_of(Thor::Shell::Basic).to_not receive(:yes?) - end - - it 'executes the .homesickrc' do - castle.file('.homesickrc') do |file| - file << "File.open(Dir.pwd + '/testing', 'w') do |f| - f.print 'testing' - end" - end - - expect(homesick).to receive(:say_status).with('eval', kind_of(Pathname)) - homesick.rc castle - - expect(castle.join('testing')).to exist - end - end - - context 'when told not to do so' do - before do - expect_any_instance_of(Thor::Shell::Basic).to receive(:yes?).with(be_a(String)).and_return(false) - end - - it 'does not execute the .homesickrc' do - castle.file('.homesickrc') do |file| - file << "File.open(Dir.pwd + '/testing', 'w') do |f| - f.print 'testing' - end" - end - - expect(homesick).to receive(:say_status).with('eval skip', /not evaling.+/, :blue) - homesick.rc castle - - expect(castle.join('testing')).not_to exist - end - end - end - - describe 'link_castle' do - let(:castle) { given_castle('glencairn') } - - it 'links dotfiles from a castle to the home folder' do - dotfile = castle.file('.some_dotfile') - - homesick.link('glencairn') - - expect(home.join('.some_dotfile').readlink).to eq(dotfile) - end - - it 'links non-dotfiles from a castle to the home folder' do - dotfile = castle.file('bin') - - homesick.link('glencairn') - - expect(home.join('bin').readlink).to eq(dotfile) - end - - context 'when forced' do - let(:homesick) { Homesick::CLI.new [], force: true } - - it 'can override symlinks to directories' do - somewhere_else = create_construct - existing_dotdir_link = home.join('.vim') - FileUtils.ln_s somewhere_else, existing_dotdir_link - - dotdir = castle.directory('.vim') - - homesick.link('glencairn') - - expect(existing_dotdir_link.readlink).to eq(dotdir) - end - - it 'can override existing directory' do - existing_dotdir = home.directory('.vim') - - dotdir = castle.directory('.vim') - - homesick.link('glencairn') - - expect(existing_dotdir.readlink).to eq(dotdir) - end - end - - context "with '.config' in .homesick_subdir" do - let(:castle) { given_castle('glencairn', ['.config']) } - it 'can symlink in sub directory' do - dotdir = castle.directory('.config') - dotfile = dotdir.file('.some_dotfile') - - homesick.link('glencairn') - - home_dotdir = home.join('.config') - expect(home_dotdir.symlink?).to eq(false) - expect(home_dotdir.join('.some_dotfile').readlink).to eq(dotfile) - end - end - - context "with '.config/appA' in .homesick_subdir" do - let(:castle) { given_castle('glencairn', ['.config/appA']) } - it 'can symlink in nested sub directory' do - dotdir = castle.directory('.config').directory('appA') - dotfile = dotdir.file('.some_dotfile') - - homesick.link('glencairn') - - home_dotdir = home.join('.config').join('appA') - expect(home_dotdir.symlink?).to eq(false) - expect(home_dotdir.join('.some_dotfile').readlink).to eq(dotfile) - end - end - - context "with '.config' and '.config/someapp' in .homesick_subdir" do - let(:castle) do - given_castle('glencairn', ['.config', '.config/someapp']) - end - it 'can symlink under both of .config and .config/someapp' do - config_dir = castle.directory('.config') - config_dotfile = config_dir.file('.some_dotfile') - someapp_dir = config_dir.directory('someapp') - someapp_dotfile = someapp_dir.file('.some_appfile') - - homesick.link('glencairn') - - home_config_dir = home.join('.config') - home_someapp_dir = home_config_dir.join('someapp') - expect(home_config_dir.symlink?).to eq(false) - expect(home_config_dir.join('.some_dotfile').readlink).to eq(config_dotfile) - expect(home_someapp_dir.symlink?).to eq(false) - expect(home_someapp_dir.join('.some_appfile').readlink).to eq(someapp_dotfile) - end - end - - context 'when call with no castle name' do - let(:castle) { given_castle('dotfiles') } - it 'using default castle name: "dotfiles"' do - dotfile = castle.file('.some_dotfile') - - homesick.link - - expect(home.join('.some_dotfile').readlink).to eq(dotfile) - end - end - - context 'when call and some files conflict' do - it 'shows differences for conflicting text files' do - contents = { castle: 'castle has new content', home: 'home already has content' } - - dotfile = castle.file('text') - File.open(dotfile.to_s, 'w') do |f| - f.write contents[:castle] - end - File.open(home.join('text').to_s, 'w') do |f| - f.write contents[:home] - end - message = Capture.stdout { homesick.shell.show_diff(home.join('text'), dotfile) } - expect(message.b).to match(/- ?#{contents[:home]}\n.*\+ ?#{contents[:castle]}$/m) - end - it 'shows message or differences for conflicting binary files' do - # content which contains NULL character, without any parentheses, braces, ... - contents = { castle: (0..255).step(30).map(&:chr).join, home: (0..255).step(30).reverse_each.map(&:chr).join } - - dotfile = castle.file('binary') - File.open(dotfile.to_s, 'w') do |f| - f.write contents[:castle] - end - File.open(home.join('binary').to_s, 'w') do |f| - f.write contents[:home] - end - message = Capture.stdout { homesick.shell.show_diff(home.join('binary'), dotfile) } - if homesick.shell.is_a?(Thor::Shell::Color) - expect(message.b).to match(/- ?#{contents[:home]}\n.*\+ ?#{contents[:castle]}$/m) - elsif homesick.shell.is_a?(Thor::Shell::Basic) - expect(message.b).to match(/^Binary files .+ differ$/) - end - end - end - end - - describe 'unlink' do - let(:castle) { given_castle('glencairn') } - - it 'unlinks dotfiles in the home folder' do - castle.file('.some_dotfile') - - homesick.link('glencairn') - homesick.unlink('glencairn') - - expect(home.join('.some_dotfile')).not_to exist - end - - it 'unlinks non-dotfiles from the home folder' do - castle.file('bin') - - homesick.link('glencairn') - homesick.unlink('glencairn') - - expect(home.join('bin')).not_to exist - end - - context "with '.config' in .homesick_subdir" do - let(:castle) { given_castle('glencairn', ['.config']) } - - it 'can unlink sub directories' do - castle.directory('.config').file('.some_dotfile') - - homesick.link('glencairn') - homesick.unlink('glencairn') - - home_dotdir = home.join('.config') - expect(home_dotdir).to exist - expect(home_dotdir.join('.some_dotfile')).not_to exist - end - end - - context "with '.config/appA' in .homesick_subdir" do - let(:castle) { given_castle('glencairn', ['.config/appA']) } - - it 'can unsymlink in nested sub directory' do - castle.directory('.config').directory('appA').file('.some_dotfile') - - homesick.link('glencairn') - homesick.unlink('glencairn') - - home_dotdir = home.join('.config').join('appA') - expect(home_dotdir).to exist - expect(home_dotdir.join('.some_dotfile')).not_to exist - end - end - - context "with '.config' and '.config/someapp' in .homesick_subdir" do - let(:castle) do - given_castle('glencairn', ['.config', '.config/someapp']) - end - - it 'can unsymlink under both of .config and .config/someapp' do - config_dir = castle.directory('.config') - config_dir.file('.some_dotfile') - config_dir.directory('someapp').file('.some_appfile') - - homesick.link('glencairn') - homesick.unlink('glencairn') - - home_config_dir = home.join('.config') - home_someapp_dir = home_config_dir.join('someapp') - expect(home_config_dir).to exist - expect(home_config_dir.join('.some_dotfile')).not_to exist - expect(home_someapp_dir).to exist - expect(home_someapp_dir.join('.some_appfile')).not_to exist - end - end - - context 'when call with no castle name' do - let(:castle) { given_castle('dotfiles') } - - it 'using default castle name: "dotfiles"' do - castle.file('.some_dotfile') - - homesick.link - homesick.unlink - - expect(home.join('.some_dotfile')).not_to exist - end - end - end - - describe 'list' do - it 'says each castle in the castle directory' do - given_castle('zomg') - given_castle('wtf/zomg') - - expect(homesick).to receive(:say_status) - .with('zomg', 'git://github.com/technicalpickles/zomg.git', :cyan) - expect(homesick).to receive(:say_status) - .with('wtf/zomg', 'git://github.com/technicalpickles/zomg.git', :cyan) - - homesick.list - end - end - - describe 'status' do - it 'says "nothing to commit" when there are no changes' do - given_castle('castle_repo') - text = Capture.stdout { homesick.status('castle_repo') } - expect(text).to match(%r{nothing to commit \(create/copy files and use "git add" to track\)$}) - end - - it 'says "Changes to be committed" when there are changes' do - given_castle('castle_repo') - some_rc_file = home.file '.some_rc_file' - homesick.track(some_rc_file.to_s, 'castle_repo') - text = Capture.stdout { homesick.status('castle_repo') } - expect(text).to match(%r{Changes to be committed:.*new file:\s*home\/.some_rc_file}m) - end - end - - describe 'diff' do - it 'outputs an empty message when there are no changes to commit' do - given_castle('castle_repo') - some_rc_file = home.file '.some_rc_file' - homesick.track(some_rc_file.to_s, 'castle_repo') - Capture.stdout do - homesick.commit 'castle_repo', 'Adding a file to the test' - end - text = Capture.stdout { homesick.diff('castle_repo') } - expect(text).to eq('') - end - - it 'outputs a diff message when there are changes to commit' do - given_castle('castle_repo') - some_rc_file = home.file '.some_rc_file' - homesick.track(some_rc_file.to_s, 'castle_repo') - Capture.stdout do - homesick.commit 'castle_repo', 'Adding a file to the test' - end - File.open(some_rc_file.to_s, 'w') do |file| - file.puts 'Some test text' - end - text = Capture.stdout { homesick.diff('castle_repo') } - expect(text).to match(/diff --git.+Some test text$/m) - end - end - - describe 'show_path' do - it 'says the path of a castle' do - castle = given_castle('castle_repo') - - expect(homesick).to receive(:say).with(castle.dirname) - - homesick.show_path('castle_repo') - end - end - - describe 'pull' do - it 'performs a pull, submodule init and update when the given castle exists' do - given_castle('castle_repo') - allow(homesick).to receive(:system).once.with('git pull --quiet') - allow(homesick).to receive(:system).once.with('git submodule --quiet init') - allow(homesick).to receive(:system).once.with('git submodule --quiet update --init --recursive >/dev/null 2>&1') - homesick.pull 'castle_repo' - end - - it 'prints an error message when trying to pull a non-existant castle' do - expect(homesick).to receive('say_status').once - .with(:error, - /Could not pull castle_repo, expected .* to exist and contain dotfiles/, - :red) - expect { homesick.pull 'castle_repo' }.to raise_error(SystemExit) - end - - describe '--all' do - it 'pulls each castle when invoked with --all' do - given_castle('castle_repo') - given_castle('glencairn') - allow(homesick).to receive(:system).exactly(2).times.with('git pull --quiet') - allow(homesick).to receive(:system).exactly(2).times - .with('git submodule --quiet init') - allow(homesick).to receive(:system).exactly(2).times - .with('git submodule --quiet update --init --recursive >/dev/null 2>&1') - Capture.stdout do - Capture.stderr { homesick.invoke 'pull', [], all: true } - end - end - end - end - - describe 'push' do - it 'performs a git push on the given castle' do - given_castle('castle_repo') - allow(homesick).to receive(:system).once.with('git push') - homesick.push 'castle_repo' - end - - it 'prints an error message when trying to push a non-existant castle' do - expect(homesick).to receive('say_status').once - .with(:error, /Could not push castle_repo, expected .* to exist and contain dotfiles/, :red) - expect { homesick.push 'castle_repo' }.to raise_error(SystemExit) - end - end - - describe 'track' do - it 'moves the tracked file into the castle' do - castle = given_castle('castle_repo') - - some_rc_file = home.file '.some_rc_file' - - homesick.track(some_rc_file.to_s, 'castle_repo') - - tracked_file = castle.join('.some_rc_file') - expect(tracked_file).to exist - - expect(some_rc_file.readlink).to eq(tracked_file) - end - - it 'handles files with parens' do - castle = given_castle('castle_repo') - - some_rc_file = home.file 'Default (Linux).sublime-keymap' - - homesick.track(some_rc_file.to_s, 'castle_repo') - - tracked_file = castle.join('Default (Linux).sublime-keymap') - expect(tracked_file).to exist - - expect(some_rc_file.readlink).to eq(tracked_file) - end - - it 'tracks a file in nested folder structure' do - castle = given_castle('castle_repo') - - some_nested_file = home.file('some/nested/file.txt') - homesick.track(some_nested_file.to_s, 'castle_repo') - - tracked_file = castle.join('some/nested/file.txt') - expect(tracked_file).to exist - expect(some_nested_file.readlink).to eq(tracked_file) - end - - it 'tracks a nested directory' do - castle = given_castle('castle_repo') - - some_nested_dir = home.directory('some/nested/directory/') - homesick.track(some_nested_dir.to_s, 'castle_repo') - - tracked_file = castle.join('some/nested/directory/') - expect(tracked_file).to exist - expect(some_nested_dir.realpath).to eq(tracked_file.realpath) - end - - context 'when call with no castle name' do - it 'using default castle name: "dotfiles"' do - castle = given_castle('dotfiles') - - some_rc_file = home.file '.some_rc_file' - - homesick.track(some_rc_file.to_s) - - tracked_file = castle.join('.some_rc_file') - expect(tracked_file).to exist - - expect(some_rc_file.readlink).to eq(tracked_file) - end - end - - describe 'commit' do - it 'has a commit message when the commit succeeds' do - given_castle('castle_repo') - some_rc_file = home.file '.a_random_rc_file' - homesick.track(some_rc_file.to_s, 'castle_repo') - text = Capture.stdout do - homesick.commit('castle_repo', 'Test message') - end - expect(text).to match(/^\[master \(root-commit\) \w+\] Test message/) - end - end - - # Note that this is a test for the subdir_file related feature of track, - # not for the subdir_file method itself. - describe 'subdir_file' do - it 'adds the nested files parent to the subdir_file' do - castle = given_castle('castle_repo') - - some_nested_file = home.file('some/nested/file.txt') - homesick.track(some_nested_file.to_s, 'castle_repo') - - subdir_file = castle.parent.join(Homesick::SUBDIR_FILENAME) - File.open(subdir_file, 'r') do |f| - expect(f.readline).to eq("some/nested\n") - end - end - - it 'does NOT add anything if the files parent is already listed' do - castle = given_castle('castle_repo') - - some_nested_file = home.file('some/nested/file.txt') - other_nested_file = home.file('some/nested/other.txt') - homesick.track(some_nested_file.to_s, 'castle_repo') - homesick.track(other_nested_file.to_s, 'castle_repo') - - subdir_file = castle.parent.join(Homesick::SUBDIR_FILENAME) - File.open(subdir_file, 'r') do |f| - expect(f.readlines.size).to eq(1) - end - end - - it 'removes the parent of a tracked file from the subdir_file if the parent itself is tracked' do - castle = given_castle('castle_repo') - - some_nested_file = home.file('some/nested/file.txt') - nested_parent = home.directory('some/nested/') - homesick.track(some_nested_file.to_s, 'castle_repo') - homesick.track(nested_parent.to_s, 'castle_repo') - - subdir_file = castle.parent.join(Homesick::SUBDIR_FILENAME) - File.open(subdir_file, 'r') do |f| - f.each_line { |line| expect(line).not_to eq("some/nested\n") } - end - end - end - end - - describe 'destroy' do - it 'removes the symlink files' do - expect_any_instance_of(Thor::Shell::Basic).to receive(:yes?).and_return('y') - given_castle('stronghold') - some_rc_file = home.file '.some_rc_file' - homesick.track(some_rc_file.to_s, 'stronghold') - homesick.destroy('stronghold') - - expect(some_rc_file).not_to be_exist - end - - it 'deletes the cloned repository' do - expect_any_instance_of(Thor::Shell::Basic).to receive(:yes?).and_return('y') - castle = given_castle('stronghold') - some_rc_file = home.file '.some_rc_file' - homesick.track(some_rc_file.to_s, 'stronghold') - homesick.destroy('stronghold') - - expect(castle).not_to be_exist - end - end - - describe 'cd' do - it "cd's to the root directory of the given castle" do - given_castle('castle_repo') - expect(homesick).to receive('inside').once.with(kind_of(Pathname)).and_yield - expect(homesick).to receive('system').once.with(ENV['SHELL']) - Capture.stdout { homesick.cd 'castle_repo' } - end - - it 'returns an error message when the given castle does not exist' do - expect(homesick).to receive('say_status').once - .with(:error, /Could not cd castle_repo, expected .* to exist and contain dotfiles/, :red) - expect { homesick.cd 'castle_repo' }.to raise_error(SystemExit) - end - end - - describe 'open' do - it 'opens the system default editor in the root of the given castle' do - # Make sure calls to ENV use default values for most things... - allow(ENV).to receive(:[]).and_call_original - # Set a default value for 'EDITOR' just in case none is set - allow(ENV).to receive(:[]).with('EDITOR').and_return('vim') - given_castle 'castle_repo' - expect(homesick).to receive('inside').once.with(kind_of(Pathname)).and_yield - expect(homesick).to receive('system').once.with('vim .') - Capture.stdout { homesick.open 'castle_repo' } - end - - it 'returns an error message when the $EDITOR environment variable is not set' do - # Return empty ENV, the test does not call it anyway - allow(ENV).to receive(:[]).and_return(nil) - # Set the default editor to make sure it fails. - allow(ENV).to receive(:[]).with('EDITOR').and_return(nil) - expect(homesick).to receive('say_status').once - .with(:error, 'The $EDITOR environment variable must be set to use this command', :red) - expect { homesick.open 'castle_repo' }.to raise_error(SystemExit) - end - - it 'returns an error message when the given castle does not exist' do - # Return empty ENV, the test does not call it anyway - allow(ENV).to receive(:[]).and_return(nil) - # Set a default just in case none is set - allow(ENV).to receive(:[]).with('EDITOR').and_return('vim') - allow(homesick).to receive('say_status').once - .with(:error, /Could not open castle_repo, expected .* to exist and contain dotfiles/, :red) - expect { homesick.open 'castle_repo' }.to raise_error(SystemExit) - end - end - - describe 'version' do - it 'prints the current version of homesick' do - text = Capture.stdout { homesick.version } - expect(text.chomp).to match(/#{Regexp.escape(Homesick::Version::STRING)}/) - end - end - - describe 'exec' do - before do - given_castle 'castle_repo' - end - it 'executes a single command with no arguments inside a given castle' do - allow(homesick).to receive('inside').once.with(kind_of(Pathname)).and_yield - allow(homesick).to receive('say_status').once - .with(be_a(String), be_a(String), :green) - allow(homesick).to receive('system').once.with('ls') - Capture.stdout { homesick.exec 'castle_repo', 'ls' } - end - - it 'executes a single command with arguments inside a given castle' do - allow(homesick).to receive('inside').once.with(kind_of(Pathname)).and_yield - allow(homesick).to receive('say_status').once - .with(be_a(String), be_a(String), :green) - allow(homesick).to receive('system').once.with('ls -la') - Capture.stdout { homesick.exec 'castle_repo', 'ls', '-la' } - end - - it 'raises an error when the method is called without a command' do - allow(homesick).to receive('say_status').once - .with(:error, be_a(String), :red) - allow(homesick).to receive('exit').once.with(1) - Capture.stdout { homesick.exec 'castle_repo' } - end - - context 'pretend' do - it 'does not execute a command when the pretend option is passed' do - allow(homesick).to receive('say_status').once - .with(be_a(String), match(/.*Would execute.*/), :green) - expect(homesick).to receive('system').never - Capture.stdout { homesick.invoke 'exec', %w[castle_repo ls -la], pretend: true } - end - end - - context 'quiet' do - it 'does not print status information when quiet is passed' do - expect(homesick).to receive('say_status').never - allow(homesick).to receive('system').once - .with('ls -la') - Capture.stdout { homesick.invoke 'exec', %w[castle_repo ls -la], quiet: true } - end - end - end - - describe 'exec_all' do - before do - given_castle 'castle_repo' - given_castle 'another_castle_repo' - end - - it 'executes a command without arguments inside the root of each cloned castle' do - allow(homesick).to receive('inside_each_castle').exactly(:twice).and_yield('castle_repo').and_yield('another_castle_repo') - allow(homesick).to receive('say_status').at_least(:once) - .with(be_a(String), be_a(String), :green) - allow(homesick).to receive('system').at_least(:once).with('ls') - Capture.stdout { homesick.exec_all 'ls' } - end - - it 'executes a command with arguments inside the root of each cloned castle' do - allow(homesick).to receive('inside_each_castle').exactly(:twice).and_yield('castle_repo').and_yield('another_castle_repo') - allow(homesick).to receive('say_status').at_least(:once) - .with(be_a(String), be_a(String), :green) - allow(homesick).to receive('system').at_least(:once).with('ls -la') - Capture.stdout { homesick.exec_all 'ls', '-la' } - end - - it 'raises an error when the method is called without a command' do - allow(homesick).to receive('say_status').once - .with(:error, be_a(String), :red) - allow(homesick).to receive('exit').once.with(1) - Capture.stdout { homesick.exec_all } - end - - context 'pretend' do - it 'does not execute a command when the pretend option is passed' do - allow(homesick).to receive('say_status').at_least(:once) - .with(be_a(String), match(/.*Would execute.*/), :green) - expect(homesick).to receive('system').never - Capture.stdout { homesick.invoke 'exec_all', %w[ls -la], pretend: true } - end - end - - context 'quiet' do - it 'does not print status information when quiet is passed' do - expect(homesick).to receive('say_status').never - allow(homesick).to receive('system').at_least(:once) - .with('ls -la') - Capture.stdout { homesick.invoke 'exec_all', %w[ls -la], quiet: true } - end - end - end -end diff --git a/spec/spec.opts b/spec/spec.opts deleted file mode 100644 index 4e1e0d2..0000000 --- a/spec/spec.opts +++ /dev/null @@ -1 +0,0 @@ ---color diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb deleted file mode 100644 index 9eed58f..0000000 --- a/spec/spec_helper.rb +++ /dev/null @@ -1,42 +0,0 @@ -require 'coveralls' -Coveralls.wear! - -$LOAD_PATH.unshift(File.dirname(__FILE__)) -$LOAD_PATH.unshift(File.join(File.dirname(__FILE__), '..', 'lib')) -require 'homesick' -require 'rspec' -require 'test_construct' -require 'tempfile' - -RSpec.configure do |config| - config.include TestConstruct::Helpers - - config.expect_with(:rspec) { |c| c.syntax = :expect } - - config.before { ENV['HOME'] = home.to_s } - - config.before { silence! } - - def silence! - allow(homesick).to receive(:say_status) - end - - def given_castle(path, subdirs = []) - name = Pathname.new(path).basename - castles.directory(path) do |castle| - Dir.chdir(castle) do - system 'git init >/dev/null 2>&1' - system 'git config user.email "test@test.com"' - system 'git config user.name "Test Name"' - system "git remote add origin git://github.com/technicalpickles/#{name}.git >/dev/null 2>&1" - if subdirs - subdir_file = castle.join(Homesick::SUBDIR_FILENAME) - subdirs.each do |subdir| - File.open(subdir_file, 'a') { |file| file.write "\n#{subdir}\n" } - end - end - return castle.directory('home') - end - end - end -end diff --git a/test/behavior/behavior_suite.sh b/test/behavior/behavior_suite.sh index 4bceaaf..e8fff28 100755 --- a/test/behavior/behavior_suite.sh +++ b/test/behavior/behavior_suite.sh @@ -1,7 +1,7 @@ #!/usr/bin/env bash set -euo pipefail -: "${HOMESICK_CMD:=ruby /workspace/bin/homesick}" +: "${HOMESICK_CMD:=/workspace/dist/homesick-go}" : "${BEHAVIOR_VERBOSE:=0}" RUN_OUTPUT="" -- 2.49.1 From c10ff251d5db60de19dd263b344831873a01bcb3 Mon Sep 17 00:00:00 2001 From: Micheal Wilkinson Date: Thu, 19 Mar 2026 16:26:31 +0000 Subject: [PATCH 018/162] docs(changelog): update formatting --- ChangeLog.markdown | 126 ------------------------ changelog.md | 240 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 240 insertions(+), 126 deletions(-) delete mode 100644 ChangeLog.markdown create mode 100644 changelog.md diff --git a/ChangeLog.markdown b/ChangeLog.markdown deleted file mode 100644 index e598d92..0000000 --- a/ChangeLog.markdown +++ /dev/null @@ -1,126 +0,0 @@ -# 1.1.6 - * Makesure the FileUtils is imported correctly to avoid a potential error - * Fixes an issue where comparing a diff would not use the content of the new file - * Small documentation fixes - -# 1.1.5 - * Fixed problem with version number being incorrect. - -# 1.1.4 - * Make sure symlink conflicts are explicitly communicated to a user and symlinks are not silently overwritten - * Use real paths of symlinks when linking a castle into home - * Fix a problem when in a diff when asking a user to resolve a conflict - * Some code refactoring and fixes - -# 1.1.3 - * Allow a destination to be passed when cloning a castle - * Make sure `homesick edit` opens default editor in the root of the given castle - * Fixed bug when diffing edited files - * Fixed crashing bug when attempting to diff directories - * Ensure that messages are escaped correctly on `git commit all` - -# 1.1.2 - * Added '--force' option to the rc command to bypass confirmation checks when running a .homesickrc file - * Added a check to make sure that a minimum of Git 1.8.0 is installed. This stops Homesick failing silently if Git is not installed. - * Code refactoring and fixes. - -# 1.1.0 - * Added exec and exec_all commands to run commands inside one or all clones castles. - * Code refactoring. - -# 1.0.0 - * Removed support for Ruby 1.8.7 - * Added a version command - -# 0.9.8 - * Introduce new commands - * `homesick cd` - * `homesick open` - -# 0.9.4 - * Use https protocol instead of git protocol - * Introduce new commands - * `homesick unlink` - * `homesick rc` - -# 0.9.3 - * Add recursive option to `homesick clone` - -# 0.9.2 - * Set "dotfiles" as default castle name - * Introduce new commands - * `homesick show_path` - * `homesick status` - * `homesick diff` - -# 0.9.1 - * Fixed small bugs: #35, #40 - -# 0.9.0 - * Introduce .homesick_subdir #39 - -# 0.8.1 - * Fixed `homesick list` bug on ruby 2.0 #37 - -# 0.8.0 - * Introduce commit & push command - * commit changes in castle and push to remote - * Enable recursive submodule update - * Git add when track - -# 0.7.0 - * Fixed double-cloning #14 - * New option for pull command: --all - * pulls each castle, instead of just one - -# 0.6.1 - - * Add a license - -# 0.6.0 - - * Introduce .homesickrc - * Castles can now have a .homesickrc inside them - * On clone, this is eval'd inside the destination directory - * Introduce track command - * Allows easily moving an existing file into a castle, and symlinking it back - -# 0.5.0 - - * Fixed listing of castles cloned using `homesick clone /` (issue 3) - * Added `homesick pull ` for updating castles (thanks Jorge Dias!) - * Added a very basic `homesick generate ` - -# 0.4.1 - - * Improved error message when a castle's home dir doesn't exist - -# 0.4.0 - - * `homesick clone` can now take a path to a directory on the filesystem, which will be symlinked into place - * `homesick clone` now tries to `git submodule init` and `git submodule update` if git submodules are defined for a cloned repo - * Fixed missing dependency on thor and others - * Use HOME environment variable for where to store files, instead of assuming ~ - -# 0.3.0 - - * Renamed 'link' to 'symlink' - * Fixed conflict resolution when symlink destination exists and is a normal file - -# 0.2.0 - - * Better support for recognizing git urls (thanks jacobat!) - * if it looks like a github user/repo, do that - * otherwise hand off to git clone - * Listing now displays in color, and show git remote - * Support pretend, force, and quiet modes - -# 0.1.1 - - * Fixed trying to link against castles that don't exist - * Fixed linking, which tries to exclude . and .. from the list of files to - link (thanks Martinos!) - -# 0.1.0 - - * Initial release diff --git a/changelog.md b/changelog.md new file mode 100644 index 0000000..7025b31 --- /dev/null +++ b/changelog.md @@ -0,0 +1,240 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +## [1.1.6] - 2017-12-20 + +### Fixed + +- Ensure `FileUtils` is imported correctly to avoid a potential error. +- Fix an issue where comparing a diff did not use the content of the new file. + +### Changed + +- Small documentation fixes. + +## [1.1.5] - 2017-03-23 + +### Fixed + +- Problem with version number being incorrect. + +## [1.1.4] - 2017-03-22 + +### Fixed + +- Ensure symlink conflicts are explicitly communicated to users and symlinks are not silently overwritten. +- Fix a problem in diff when asking a user to resolve a conflict. + +### Changed + +- Use real paths of symlinks when linking a castle into home. +- Code refactoring and fixes. + +## [1.1.3] - 2015-10-31 + +### Added + +- Allow a destination to be passed when cloning a castle. + +### Fixed + +- Make sure `homesick edit` opens the default editor in the root of the given castle. +- Bug when diffing edited files. +- Crashing bug when attempting to diff directories. +- Ensure that messages are escaped correctly on `git commit all`. + +## [1.1.2] - 2015-01-02 + +### Added + +- `--force` option to the rc command to bypass confirmation checks when running a `.homesickrc` file. +- Check to ensure that at least Git 1.8.0 is installed. + +### Fixed + +- Stop Homesick failing silently when Git is not installed. + +### Changed + +- Code refactoring and fixes. + +## [1.1.0] - 2014-04-28 + +### Added + +- `exec` and `exec_all` commands to run commands inside one or all cloned castles. + +### Changed + +- Code refactoring. + +## [1.0.0] - 2014-01-15 + +### Added + +- `version` command. + +### Removed + +- Support for Ruby 1.8.7. + +## [0.9.8] - 2014-01-02 + +### Added + +- `homesick cd` command. +- `homesick open` command. + +## [0.9.4] - 2013-07-31 + +### Added + +- `homesick unlink` command. +- `homesick rc` command. + +### Changed + +- Use HTTPS protocol instead of git protocol. + +## [0.9.3] - 2013-07-07 + +### Added + +- Recursive option to `homesick clone`. + +## [0.9.2] - 2013-06-27 + +### Added + +- `homesick show_path` command. +- `homesick status` command. +- `homesick diff` command. + +### Changed + +- Set `dotfiles` as default castle name. + +## [0.9.1] - 2013-06-17 + +### Fixed + +- Small bugs: #35, #40. + +## [0.9.0] - 2013-06-06 + +### Added + +- `.homesick_subdir` (#39). + +## [0.8.1] - 2013-05-19 + +### Fixed + +- `homesick list` bug on Ruby 2.0 (#37). + +## [0.8.0] - 2013-04-06 + +### Added + +- `commit` and `push` command. +- Commit changes in a castle and push to remote. +- Enable recursive submodule update. +- Git add when using track. + +## [0.7.0] - 2012-05-28 + +### Added + +- New option for pull command: `--all`. +- Pull each castle instead of just one. + +### Fixed + +- Double-cloning (#14). + +## [0.6.1] - 2010-11-13 + +### Added + +- License. + +## [0.6.0] - 2010-10-27 + +### Added + +- `.homesickrc` support. +- Castles can now have a `.homesickrc` inside them. +- On clone, this is eval'd inside the destination directory. +- `track` command. +- Allows easily moving an existing file into a castle and symlinking it back. + +## [0.5.0] - 2010-05-18 + +### Added + +- `homesick pull ` for updating castles (thanks Jorge Dias). +- A very basic `homesick generate `. + +### Fixed + +- Listing of castles cloned using `homesick clone /` (issue 3). + +## [0.4.1] - 2010-04-02 + +### Fixed + +- Improve error message when a castle's home dir does not exist. + +## [0.4.0] - 2010-04-01 + +### Added + +- `homesick clone` can take a path to a directory on the filesystem, which is symlinked into place. +- `homesick clone` tries to run `git submodule init` and `git submodule update` if git submodules are defined for a cloned repo. + +### Changed + +- Use `HOME` environment variable for where to store files, instead of assuming `~`. + +### Fixed + +- Missing dependency on thor and others. + +## [0.3.0] - 2010-04-01 + +### Changed + +- Rename `link` to `symlink`. + +### Fixed + +- Conflict resolution when symlink destination exists and is a normal file. + +## [0.2.0] - 2010-03-19 + +### Added + +- Better support for recognizing git URLs (thanks jacobat). +- If it looks like a GitHub user/repo, use that. +- Otherwise hand off to git clone. +- Listing now displays in color and shows git remote. +- Support pretend, force, and quiet modes. + +## [0.1.1] - 2010-03-17 + +### Fixed + +- Trying to link against castles that do not exist. +- Linking now excludes `.` and `..` from the list of files to link (thanks Martinos). + +## [0.1.0] - 2010-03-10 + +### Added + +- Initial release. -- 2.49.1 From 1d2659401066156d0e335af1fbaf2ce982bf428d Mon Sep 17 00:00:00 2001 From: Micheal Wilkinson Date: Thu, 19 Mar 2026 16:28:44 +0000 Subject: [PATCH 019/162] docs(changelog): add unreleased migration notes --- changelog.md | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/changelog.md b/changelog.md index 7025b31..8b215f8 100644 --- a/changelog.md +++ b/changelog.md @@ -7,6 +7,27 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Native Go implementations for `clone`, `link`, `unlink`, and `track`. +- Containerized behavior test suite for command parity validation. +- Dedicated test suites for `list`, `show_path`, `status`, `diff`, and `version`. +- Just workflow support for building and running the Linux behavior binary. + +### Changed + +- CLI argument parsing migrated to Kong. +- Git operations for clone and track migrated to `go-git`. +- Release notes standardized to Keep a Changelog format. + +### Fixed + +- `status` and `diff` now consistently write through configured app output writers. + +### Removed + +- Legacy Ruby implementation and Ruby toolchain. + ## [1.1.6] - 2017-12-20 ### Fixed -- 2.49.1 From 0dfacc31d4cb6a0ef9ba0031b979556647ff426b Mon Sep 17 00:00:00 2001 From: Micheal Wilkinson Date: Thu, 19 Mar 2026 16:33:45 +0000 Subject: [PATCH 020/162] chore(build): rename binary to gosick --- README.md | 2 +- bin/homesick | 4 ++-- changelog.md | 1 + docker/behavior/Dockerfile | 4 ++-- justfile | 4 ++-- script/run-behavior-suite-docker.sh | 2 +- test/behavior/behavior_suite.sh | 2 +- 7 files changed, 10 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 5351707..12e169e 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@ just go-build Or directly with Go: ```bash -go build -o dist/homesick-go ./cmd/homesick +go build -o dist/gosick ./cmd/homesick ``` ## Commands diff --git a/bin/homesick b/bin/homesick index d36558e..c3909a7 100755 --- a/bin/homesick +++ b/bin/homesick @@ -4,8 +4,8 @@ set -euo pipefail script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" repo_root="$(cd "$script_dir/.." && pwd)" -if [[ -x "$repo_root/dist/homesick-go" ]]; then - exec "$repo_root/dist/homesick-go" "$@" +if [[ -x "$repo_root/dist/gosick" ]]; then + exec "$repo_root/dist/gosick" "$@" fi exec go run "$repo_root/cmd/homesick" "$@" diff --git a/changelog.md b/changelog.md index 8b215f8..800c710 100644 --- a/changelog.md +++ b/changelog.md @@ -18,6 +18,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - CLI argument parsing migrated to Kong. - Git operations for clone and track migrated to `go-git`. +- Build and behavior workflows now produce and run the `gosick` binary name. - Release notes standardized to Keep a Changelog format. ### Fixed diff --git a/docker/behavior/Dockerfile b/docker/behavior/Dockerfile index e5d0eab..3b38b4a 100644 --- a/docker/behavior/Dockerfile +++ b/docker/behavior/Dockerfile @@ -5,7 +5,7 @@ COPY go.mod go.sum /workspace/ RUN go mod download COPY . /workspace RUN mkdir -p /workspace/dist && \ - go build -o /workspace/dist/homesick-go ./cmd/homesick + go build -o /workspace/dist/gosick ./cmd/homesick FROM alpine:3.21 @@ -16,6 +16,6 @@ RUN apk add --no-cache \ WORKDIR /workspace COPY . /workspace -COPY --from=builder /workspace/dist/homesick-go /workspace/dist/homesick-go +COPY --from=builder /workspace/dist/gosick /workspace/dist/gosick ENTRYPOINT ["/workspace/test/behavior/behavior_suite.sh"] diff --git a/justfile b/justfile index 9267197..ea5dabb 100644 --- a/justfile +++ b/justfile @@ -5,11 +5,11 @@ default: go-build: @mkdir -p dist - go build -o dist/homesick-go ./cmd/homesick + go build -o dist/gosick ./cmd/homesick go-build-linux: @mkdir -p dist - GOOS=linux GOARCH="$(go env GOARCH)" go build -o dist/homesick-go ./cmd/homesick + GOOS=linux GOARCH="$(go env GOARCH)" go build -o dist/gosick ./cmd/homesick go-test: go test ./... diff --git a/script/run-behavior-suite-docker.sh b/script/run-behavior-suite-docker.sh index 3dd736a..446fa28 100755 --- a/script/run-behavior-suite-docker.sh +++ b/script/run-behavior-suite-docker.sh @@ -1,7 +1,7 @@ #!/usr/bin/env bash set -euo pipefail -: "${HOMESICK_CMD:=/workspace/dist/homesick-go}" +: "${HOMESICK_CMD:=/workspace/dist/gosick}" behavior_verbose="${BEHAVIOR_VERBOSE:-0}" while [[ $# -gt 0 ]]; do diff --git a/test/behavior/behavior_suite.sh b/test/behavior/behavior_suite.sh index e8fff28..93ccfa1 100755 --- a/test/behavior/behavior_suite.sh +++ b/test/behavior/behavior_suite.sh @@ -1,7 +1,7 @@ #!/usr/bin/env bash set -euo pipefail -: "${HOMESICK_CMD:=/workspace/dist/homesick-go}" +: "${HOMESICK_CMD:=/workspace/dist/gosick}" : "${BEHAVIOR_VERBOSE:=0}" RUN_OUTPUT="" -- 2.49.1 From a7e4c501e4643c2b0e5377136b7b8dcd2e180960 Mon Sep 17 00:00:00 2001 From: Micheal Wilkinson Date: Fri, 20 Mar 2026 09:37:09 +0000 Subject: [PATCH 021/162] ci(gitea): add validation and release workflows --- .gitea/workflows/pr-merge-validation.yml | 27 ++++++ .gitea/workflows/pr-validation.yml | 26 ++++++ .gitea/workflows/push-unit-tests.yml | 23 +++++ .gitea/workflows/tag-build-artifacts.yml | 107 +++++++++++++++++++++++ 4 files changed, 183 insertions(+) create mode 100644 .gitea/workflows/pr-merge-validation.yml create mode 100644 .gitea/workflows/pr-validation.yml create mode 100644 .gitea/workflows/push-unit-tests.yml create mode 100644 .gitea/workflows/tag-build-artifacts.yml diff --git a/.gitea/workflows/pr-merge-validation.yml b/.gitea/workflows/pr-merge-validation.yml new file mode 100644 index 0000000..38b13f9 --- /dev/null +++ b/.gitea/workflows/pr-merge-validation.yml @@ -0,0 +1,27 @@ +name: Pull Request Merge Validation + +on: + pull_request_target: + types: + - closed + +jobs: + validate-merged-pr: + if: ${{ github.event.pull_request.merged == true }} + runs-on: ubuntu-latest + steps: + - name: Checkout merged commit + uses: actions/checkout@v4 + with: + ref: ${{ github.event.pull_request.merge_commit_sha }} + + - name: Setup Go + uses: actions/setup-go@v5 + with: + go-version-file: go.mod + + - name: Run full unit test suite + run: go test ./... + + - name: Run behavior suite + run: ./script/run-behavior-suite-docker.sh diff --git a/.gitea/workflows/pr-validation.yml b/.gitea/workflows/pr-validation.yml new file mode 100644 index 0000000..106a130 --- /dev/null +++ b/.gitea/workflows/pr-validation.yml @@ -0,0 +1,26 @@ +name: Pull Request Validation + +on: + pull_request: + types: + - opened + - synchronize + - reopened + +jobs: + validate: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Go + uses: actions/setup-go@v5 + with: + go-version-file: go.mod + + - name: Run full unit test suite + run: go test ./... + + - name: Run behavior suite + run: ./script/run-behavior-suite-docker.sh diff --git a/.gitea/workflows/push-unit-tests.yml b/.gitea/workflows/push-unit-tests.yml new file mode 100644 index 0000000..8a84b65 --- /dev/null +++ b/.gitea/workflows/push-unit-tests.yml @@ -0,0 +1,23 @@ +name: Push Unit Tests + +on: + push: + branches: + - "**" + tags-ignore: + - "*" + +jobs: + unit-tests: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Go + uses: actions/setup-go@v5 + with: + go-version-file: go.mod + + - name: Run full unit test suite + run: go test ./... diff --git a/.gitea/workflows/tag-build-artifacts.yml b/.gitea/workflows/tag-build-artifacts.yml new file mode 100644 index 0000000..d1d633a --- /dev/null +++ b/.gitea/workflows/tag-build-artifacts.yml @@ -0,0 +1,107 @@ +name: Tag Build Artifacts + +on: + push: + tags: + - "v*" + +jobs: + build: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + include: + - goos: linux + goarch: amd64 + - goos: linux + goarch: arm64 + - goos: darwin + goarch: amd64 + - goos: darwin + goarch: arm64 + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Go + uses: actions/setup-go@v5 + with: + go-version-file: go.mod + + - name: Build binary + run: | + mkdir -p dist + GOOS=${{ matrix.goos }} GOARCH=${{ matrix.goarch }} CGO_ENABLED=0 \ + go build -o dist/gosick_${{ matrix.goos }}_${{ matrix.goarch }} ./cmd/homesick + + - name: Package artifact + run: | + cd dist + tar -czf gosick_${{ matrix.goos }}_${{ matrix.goarch }}.tar.gz gosick_${{ matrix.goos }}_${{ matrix.goarch }} + + - name: Publish workflow artifact + uses: actions/upload-artifact@v4 + with: + name: gosick_${{ matrix.goos }}_${{ matrix.goarch }} + path: dist/gosick_${{ matrix.goos }}_${{ matrix.goarch }}.tar.gz + + release: + runs-on: ubuntu-latest + needs: build + env: + RELEASE_TOKEN: ${{ secrets.GITHUB_TOKEN }} + steps: + - name: Download build artifacts + uses: actions/download-artifact@v4 + with: + path: dist + + - name: Ensure jq is installed + run: | + if ! command -v jq >/dev/null 2>&1; then + sudo apt-get update + sudo apt-get install -y jq + fi + + - name: Create release if needed and upload assets + run: | + set -euo pipefail + + if [[ -z "${RELEASE_TOKEN:-}" ]]; then + echo "RELEASE_TOKEN is empty. Expected secrets.GITHUB_TOKEN to be available." >&2 + exit 1 + fi + + tag="${GITHUB_REF_NAME}" + api_base="${GITHUB_SERVER_URL}/api/v1/repos/${GITHUB_REPOSITORY}" + + release_json="$(curl -sS -H "Authorization: token ${RELEASE_TOKEN}" "${api_base}/releases/tags/${tag}" || true)" + release_id="$(printf '%s' "${release_json}" | jq -r '.id // empty')" + + if [[ -z "${release_id}" ]]; then + create_payload="$(jq -n --arg tag "${tag}" --arg name "${tag}" '{tag_name:$tag, name:$name, draft:false, prerelease:false}')" + release_json="$(curl -sS -X POST \ + -H "Authorization: token ${RELEASE_TOKEN}" \ + -H "Content-Type: application/json" \ + -d "${create_payload}" \ + "${api_base}/releases")" + release_id="$(printf '%s' "${release_json}" | jq -r '.id // empty')" + fi + + if [[ -z "${release_id}" ]]; then + echo "Unable to determine or create release id for tag ${tag}" >&2 + printf '%s\n' "${release_json}" >&2 + exit 1 + fi + + find dist -type f -name '*.tar.gz' -print0 | while IFS= read -r -d '' file; do + asset_name="$(basename "${file}")" + curl -sS -X POST \ + -H "Authorization: token ${RELEASE_TOKEN}" \ + -H "Content-Type: application/octet-stream" \ + --data-binary @"${file}" \ + "${api_base}/releases/${release_id}/assets?name=${asset_name}" + echo "Uploaded ${asset_name}" + done -- 2.49.1 From aa66695665413ae00f0cc03e5ce5120cf9c5aff7 Mon Sep 17 00:00:00 2001 From: Micheal Wilkinson Date: Fri, 20 Mar 2026 09:41:28 +0000 Subject: [PATCH 022/162] docs(readme): add workflow status badges --- README.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/README.md b/README.md index 12e169e..6914f2d 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,9 @@ # homesick +[![Main Unit Tests](https://git.hrafn.xyz/aether/gosick/actions/workflows/push-unit-tests.yml?branch=main&event=push)](https://git.hrafn.xyz/aether/gosick/actions/workflows/push-unit-tests.yml) +[![PR Validation](https://git.hrafn.xyz/aether/gosick/actions/workflows/pr-validation.yml?branch=main&event=pull_request)](https://git.hrafn.xyz/aether/gosick/actions/workflows/pr-validation.yml) +[![Tag Build Artifacts](https://git.hrafn.xyz/aether/gosick/actions/workflows/tag-build-artifacts.yml?event=push)](https://git.hrafn.xyz/aether/gosick/actions/workflows/tag-build-artifacts.yml) + Your home directory is your castle. Don't leave your dotfiles behind. This repository now contains a Go implementation of Homesick. A dotfiles repository is called a castle and should contain a `home/` directory with files to link into your `$HOME`. -- 2.49.1 From 0034a6f4e2a2b2ca1f38f7c13b4bafbe8c9f101e Mon Sep 17 00:00:00 2001 From: Micheal Wilkinson Date: Fri, 20 Mar 2026 09:50:00 +0000 Subject: [PATCH 023/162] ci(gitea): unify push and merged-pr validation --- .gitea/workflows/pr-merge-validation.yml | 27 ------------------- ...ush-unit-tests.yml => push-validation.yml} | 8 ++++-- README.md | 2 +- 3 files changed, 7 insertions(+), 30 deletions(-) delete mode 100644 .gitea/workflows/pr-merge-validation.yml rename .gitea/workflows/{push-unit-tests.yml => push-validation.yml} (65%) diff --git a/.gitea/workflows/pr-merge-validation.yml b/.gitea/workflows/pr-merge-validation.yml deleted file mode 100644 index 38b13f9..0000000 --- a/.gitea/workflows/pr-merge-validation.yml +++ /dev/null @@ -1,27 +0,0 @@ -name: Pull Request Merge Validation - -on: - pull_request_target: - types: - - closed - -jobs: - validate-merged-pr: - if: ${{ github.event.pull_request.merged == true }} - runs-on: ubuntu-latest - steps: - - name: Checkout merged commit - uses: actions/checkout@v4 - with: - ref: ${{ github.event.pull_request.merge_commit_sha }} - - - name: Setup Go - uses: actions/setup-go@v5 - with: - go-version-file: go.mod - - - name: Run full unit test suite - run: go test ./... - - - name: Run behavior suite - run: ./script/run-behavior-suite-docker.sh diff --git a/.gitea/workflows/push-unit-tests.yml b/.gitea/workflows/push-validation.yml similarity index 65% rename from .gitea/workflows/push-unit-tests.yml rename to .gitea/workflows/push-validation.yml index 8a84b65..e8e4996 100644 --- a/.gitea/workflows/push-unit-tests.yml +++ b/.gitea/workflows/push-validation.yml @@ -1,4 +1,4 @@ -name: Push Unit Tests +name: Push Validation on: push: @@ -8,7 +8,7 @@ on: - "*" jobs: - unit-tests: + validate: runs-on: ubuntu-latest steps: - name: Checkout @@ -21,3 +21,7 @@ jobs: - name: Run full unit test suite run: go test ./... + + - name: Run behavior suite on main pushes + if: ${{ github.ref == 'refs/heads/main' }} + run: ./script/run-behavior-suite-docker.sh diff --git a/README.md b/README.md index 6914f2d..a0d09d1 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # homesick -[![Main Unit Tests](https://git.hrafn.xyz/aether/gosick/actions/workflows/push-unit-tests.yml?branch=main&event=push)](https://git.hrafn.xyz/aether/gosick/actions/workflows/push-unit-tests.yml) +[![Main Validation](https://git.hrafn.xyz/aether/gosick/actions/workflows/push-validation.yml?branch=main&event=push)](https://git.hrafn.xyz/aether/gosick/actions/workflows/push-validation.yml) [![PR Validation](https://git.hrafn.xyz/aether/gosick/actions/workflows/pr-validation.yml?branch=main&event=pull_request)](https://git.hrafn.xyz/aether/gosick/actions/workflows/pr-validation.yml) [![Tag Build Artifacts](https://git.hrafn.xyz/aether/gosick/actions/workflows/tag-build-artifacts.yml?event=push)](https://git.hrafn.xyz/aether/gosick/actions/workflows/tag-build-artifacts.yml) -- 2.49.1 From e09bdd78c2c5c05e5d38f77e6a03f1c899461062 Mon Sep 17 00:00:00 2001 From: Micheal Wilkinson Date: Fri, 20 Mar 2026 09:50:12 +0000 Subject: [PATCH 024/162] docs(changelog): note unified push validation workflow --- changelog.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/changelog.md b/changelog.md index 800c710..22d10e3 100644 --- a/changelog.md +++ b/changelog.md @@ -19,6 +19,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - CLI argument parsing migrated to Kong. - Git operations for clone and track migrated to `go-git`. - Build and behavior workflows now produce and run the `gosick` binary name. +- CI validation is unified into push events, running behavior tests only on `main` pushes. +- Gitea workflow and README badge updated from `push-unit-tests` to `push-validation`. - Release notes standardized to Keep a Changelog format. ### Fixed -- 2.49.1 From d638f201fe34f9c07b9b79bea7505b1725c54dbb Mon Sep 17 00:00:00 2001 From: Micheal Wilkinson Date: Fri, 20 Mar 2026 09:54:42 +0000 Subject: [PATCH 025/162] fix(cli): improve help name and description --- internal/homesick/cli/cli.go | 15 +++++++++++++-- internal/homesick/cli/cli_test.go | 16 +++++++++++++++- 2 files changed, 28 insertions(+), 3 deletions(-) diff --git a/internal/homesick/cli/cli.go b/internal/homesick/cli/cli.go index 9afef8e..0cd516f 100644 --- a/internal/homesick/cli/cli.go +++ b/internal/homesick/cli/cli.go @@ -5,6 +5,7 @@ import ( "fmt" "io" "os" + "path/filepath" "strings" "git.hrafn.xyz/aether/gosick/internal/homesick/core" @@ -21,8 +22,8 @@ func Run(args []string, stdout io.Writer, stderr io.Writer) int { parser, err := kong.New( &cliModel{}, - kong.Name("homesick"), - kong.Description("Go scaffold"), + kong.Name(programName()), + kong.Description("Your home is your castle. Don't leave your precious dotfiles behind."), kong.Writers(stdout, stderr), kong.Exit(func(int) {}), kong.ConfigureHelp(kong.HelpOptions{Compact: true}), @@ -192,6 +193,16 @@ func defaultCastle(castle string) string { return castle } +func programName() string { + if len(os.Args) > 0 { + if name := strings.TrimSpace(filepath.Base(os.Args[0])); name != "" { + return name + } + } + + return "gosick" +} + func normalizeArgs(args []string) []string { if len(args) == 0 { return []string{"--help"} diff --git a/internal/homesick/cli/cli_test.go b/internal/homesick/cli/cli_test.go index cd97a4e..3fa5d51 100644 --- a/internal/homesick/cli/cli_test.go +++ b/internal/homesick/cli/cli_test.go @@ -59,4 +59,18 @@ func (s *CLISuite) TestRun_CloneSubcommandHelp() { require.Contains(s.T(), s.stdout.String(), "clone") require.Contains(s.T(), s.stdout.String(), "URI") require.Empty(s.T(), s.stderr.String()) -} \ No newline at end of file +} + +func (s *CLISuite) TestRun_Help_UsesProgramNameAndDescription() { + originalArgs := os.Args + s.T().Cleanup(func() { os.Args = originalArgs }) + os.Args = []string{"gosick"} + + exitCode := cli.Run([]string{"--help"}, s.stdout, s.stderr) + + require.Equal(s.T(), 0, exitCode) + require.Contains(s.T(), s.stdout.String(), "Usage: gosick") + require.NotContains(s.T(), s.stdout.String(), "Usage: homesick") + require.Contains(s.T(), s.stdout.String(), "precious dotfiles") + require.Empty(s.T(), s.stderr.String()) +} -- 2.49.1 From 96ce57279263256df57b1eee9453c21028d6448a Mon Sep 17 00:00:00 2001 From: Micheal Wilkinson Date: Fri, 20 Mar 2026 09:56:16 +0000 Subject: [PATCH 026/162] docs(changelog): note CLI help messaging improvements --- changelog.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/changelog.md b/changelog.md index 22d10e3..d6c0f41 100644 --- a/changelog.md +++ b/changelog.md @@ -21,6 +21,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Build and behavior workflows now produce and run the `gosick` binary name. - CI validation is unified into push events, running behavior tests only on `main` pushes. - Gitea workflow and README badge updated from `push-unit-tests` to `push-validation`. +- CLI help now uses the invoked binary name (defaulting to `gosick`) in usage output. +- CLI help description now reflects Homesick's purpose for managing precious dotfiles. - Release notes standardized to Keep a Changelog format. ### Fixed -- 2.49.1 From ea16ba84307b64373fcea2bc52c6dcadf8708ce9 Mon Sep 17 00:00:00 2001 From: Micheal Wilkinson Date: Fri, 20 Mar 2026 09:57:40 +0000 Subject: [PATCH 027/162] chore(go): Removing unused function --- internal/homesick/core/core.go | 4 ---- 1 file changed, 4 deletions(-) diff --git a/internal/homesick/core/core.go b/internal/homesick/core/core.go index d2d7f23..8c31e1d 100644 --- a/internal/homesick/core/core.go +++ b/internal/homesick/core/core.go @@ -510,10 +510,6 @@ func matchesIgnoredDir(castleHome string, candidate string, subdirs []string) (b return ok, nil } -func runGit(dir string, args ...string) error { - return runGitWithIO(dir, os.Stdout, os.Stderr, args...) -} - func runGitWithIO(dir string, stdout io.Writer, stderr io.Writer, args ...string) error { cmd := exec.Command("git", args...) cmd.Dir = dir -- 2.49.1 From f6b5186f31cb5e27b6df971c61fe01ba2fad0b9c Mon Sep 17 00:00:00 2001 From: Micheal Wilkinson Date: Fri, 20 Mar 2026 11:46:05 +0000 Subject: [PATCH 028/162] ci(gitea): publish coverage reports to artefact storage --- .gitea/workflows/pr-validation.yml | 138 ++++++++++++++++++++++++++- .gitea/workflows/push-validation.yml | 96 ++++++++++++++++++- 2 files changed, 230 insertions(+), 4 deletions(-) diff --git a/.gitea/workflows/pr-validation.yml b/.gitea/workflows/pr-validation.yml index 106a130..b0b8bc1 100644 --- a/.gitea/workflows/pr-validation.yml +++ b/.gitea/workflows/pr-validation.yml @@ -10,6 +10,14 @@ on: jobs: validate: runs-on: ubuntu-latest + env: + ARTEFACT_BUCKET_NAME: ${{ vars.ARTEFACT_BUCKET_NAME }} + ARTEFACT_BUCKET_ENDPONT: ${{ vars.ARTEFACT_BUCKET_ENDPONT }} + ARTEFACT_BUCKET_REGION: ${{ vars.ARTEFACT_BUCKET_REGION }} + AWS_ACCESS_KEY_ID: ${{ secrets.ARTEFACT_BUCKET_WRITE_ACCESS_KEY }} + AWS_SECRET_ACCESS_KEY: ${{ secrets.ARTEFACT_BUCKET_WRITE_ACCESS_SECRET }} + AWS_DEFAULT_REGION: ${{ vars.ARTEFACT_BUCKET_REGION }} + AWS_EC2_METADATA_DISABLED: true steps: - name: Checkout uses: actions/checkout@v4 @@ -19,8 +27,134 @@ jobs: with: go-version-file: go.mod - - name: Run full unit test suite - run: go test ./... + - name: Ensure tooling is available + run: | + set -euo pipefail + + if ! command -v aws >/dev/null 2>&1; then + sudo apt-get update + sudo apt-get install -y awscli jq + exit 0 + fi + + if ! command -v jq >/dev/null 2>&1; then + sudo apt-get update + sudo apt-get install -y jq + fi + + - name: Run full unit test suite with coverage + id: coverage + run: | + set -euo pipefail + + go test -covermode=atomic -coverprofile=coverage.out ./... + go tool cover -html=coverage.out -o coverage.html + + total="$(go tool cover -func=coverage.out | awk '/^total:/ {sub(/%/, "", $3); print $3}')" + printf '{\n "total": "%s"\n}\n' "$total" > coverage-summary.json + printf 'total=%s\n' "$total" >> "$GITHUB_OUTPUT" + + - name: Generate coverage badge + env: + COVERAGE_TOTAL: ${{ steps.coverage.outputs.total }} + run: | + set -euo pipefail + + color="$(awk -v total="$COVERAGE_TOTAL" 'BEGIN { + if (total >= 80) print "brightgreen"; + else if (total >= 70) print "green"; + else if (total >= 60) print "yellowgreen"; + else if (total >= 50) print "yellow"; + else print "red"; + }')" + + cat > coverage-badge.svg < + + + + + + + + + + + + + + coverage + coverage + ${COVERAGE_TOTAL}% + ${COVERAGE_TOTAL}% + + + EOF + + - name: Upload PR coverage artefacts + id: upload + run: | + set -euo pipefail + + aws configure set default.s3.addressing_style path + + repo_name="${GITHUB_REPOSITORY##*/}" + prefix="${repo_name}/pull-requests/${{ github.event.pull_request.number }}" + report_url="${ARTEFACT_BUCKET_ENDPONT%/}/${ARTEFACT_BUCKET_NAME}/${prefix}/coverage.html" + badge_url="${ARTEFACT_BUCKET_ENDPONT%/}/${ARTEFACT_BUCKET_NAME}/${prefix}/coverage-badge.svg" + + aws --endpoint-url "${ARTEFACT_BUCKET_ENDPONT}" s3 cp coverage.html "s3://${ARTEFACT_BUCKET_NAME}/${prefix}/coverage.html" --content-type text/html + aws --endpoint-url "${ARTEFACT_BUCKET_ENDPONT}" s3 cp coverage-badge.svg "s3://${ARTEFACT_BUCKET_NAME}/${prefix}/coverage-badge.svg" --content-type image/svg+xml + aws --endpoint-url "${ARTEFACT_BUCKET_ENDPONT}" s3 cp coverage-summary.json "s3://${ARTEFACT_BUCKET_NAME}/${prefix}/coverage-summary.json" --content-type application/json + + printf 'report_url=%s\n' "$report_url" >> "$GITHUB_OUTPUT" + printf 'badge_url=%s\n' "$badge_url" >> "$GITHUB_OUTPUT" + + - name: Comment coverage report on pull request + env: + COVERAGE_BADGE_URL: ${{ steps.upload.outputs.badge_url }} + COVERAGE_REPORT_URL: ${{ steps.upload.outputs.report_url }} + COVERAGE_TOTAL: ${{ steps.coverage.outputs.total }} + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + set -euo pipefail + + marker='' + api_base="${GITHUB_SERVER_URL}/api/v1/repos/${GITHUB_REPOSITORY}" + + payload="$(jq -n \ + --arg marker "$marker" \ + --arg total "$COVERAGE_TOTAL" \ + --arg report "$COVERAGE_REPORT_URL" \ + --arg badge "$COVERAGE_BADGE_URL" \ + '{body: ($marker + "\n## Coverage Report\n\nCoverage total: **" + $total + "%**\n\n[HTML report](" + $report + ")\n\n![Coverage badge](" + $badge + ")")}')" + + comments="$(curl -sS -H "Authorization: token ${GITHUB_TOKEN}" "${api_base}/issues/${{ github.event.pull_request.number }}/comments")" + comment_id="$(printf '%s' "$comments" | jq -r '.[] | select(.body | contains("")) | .id' | tail -n 1)" + + if [[ -n "$comment_id" ]]; then + curl -sS -X PATCH \ + -H "Authorization: token ${GITHUB_TOKEN}" \ + -H 'Content-Type: application/json' \ + -d "$payload" \ + "${api_base}/issues/comments/${comment_id}" >/dev/null + else + curl -sS -X POST \ + -H "Authorization: token ${GITHUB_TOKEN}" \ + -H 'Content-Type: application/json' \ + -d "$payload" \ + "${api_base}/issues/${{ github.event.pull_request.number }}/comments" >/dev/null + fi + + - name: Add coverage summary + run: | + { + echo '## Coverage' + echo + echo '- Total: `${{ steps.coverage.outputs.total }}%`' + echo '- Report: ${{ steps.upload.outputs.report_url }}' + echo '- Badge: ${{ steps.upload.outputs.badge_url }}' + } >> "$GITHUB_STEP_SUMMARY" - name: Run behavior suite run: ./script/run-behavior-suite-docker.sh diff --git a/.gitea/workflows/push-validation.yml b/.gitea/workflows/push-validation.yml index e8e4996..16df285 100644 --- a/.gitea/workflows/push-validation.yml +++ b/.gitea/workflows/push-validation.yml @@ -10,6 +10,14 @@ on: jobs: validate: runs-on: ubuntu-latest + env: + ARTEFACT_BUCKET_NAME: ${{ vars.ARTEFACT_BUCKET_NAME }} + ARTEFACT_BUCKET_ENDPONT: ${{ vars.ARTEFACT_BUCKET_ENDPONT }} + ARTEFACT_BUCKET_REGION: ${{ vars.ARTEFACT_BUCKET_REGION }} + AWS_ACCESS_KEY_ID: ${{ secrets.ARTEFACT_BUCKET_WRITE_ACCESS_KEY }} + AWS_SECRET_ACCESS_KEY: ${{ secrets.ARTEFACT_BUCKET_WRITE_ACCESS_SECRET }} + AWS_DEFAULT_REGION: ${{ vars.ARTEFACT_BUCKET_REGION }} + AWS_EC2_METADATA_DISABLED: true steps: - name: Checkout uses: actions/checkout@v4 @@ -19,8 +27,92 @@ jobs: with: go-version-file: go.mod - - name: Run full unit test suite - run: go test ./... + - name: Ensure tooling is available + run: | + set -euo pipefail + + if ! command -v aws >/dev/null 2>&1; then + sudo apt-get update + sudo apt-get install -y awscli + fi + + - name: Run full unit test suite with coverage + id: coverage + run: | + set -euo pipefail + + go test -covermode=atomic -coverprofile=coverage.out ./... + go tool cover -html=coverage.out -o coverage.html + + total="$(go tool cover -func=coverage.out | awk '/^total:/ {sub(/%/, "", $3); print $3}')" + printf '{\n "total": "%s"\n}\n' "$total" > coverage-summary.json + printf 'total=%s\n' "$total" >> "$GITHUB_OUTPUT" + + - name: Generate coverage badge + env: + COVERAGE_TOTAL: ${{ steps.coverage.outputs.total }} + run: | + set -euo pipefail + + color="$(awk -v total="$COVERAGE_TOTAL" 'BEGIN { + if (total >= 80) print "brightgreen"; + else if (total >= 70) print "green"; + else if (total >= 60) print "yellowgreen"; + else if (total >= 50) print "yellow"; + else print "red"; + }')" + + cat > coverage-badge.svg < + + + + + + + + + + + + + + coverage + coverage + ${COVERAGE_TOTAL}% + ${COVERAGE_TOTAL}% + + + EOF + + - name: Upload branch coverage artefacts + id: upload + run: | + set -euo pipefail + + aws configure set default.s3.addressing_style path + + repo_name="${GITHUB_REPOSITORY##*/}" + prefix="${repo_name}/branch/${GITHUB_REF_NAME}" + report_url="${ARTEFACT_BUCKET_ENDPONT%/}/${ARTEFACT_BUCKET_NAME}/${prefix}/coverage.html" + badge_url="${ARTEFACT_BUCKET_ENDPONT%/}/${ARTEFACT_BUCKET_NAME}/${prefix}/coverage-badge.svg" + + aws --endpoint-url "${ARTEFACT_BUCKET_ENDPONT}" s3 cp coverage.html "s3://${ARTEFACT_BUCKET_NAME}/${prefix}/coverage.html" --content-type text/html + aws --endpoint-url "${ARTEFACT_BUCKET_ENDPONT}" s3 cp coverage-badge.svg "s3://${ARTEFACT_BUCKET_NAME}/${prefix}/coverage-badge.svg" --content-type image/svg+xml + aws --endpoint-url "${ARTEFACT_BUCKET_ENDPONT}" s3 cp coverage-summary.json "s3://${ARTEFACT_BUCKET_NAME}/${prefix}/coverage-summary.json" --content-type application/json + + printf 'report_url=%s\n' "$report_url" >> "$GITHUB_OUTPUT" + printf 'badge_url=%s\n' "$badge_url" >> "$GITHUB_OUTPUT" + + - name: Add coverage summary + run: | + { + echo '## Coverage' + echo + echo '- Total: `${{ steps.coverage.outputs.total }}%`' + echo '- Report: ${{ steps.upload.outputs.report_url }}' + echo '- Badge: ${{ steps.upload.outputs.badge_url }}' + } >> "$GITHUB_STEP_SUMMARY" - name: Run behavior suite on main pushes if: ${{ github.ref == 'refs/heads/main' }} -- 2.49.1 From 195b936de6b62d6e789c39949248f5c2574c02f3 Mon Sep 17 00:00:00 2001 From: Micheal Wilkinson Date: Fri, 20 Mar 2026 11:46:12 +0000 Subject: [PATCH 029/162] docs(changelog): note coverage artefact publishing --- changelog.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/changelog.md b/changelog.md index d6c0f41..7ac0cb5 100644 --- a/changelog.md +++ b/changelog.md @@ -13,6 +13,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Containerized behavior test suite for command parity validation. - Dedicated test suites for `list`, `show_path`, `status`, `diff`, and `version`. - Just workflow support for building and running the Linux behavior binary. +- Coverage reports and badges published to shared object storage for branches and pull requests. +- Pull requests now receive coverage report links in CI comments. ### Changed -- 2.49.1 From 9d6dacb0f830a80c324b037f26e90aa04ccc3103 Mon Sep 17 00:00:00 2001 From: Micheal Wilkinson Date: Fri, 20 Mar 2026 12:07:10 +0000 Subject: [PATCH 030/162] ci: cache go modules and build outputs in workflows --- .gitea/workflows/pr-validation.yml | 20 ++++++++++++++++++++ .gitea/workflows/push-validation.yml | 20 ++++++++++++++++++++ .gitea/workflows/tag-build-artifacts.yml | 21 +++++++++++++++++++++ 3 files changed, 61 insertions(+) diff --git a/.gitea/workflows/pr-validation.yml b/.gitea/workflows/pr-validation.yml index b0b8bc1..65edb40 100644 --- a/.gitea/workflows/pr-validation.yml +++ b/.gitea/workflows/pr-validation.yml @@ -11,6 +11,7 @@ jobs: validate: runs-on: ubuntu-latest env: + RUNNER_TOOL_CACHE: /cache/tools ARTEFACT_BUCKET_NAME: ${{ vars.ARTEFACT_BUCKET_NAME }} ARTEFACT_BUCKET_ENDPONT: ${{ vars.ARTEFACT_BUCKET_ENDPONT }} ARTEFACT_BUCKET_REGION: ${{ vars.ARTEFACT_BUCKET_REGION }} @@ -27,6 +28,25 @@ jobs: with: go-version-file: go.mod + - name: Get go-hashfiles + uses: https://gitea.com/actions/go-hashfiles@v0.0.1 + id: hash-go + with: + patterns: |- + go.mod + go.sum + + - name: Cache go + id: cache-go + uses: https://github.com/actions/cache@v3 + with: + path: |- + /root/go/pkg/mod + /root/.cache/go-build + key: go_cache-${{ steps.hash-go.outputs.hash }} + restore-keys: |- + go_cache-${{ steps.hash-go.outputs.hash }} + - name: Ensure tooling is available run: | set -euo pipefail diff --git a/.gitea/workflows/push-validation.yml b/.gitea/workflows/push-validation.yml index 16df285..060c037 100644 --- a/.gitea/workflows/push-validation.yml +++ b/.gitea/workflows/push-validation.yml @@ -11,6 +11,7 @@ jobs: validate: runs-on: ubuntu-latest env: + RUNNER_TOOL_CACHE: /cache/tools ARTEFACT_BUCKET_NAME: ${{ vars.ARTEFACT_BUCKET_NAME }} ARTEFACT_BUCKET_ENDPONT: ${{ vars.ARTEFACT_BUCKET_ENDPONT }} ARTEFACT_BUCKET_REGION: ${{ vars.ARTEFACT_BUCKET_REGION }} @@ -27,6 +28,25 @@ jobs: with: go-version-file: go.mod + - name: Get go-hashfiles + uses: https://gitea.com/actions/go-hashfiles@v0.0.1 + id: hash-go + with: + patterns: |- + go.mod + go.sum + + - name: Cache go + id: cache-go + uses: https://github.com/actions/cache@v3 + with: + path: |- + /root/go/pkg/mod + /root/.cache/go-build + key: go_cache-${{ steps.hash-go.outputs.hash }} + restore-keys: |- + go_cache-${{ steps.hash-go.outputs.hash }} + - name: Ensure tooling is available run: | set -euo pipefail diff --git a/.gitea/workflows/tag-build-artifacts.yml b/.gitea/workflows/tag-build-artifacts.yml index d1d633a..b39007b 100644 --- a/.gitea/workflows/tag-build-artifacts.yml +++ b/.gitea/workflows/tag-build-artifacts.yml @@ -8,6 +8,8 @@ on: jobs: build: runs-on: ubuntu-latest + env: + RUNNER_TOOL_CACHE: /cache/tools strategy: fail-fast: false matrix: @@ -30,6 +32,25 @@ jobs: with: go-version-file: go.mod + - name: Get go-hashfiles + uses: https://gitea.com/actions/go-hashfiles@v0.0.1 + id: hash-go + with: + patterns: |- + go.mod + go.sum + + - name: Cache go + id: cache-go + uses: https://github.com/actions/cache@v3 + with: + path: |- + /root/go/pkg/mod + /root/.cache/go-build + key: go_cache-${{ steps.hash-go.outputs.hash }} + restore-keys: |- + go_cache-${{ steps.hash-go.outputs.hash }} + - name: Build binary run: | mkdir -p dist -- 2.49.1 From b3f66e9e2e75abc40d4dd4c56678f35dc976b799 Mon Sep 17 00:00:00 2001 From: Micheal Wilkinson Date: Fri, 20 Mar 2026 12:07:18 +0000 Subject: [PATCH 031/162] docs(changelog): note go cache in gitea pipelines --- changelog.md | 1 + 1 file changed, 1 insertion(+) diff --git a/changelog.md b/changelog.md index 7ac0cb5..0a979eb 100644 --- a/changelog.md +++ b/changelog.md @@ -22,6 +22,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Git operations for clone and track migrated to `go-git`. - Build and behavior workflows now produce and run the `gosick` binary name. - CI validation is unified into push events, running behavior tests only on `main` pushes. +- Gitea CI workflows now cache Go modules and build artifacts using a shared runner tool cache. - Gitea workflow and README badge updated from `push-unit-tests` to `push-validation`. - CLI help now uses the invoked binary name (defaulting to `gosick`) in usage output. - CLI help description now reflects Homesick's purpose for managing precious dotfiles. -- 2.49.1 From 4a8ef7e1f63e3abbe6d15fa9248fe4ab627a096c Mon Sep 17 00:00:00 2001 From: Micheal Wilkinson Date: Fri, 20 Mar 2026 12:53:09 +0000 Subject: [PATCH 032/162] ci(gitea): use pip for awscli installation --- .gitea/workflows/pr-validation.yml | 4 +--- .gitea/workflows/push-validation.yml | 3 +-- 2 files changed, 2 insertions(+), 5 deletions(-) diff --git a/.gitea/workflows/pr-validation.yml b/.gitea/workflows/pr-validation.yml index 65edb40..3a8ce87 100644 --- a/.gitea/workflows/pr-validation.yml +++ b/.gitea/workflows/pr-validation.yml @@ -52,9 +52,7 @@ jobs: set -euo pipefail if ! command -v aws >/dev/null 2>&1; then - sudo apt-get update - sudo apt-get install -y awscli jq - exit 0 + pip install awscli fi if ! command -v jq >/dev/null 2>&1; then diff --git a/.gitea/workflows/push-validation.yml b/.gitea/workflows/push-validation.yml index 060c037..d1f1cc0 100644 --- a/.gitea/workflows/push-validation.yml +++ b/.gitea/workflows/push-validation.yml @@ -52,8 +52,7 @@ jobs: set -euo pipefail if ! command -v aws >/dev/null 2>&1; then - sudo apt-get update - sudo apt-get install -y awscli + pip install awscli fi - name: Run full unit test suite with coverage -- 2.49.1 From 484db0781bf75d521357dde7eaefba2fc0567a59 Mon Sep 17 00:00:00 2001 From: Micheal Wilkinson Date: Fri, 20 Mar 2026 13:04:30 +0000 Subject: [PATCH 033/162] ci(gitea): use pipx for awscli installation --- .gitea/workflows/pr-validation.yml | 2 +- .gitea/workflows/push-validation.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.gitea/workflows/pr-validation.yml b/.gitea/workflows/pr-validation.yml index 3a8ce87..b774396 100644 --- a/.gitea/workflows/pr-validation.yml +++ b/.gitea/workflows/pr-validation.yml @@ -52,7 +52,7 @@ jobs: set -euo pipefail if ! command -v aws >/dev/null 2>&1; then - pip install awscli + pipx install awscli fi if ! command -v jq >/dev/null 2>&1; then diff --git a/.gitea/workflows/push-validation.yml b/.gitea/workflows/push-validation.yml index d1f1cc0..32b4726 100644 --- a/.gitea/workflows/push-validation.yml +++ b/.gitea/workflows/push-validation.yml @@ -52,7 +52,7 @@ jobs: set -euo pipefail if ! command -v aws >/dev/null 2>&1; then - pip install awscli + pipx install awscli fi - name: Run full unit test suite with coverage -- 2.49.1 From a6034ce47098641162db9ceb471bff2c348e1c2b Mon Sep 17 00:00:00 2001 From: Micheal Wilkinson Date: Fri, 20 Mar 2026 13:06:46 +0000 Subject: [PATCH 034/162] chore(bash): remove redundant bash script --- bin/homesick | 11 ----------- 1 file changed, 11 deletions(-) delete mode 100755 bin/homesick diff --git a/bin/homesick b/bin/homesick deleted file mode 100755 index c3909a7..0000000 --- a/bin/homesick +++ /dev/null @@ -1,11 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -repo_root="$(cd "$script_dir/.." && pwd)" - -if [[ -x "$repo_root/dist/gosick" ]]; then - exec "$repo_root/dist/gosick" "$@" -fi - -exec go run "$repo_root/cmd/homesick" "$@" -- 2.49.1 From d084abd636945b5ba3e8bc014d99b8eee5843bf4 Mon Sep 17 00:00:00 2001 From: Micheal Wilkinson Date: Fri, 20 Mar 2026 13:13:53 +0000 Subject: [PATCH 035/162] chore(ci): remove Go module caching to eliminate artifact cache timeouts --- .gitea/workflows/pr-validation.yml | 21 +-------------------- .gitea/workflows/push-validation.yml | 21 +-------------------- .gitea/workflows/tag-build-artifacts.yml | 20 +------------------- 3 files changed, 3 insertions(+), 59 deletions(-) diff --git a/.gitea/workflows/pr-validation.yml b/.gitea/workflows/pr-validation.yml index b774396..1ad7658 100644 --- a/.gitea/workflows/pr-validation.yml +++ b/.gitea/workflows/pr-validation.yml @@ -11,7 +11,6 @@ jobs: validate: runs-on: ubuntu-latest env: - RUNNER_TOOL_CACHE: /cache/tools ARTEFACT_BUCKET_NAME: ${{ vars.ARTEFACT_BUCKET_NAME }} ARTEFACT_BUCKET_ENDPONT: ${{ vars.ARTEFACT_BUCKET_ENDPONT }} ARTEFACT_BUCKET_REGION: ${{ vars.ARTEFACT_BUCKET_REGION }} @@ -27,25 +26,7 @@ jobs: uses: actions/setup-go@v5 with: go-version-file: go.mod - - - name: Get go-hashfiles - uses: https://gitea.com/actions/go-hashfiles@v0.0.1 - id: hash-go - with: - patterns: |- - go.mod - go.sum - - - name: Cache go - id: cache-go - uses: https://github.com/actions/cache@v3 - with: - path: |- - /root/go/pkg/mod - /root/.cache/go-build - key: go_cache-${{ steps.hash-go.outputs.hash }} - restore-keys: |- - go_cache-${{ steps.hash-go.outputs.hash }} + cache: false - name: Ensure tooling is available run: | diff --git a/.gitea/workflows/push-validation.yml b/.gitea/workflows/push-validation.yml index 32b4726..3080004 100644 --- a/.gitea/workflows/push-validation.yml +++ b/.gitea/workflows/push-validation.yml @@ -11,7 +11,6 @@ jobs: validate: runs-on: ubuntu-latest env: - RUNNER_TOOL_CACHE: /cache/tools ARTEFACT_BUCKET_NAME: ${{ vars.ARTEFACT_BUCKET_NAME }} ARTEFACT_BUCKET_ENDPONT: ${{ vars.ARTEFACT_BUCKET_ENDPONT }} ARTEFACT_BUCKET_REGION: ${{ vars.ARTEFACT_BUCKET_REGION }} @@ -27,25 +26,7 @@ jobs: uses: actions/setup-go@v5 with: go-version-file: go.mod - - - name: Get go-hashfiles - uses: https://gitea.com/actions/go-hashfiles@v0.0.1 - id: hash-go - with: - patterns: |- - go.mod - go.sum - - - name: Cache go - id: cache-go - uses: https://github.com/actions/cache@v3 - with: - path: |- - /root/go/pkg/mod - /root/.cache/go-build - key: go_cache-${{ steps.hash-go.outputs.hash }} - restore-keys: |- - go_cache-${{ steps.hash-go.outputs.hash }} + cache: false - name: Ensure tooling is available run: | diff --git a/.gitea/workflows/tag-build-artifacts.yml b/.gitea/workflows/tag-build-artifacts.yml index b39007b..8ad21b3 100644 --- a/.gitea/workflows/tag-build-artifacts.yml +++ b/.gitea/workflows/tag-build-artifacts.yml @@ -31,25 +31,7 @@ jobs: uses: actions/setup-go@v5 with: go-version-file: go.mod - - - name: Get go-hashfiles - uses: https://gitea.com/actions/go-hashfiles@v0.0.1 - id: hash-go - with: - patterns: |- - go.mod - go.sum - - - name: Cache go - id: cache-go - uses: https://github.com/actions/cache@v3 - with: - path: |- - /root/go/pkg/mod - /root/.cache/go-build - key: go_cache-${{ steps.hash-go.outputs.hash }} - restore-keys: |- - go_cache-${{ steps.hash-go.outputs.hash }} + cache: false - name: Build binary run: | -- 2.49.1 From 665401f2bdbe0e5c95765425e0c22ed2d01399e3 Mon Sep 17 00:00:00 2001 From: Micheal Wilkinson Date: Fri, 20 Mar 2026 13:16:11 +0000 Subject: [PATCH 036/162] chore(ci): use catthehacker/ubuntu container for better tool availability --- .gitea/workflows/pr-validation.yml | 1 + .gitea/workflows/push-validation.yml | 1 + .gitea/workflows/tag-build-artifacts.yml | 1 + 3 files changed, 3 insertions(+) diff --git a/.gitea/workflows/pr-validation.yml b/.gitea/workflows/pr-validation.yml index 1ad7658..444f245 100644 --- a/.gitea/workflows/pr-validation.yml +++ b/.gitea/workflows/pr-validation.yml @@ -10,6 +10,7 @@ on: jobs: validate: runs-on: ubuntu-latest + container: docker.io/catthehacker/ubuntu:act-latest env: ARTEFACT_BUCKET_NAME: ${{ vars.ARTEFACT_BUCKET_NAME }} ARTEFACT_BUCKET_ENDPONT: ${{ vars.ARTEFACT_BUCKET_ENDPONT }} diff --git a/.gitea/workflows/push-validation.yml b/.gitea/workflows/push-validation.yml index 3080004..c103ad1 100644 --- a/.gitea/workflows/push-validation.yml +++ b/.gitea/workflows/push-validation.yml @@ -10,6 +10,7 @@ on: jobs: validate: runs-on: ubuntu-latest + container: docker.io/catthehacker/ubuntu:act-latest env: ARTEFACT_BUCKET_NAME: ${{ vars.ARTEFACT_BUCKET_NAME }} ARTEFACT_BUCKET_ENDPONT: ${{ vars.ARTEFACT_BUCKET_ENDPONT }} diff --git a/.gitea/workflows/tag-build-artifacts.yml b/.gitea/workflows/tag-build-artifacts.yml index 8ad21b3..4a2d640 100644 --- a/.gitea/workflows/tag-build-artifacts.yml +++ b/.gitea/workflows/tag-build-artifacts.yml @@ -8,6 +8,7 @@ on: jobs: build: runs-on: ubuntu-latest + container: docker.io/catthehacker/ubuntu:act-latest env: RUNNER_TOOL_CACHE: /cache/tools strategy: -- 2.49.1 From c6c382afcedfeabf4d562be7e0fe94f724a6afdd Mon Sep 17 00:00:00 2001 From: Micheal Wilkinson Date: Fri, 20 Mar 2026 13:20:24 +0000 Subject: [PATCH 037/162] chore(ci): add bash as default shell for workflows --- .gitea/workflows/pr-validation.yml | 3 +++ .gitea/workflows/push-validation.yml | 3 +++ .gitea/workflows/tag-build-artifacts.yml | 3 +++ 3 files changed, 9 insertions(+) diff --git a/.gitea/workflows/pr-validation.yml b/.gitea/workflows/pr-validation.yml index 444f245..e1ca4b7 100644 --- a/.gitea/workflows/pr-validation.yml +++ b/.gitea/workflows/pr-validation.yml @@ -11,6 +11,9 @@ jobs: validate: runs-on: ubuntu-latest container: docker.io/catthehacker/ubuntu:act-latest + defaults: + run: + shell: bash env: ARTEFACT_BUCKET_NAME: ${{ vars.ARTEFACT_BUCKET_NAME }} ARTEFACT_BUCKET_ENDPONT: ${{ vars.ARTEFACT_BUCKET_ENDPONT }} diff --git a/.gitea/workflows/push-validation.yml b/.gitea/workflows/push-validation.yml index c103ad1..ed96c5f 100644 --- a/.gitea/workflows/push-validation.yml +++ b/.gitea/workflows/push-validation.yml @@ -11,6 +11,9 @@ jobs: validate: runs-on: ubuntu-latest container: docker.io/catthehacker/ubuntu:act-latest + defaults: + run: + shell: bash env: ARTEFACT_BUCKET_NAME: ${{ vars.ARTEFACT_BUCKET_NAME }} ARTEFACT_BUCKET_ENDPONT: ${{ vars.ARTEFACT_BUCKET_ENDPONT }} diff --git a/.gitea/workflows/tag-build-artifacts.yml b/.gitea/workflows/tag-build-artifacts.yml index 4a2d640..cfca3a5 100644 --- a/.gitea/workflows/tag-build-artifacts.yml +++ b/.gitea/workflows/tag-build-artifacts.yml @@ -9,6 +9,9 @@ jobs: build: runs-on: ubuntu-latest container: docker.io/catthehacker/ubuntu:act-latest + defaults: + run: + shell: bash env: RUNNER_TOOL_CACHE: /cache/tools strategy: -- 2.49.1 From 3d71433630ae2b8d46d34bbec22cd11d7c1a8774 Mon Sep 17 00:00:00 2001 From: Micheal Wilkinson Date: Fri, 20 Mar 2026 13:30:19 +0000 Subject: [PATCH 038/162] chore(ci): pin Go toolchain to 1.26.1 in workflows --- .gitea/workflows/pr-validation.yml | 3 ++- .gitea/workflows/push-validation.yml | 3 ++- .gitea/workflows/tag-build-artifacts.yml | 3 ++- 3 files changed, 6 insertions(+), 3 deletions(-) diff --git a/.gitea/workflows/pr-validation.yml b/.gitea/workflows/pr-validation.yml index e1ca4b7..667c990 100644 --- a/.gitea/workflows/pr-validation.yml +++ b/.gitea/workflows/pr-validation.yml @@ -29,7 +29,8 @@ jobs: - name: Setup Go uses: actions/setup-go@v5 with: - go-version-file: go.mod + go-version: '1.26.1' + check-latest: true cache: false - name: Ensure tooling is available diff --git a/.gitea/workflows/push-validation.yml b/.gitea/workflows/push-validation.yml index ed96c5f..3ce08e8 100644 --- a/.gitea/workflows/push-validation.yml +++ b/.gitea/workflows/push-validation.yml @@ -29,7 +29,8 @@ jobs: - name: Setup Go uses: actions/setup-go@v5 with: - go-version-file: go.mod + go-version: '1.26.1' + check-latest: true cache: false - name: Ensure tooling is available diff --git a/.gitea/workflows/tag-build-artifacts.yml b/.gitea/workflows/tag-build-artifacts.yml index cfca3a5..9bcee0b 100644 --- a/.gitea/workflows/tag-build-artifacts.yml +++ b/.gitea/workflows/tag-build-artifacts.yml @@ -34,7 +34,8 @@ jobs: - name: Setup Go uses: actions/setup-go@v5 with: - go-version-file: go.mod + go-version: '1.26.1' + check-latest: true cache: false - name: Build binary -- 2.49.1 From 7e32cd83c50182afe8d0f00b2e11f3a69fafcb6a Mon Sep 17 00:00:00 2001 From: Micheal Wilkinson Date: Fri, 20 Mar 2026 13:42:23 +0000 Subject: [PATCH 039/162] chore(ci): install aws cli via setup action --- .gitea/workflows/pr-validation.yml | 11 ++++++----- .gitea/workflows/push-validation.yml | 10 ++++------ 2 files changed, 10 insertions(+), 11 deletions(-) diff --git a/.gitea/workflows/pr-validation.yml b/.gitea/workflows/pr-validation.yml index 667c990..5011708 100644 --- a/.gitea/workflows/pr-validation.yml +++ b/.gitea/workflows/pr-validation.yml @@ -33,17 +33,18 @@ jobs: check-latest: true cache: false + - name: Install AWS CLI v2 + uses: ankurk91/install-aws-cli-action@v1 + - name: Ensure tooling is available run: | set -euo pipefail - if ! command -v aws >/dev/null 2>&1; then - pipx install awscli - fi + aws --version if ! command -v jq >/dev/null 2>&1; then - sudo apt-get update - sudo apt-get install -y jq + apt-get update + apt-get install -y jq fi - name: Run full unit test suite with coverage diff --git a/.gitea/workflows/push-validation.yml b/.gitea/workflows/push-validation.yml index 3ce08e8..d042aae 100644 --- a/.gitea/workflows/push-validation.yml +++ b/.gitea/workflows/push-validation.yml @@ -33,13 +33,11 @@ jobs: check-latest: true cache: false - - name: Ensure tooling is available - run: | - set -euo pipefail + - name: Install AWS CLI v2 + uses: ankurk91/install-aws-cli-action@v1 - if ! command -v aws >/dev/null 2>&1; then - pipx install awscli - fi + - name: Verify AWS CLI + run: aws --version - name: Run full unit test suite with coverage id: coverage -- 2.49.1 From 8fc831dfdfb962d7e1646bf2d7c7f2f0da566db3 Mon Sep 17 00:00:00 2001 From: Micheal Wilkinson Date: Fri, 20 Mar 2026 13:55:09 +0000 Subject: [PATCH 040/162] chore(ci): re-enable Go module caching and add coverage badge to README --- .gitea/workflows/pr-validation.yml | 3 ++- .gitea/workflows/push-validation.yml | 3 ++- .gitea/workflows/tag-build-artifacts.yml | 3 ++- README.md | 1 + 4 files changed, 7 insertions(+), 3 deletions(-) diff --git a/.gitea/workflows/pr-validation.yml b/.gitea/workflows/pr-validation.yml index 5011708..ba9ff49 100644 --- a/.gitea/workflows/pr-validation.yml +++ b/.gitea/workflows/pr-validation.yml @@ -31,7 +31,8 @@ jobs: with: go-version: '1.26.1' check-latest: true - cache: false + cache: true + cache-dependency-path: go.sum - name: Install AWS CLI v2 uses: ankurk91/install-aws-cli-action@v1 diff --git a/.gitea/workflows/push-validation.yml b/.gitea/workflows/push-validation.yml index d042aae..f069133 100644 --- a/.gitea/workflows/push-validation.yml +++ b/.gitea/workflows/push-validation.yml @@ -31,7 +31,8 @@ jobs: with: go-version: '1.26.1' check-latest: true - cache: false + cache: true + cache-dependency-path: go.sum - name: Install AWS CLI v2 uses: ankurk91/install-aws-cli-action@v1 diff --git a/.gitea/workflows/tag-build-artifacts.yml b/.gitea/workflows/tag-build-artifacts.yml index 9bcee0b..9cf41af 100644 --- a/.gitea/workflows/tag-build-artifacts.yml +++ b/.gitea/workflows/tag-build-artifacts.yml @@ -36,7 +36,8 @@ jobs: with: go-version: '1.26.1' check-latest: true - cache: false + cache: true + cache-dependency-path: go.sum - name: Build binary run: | diff --git a/README.md b/README.md index a0d09d1..17a5764 100644 --- a/README.md +++ b/README.md @@ -3,6 +3,7 @@ [![Main Validation](https://git.hrafn.xyz/aether/gosick/actions/workflows/push-validation.yml?branch=main&event=push)](https://git.hrafn.xyz/aether/gosick/actions/workflows/push-validation.yml) [![PR Validation](https://git.hrafn.xyz/aether/gosick/actions/workflows/pr-validation.yml?branch=main&event=pull_request)](https://git.hrafn.xyz/aether/gosick/actions/workflows/pr-validation.yml) [![Tag Build Artifacts](https://git.hrafn.xyz/aether/gosick/actions/workflows/tag-build-artifacts.yml?event=push)](https://git.hrafn.xyz/aether/gosick/actions/workflows/tag-build-artifacts.yml) +[![Coverage](https://s3.hrafn.xyz/aether-workflow-report-artefacts/gosick/branch/main/coverage-badge.svg)](https://s3.hrafn.xyz/aether-workflow-report-artefacts/gosick/branch/main/coverage.html) Your home directory is your castle. Don't leave your dotfiles behind. -- 2.49.1 From c3f809a586dcdb41a78f49be0b7b7de07b23d780 Mon Sep 17 00:00:00 2001 From: Micheal Wilkinson Date: Fri, 20 Mar 2026 14:46:54 +0000 Subject: [PATCH 041/162] chore(release): add UPX compression for linux artifacts --- .gitea/workflows/tag-build-artifacts.yml | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/.gitea/workflows/tag-build-artifacts.yml b/.gitea/workflows/tag-build-artifacts.yml index 9cf41af..f5c7ecf 100644 --- a/.gitea/workflows/tag-build-artifacts.yml +++ b/.gitea/workflows/tag-build-artifacts.yml @@ -39,11 +39,26 @@ jobs: cache: true cache-dependency-path: go.sum + - name: Install UPX + uses: crazy-max/ghaction-upx@v3 + with: + install-only: true + - name: Build binary run: | mkdir -p dist + output="dist/gosick_${{ matrix.goos }}_${{ matrix.goarch }}" GOOS=${{ matrix.goos }} GOARCH=${{ matrix.goarch }} CGO_ENABLED=0 \ - go build -o dist/gosick_${{ matrix.goos }}_${{ matrix.goarch }} ./cmd/homesick + go build -o "$output" ./cmd/homesick + + - name: Compress binary with UPX + if: ${{ matrix.goos == 'linux' }} + run: | + set -euo pipefail + output="dist/gosick_${{ matrix.goos }}_${{ matrix.goarch }}" + if ! upx --best --lzma "$output"; then + echo "::warning::UPX compression failed for ${output}; continuing with uncompressed binary" + fi - name: Package artifact run: | -- 2.49.1 From dbb6c825623f100323fad0958b8918d999e0c564 Mon Sep 17 00:00:00 2001 From: Micheal Wilkinson Date: Fri, 20 Mar 2026 14:49:42 +0000 Subject: [PATCH 042/162] test(release): cover automated release preparation --- internal/releaseprep/releaseprep_test.go | 63 ++++++++++++++++++++++++ 1 file changed, 63 insertions(+) create mode 100644 internal/releaseprep/releaseprep_test.go diff --git a/internal/releaseprep/releaseprep_test.go b/internal/releaseprep/releaseprep_test.go new file mode 100644 index 0000000..ca41f49 --- /dev/null +++ b/internal/releaseprep/releaseprep_test.go @@ -0,0 +1,63 @@ +package releaseprep + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" + "github.com/stretchr/testify/suite" +) + +type PrepareSuite struct { + suite.Suite + rootDir string +} + +func TestPrepareSuite(t *testing.T) { + suite.Run(t, new(PrepareSuite)) +} + +func (s *PrepareSuite) SetupTest() { + s.rootDir = s.T().TempDir() + versionDir := filepath.Join(s.rootDir, "internal", "homesick", "version") + require.NoError(s.T(), os.MkdirAll(versionDir, 0o755)) + + require.NoError(s.T(), os.WriteFile( + filepath.Join(versionDir, "version.go"), + []byte("package version\n\nconst String = \"1.1.6\"\n"), + 0o644, + )) + + require.NoError(s.T(), os.WriteFile( + filepath.Join(s.rootDir, "changelog.md"), + []byte("# Changelog\n\n## [Unreleased]\n\n### Added\n\n- New thing.\n\n### Fixed\n\n- Old thing.\n\n## [1.1.6] - 2017-12-20\n\n### Fixed\n\n- Historical note.\n"), + 0o644, + )) +} + +func (s *PrepareSuite) TestPrepare_UpdatesVersionAndPromotesUnreleasedNotes() { + err := Prepare(s.rootDir, "v1.1.7", "2026-03-20") + + require.NoError(s.T(), err) + + versionBytes, err := os.ReadFile(filepath.Join(s.rootDir, "internal", "homesick", "version", "version.go")) + require.NoError(s.T(), err) + require.Equal(s.T(), "package version\n\nconst String = \"1.1.7\"\n", string(versionBytes)) + + changelogBytes, err := os.ReadFile(filepath.Join(s.rootDir, "changelog.md")) + require.NoError(s.T(), err) + require.Equal(s.T(), "# Changelog\n\n## [Unreleased]\n\n## [1.1.7] - 2026-03-20\n\n### Added\n\n- New thing.\n\n### Fixed\n\n- Old thing.\n\n## [1.1.6] - 2017-12-20\n\n### Fixed\n\n- Historical note.\n", string(changelogBytes)) +} + +func (s *PrepareSuite) TestPrepare_ReturnsErrorWhenUnreleasedSectionMissing() { + require.NoError(s.T(), os.WriteFile( + filepath.Join(s.rootDir, "changelog.md"), + []byte("# Changelog\n\n## [1.1.6] - 2017-12-20\n"), + 0o644, + )) + + err := Prepare(s.rootDir, "1.1.7", "2026-03-20") + + require.ErrorContains(s.T(), err, "unreleased section") +} -- 2.49.1 From feb8ca3434f8fb86ae9d363bde75b094548e0719 Mon Sep 17 00:00:00 2001 From: Micheal Wilkinson Date: Fri, 20 Mar 2026 14:51:23 +0000 Subject: [PATCH 043/162] test(release): use external package for release prep tests --- internal/releaseprep/releaseprep_test.go | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/internal/releaseprep/releaseprep_test.go b/internal/releaseprep/releaseprep_test.go index ca41f49..0b94608 100644 --- a/internal/releaseprep/releaseprep_test.go +++ b/internal/releaseprep/releaseprep_test.go @@ -1,10 +1,11 @@ -package releaseprep +package releaseprep_test import ( "os" "path/filepath" "testing" + "git.hrafn.xyz/aether/gosick/internal/releaseprep" "github.com/stretchr/testify/require" "github.com/stretchr/testify/suite" ) @@ -37,7 +38,7 @@ func (s *PrepareSuite) SetupTest() { } func (s *PrepareSuite) TestPrepare_UpdatesVersionAndPromotesUnreleasedNotes() { - err := Prepare(s.rootDir, "v1.1.7", "2026-03-20") + err := releaseprep.Prepare(s.rootDir, "v1.1.7", "2026-03-20") require.NoError(s.T(), err) @@ -57,7 +58,7 @@ func (s *PrepareSuite) TestPrepare_ReturnsErrorWhenUnreleasedSectionMissing() { 0o644, )) - err := Prepare(s.rootDir, "1.1.7", "2026-03-20") + err := releaseprep.Prepare(s.rootDir, "1.1.7", "2026-03-20") require.ErrorContains(s.T(), err, "unreleased section") } -- 2.49.1 From 799c8d167dc41a0306c1b06533c9479307e80415 Mon Sep 17 00:00:00 2001 From: Micheal Wilkinson Date: Fri, 20 Mar 2026 14:54:57 +0000 Subject: [PATCH 044/162] feat(release): automate release preparation --- .gitea/workflows/prepare-release.yml | 83 +++++++++++++++++++++++++ cmd/releaseprep/main.go | 33 ++++++++++ internal/releaseprep/releaseprep.go | 90 ++++++++++++++++++++++++++++ justfile | 3 + script/prepare-release.sh | 12 ++++ 5 files changed, 221 insertions(+) create mode 100644 .gitea/workflows/prepare-release.yml create mode 100644 cmd/releaseprep/main.go create mode 100644 internal/releaseprep/releaseprep.go create mode 100755 script/prepare-release.sh diff --git a/.gitea/workflows/prepare-release.yml b/.gitea/workflows/prepare-release.yml new file mode 100644 index 0000000..9456177 --- /dev/null +++ b/.gitea/workflows/prepare-release.yml @@ -0,0 +1,83 @@ +name: Prepare Release + +on: + workflow_dispatch: + inputs: + version: + description: Semantic version to release, with or without leading v. + required: true + +jobs: + prepare: + runs-on: ubuntu-latest + container: docker.io/catthehacker/ubuntu:act-latest + defaults: + run: + shell: bash + env: + RELEASE_TOKEN: ${{ secrets.GITHUB_TOKEN }} + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Setup Go + uses: actions/setup-go@v5 + with: + go-version: '1.26.1' + check-latest: true + cache: true + cache-dependency-path: go.sum + + - name: Prepare release files + env: + RELEASE_VERSION: ${{ inputs.version }} + run: | + set -euo pipefail + ./script/prepare-release.sh "$RELEASE_VERSION" + + - name: Run tests + run: | + set -euo pipefail + go test ./... + + - name: Configure git author + run: | + set -euo pipefail + git config user.name "gitea-actions[bot]" + git config user.email "gitea-actions[bot]@users.noreply.local" + + - name: Commit release changes and push tag + env: + RELEASE_VERSION: ${{ inputs.version }} + run: | + set -euo pipefail + + normalized_version="${RELEASE_VERSION#v}" + tag="v${normalized_version}" + + if git rev-parse "$tag" >/dev/null 2>&1; then + echo "Tag ${tag} already exists" >&2 + exit 1 + fi + + case "$GITHUB_SERVER_URL" in + https://*) + authed_remote="https://oauth2:${RELEASE_TOKEN}@${GITHUB_SERVER_URL#https://}/${GITHUB_REPOSITORY}.git" + ;; + http://*) + authed_remote="http://oauth2:${RELEASE_TOKEN}@${GITHUB_SERVER_URL#http://}/${GITHUB_REPOSITORY}.git" + ;; + *) + echo "Unsupported GITHUB_SERVER_URL: ${GITHUB_SERVER_URL}" >&2 + exit 1 + ;; + esac + + git remote set-url origin "$authed_remote" + git add changelog.md internal/homesick/version/version.go + git commit -m "release: prepare ${tag}" + git tag "$tag" + git push origin HEAD + git push origin "$tag" \ No newline at end of file diff --git a/cmd/releaseprep/main.go b/cmd/releaseprep/main.go new file mode 100644 index 0000000..d1c8059 --- /dev/null +++ b/cmd/releaseprep/main.go @@ -0,0 +1,33 @@ +package main + +import ( + "flag" + "fmt" + "os" + "path/filepath" + + "git.hrafn.xyz/aether/gosick/internal/releaseprep" +) + +func main() { + version := flag.String("version", "", "semantic version to release, with or without leading v") + date := flag.String("date", "", "release date in YYYY-MM-DD format") + root := flag.String("root", ".", "repository root to update") + flag.Parse() + + if *version == "" || *date == "" { + fmt.Fprintln(os.Stderr, "usage: releaseprep --version --date [--root ]") + os.Exit(2) + } + + absRoot, err := filepath.Abs(*root) + if err != nil { + fmt.Fprintf(os.Stderr, "resolve root: %v\n", err) + os.Exit(1) + } + + if err := releaseprep.Prepare(absRoot, *version, *date); err != nil { + fmt.Fprintf(os.Stderr, "prepare release: %v\n", err) + os.Exit(1) + } +} \ No newline at end of file diff --git a/internal/releaseprep/releaseprep.go b/internal/releaseprep/releaseprep.go new file mode 100644 index 0000000..5a43a6a --- /dev/null +++ b/internal/releaseprep/releaseprep.go @@ -0,0 +1,90 @@ +package releaseprep + +import ( + "fmt" + "os" + "path/filepath" + "regexp" + "strings" +) + +var versionPattern = regexp.MustCompile(`const String = "[^"]+"`) + +func Prepare(rootDir, version, releaseDate string) error { + normalizedVersion := strings.TrimPrefix(strings.TrimSpace(version), "v") + if normalizedVersion == "" { + return fmt.Errorf("version must not be empty") + } + + releaseDate = strings.TrimSpace(releaseDate) + if releaseDate == "" { + return fmt.Errorf("release date must not be empty") + } + + if err := updateVersionFile(rootDir, normalizedVersion); err != nil { + return err + } + + if err := updateChangelog(rootDir, normalizedVersion, releaseDate); err != nil { + return err + } + + return nil +} + +func updateVersionFile(rootDir, version string) error { + path := filepath.Join(rootDir, "internal", "homesick", "version", "version.go") + contents, err := os.ReadFile(path) + if err != nil { + return fmt.Errorf("read version file: %w", err) + } + + updated := versionPattern.ReplaceAllString(string(contents), fmt.Sprintf(`const String = %q`, version)) + if updated == string(contents) { + return fmt.Errorf("version constant not found in %s", path) + } + + if err := os.WriteFile(path, []byte(updated), 0o644); err != nil { + return fmt.Errorf("write version file: %w", err) + } + + return nil +} + +func updateChangelog(rootDir, version, releaseDate string) error { + path := filepath.Join(rootDir, "changelog.md") + contents, err := os.ReadFile(path) + if err != nil { + return fmt.Errorf("read changelog: %w", err) + } + + text := string(contents) + unreleasedHeader := "## [Unreleased]\n" + start := strings.Index(text, unreleasedHeader) + if start == -1 { + return fmt.Errorf("unreleased section not found in changelog") + } + + afterHeader := start + len(unreleasedHeader) + nextSectionRelative := strings.Index(text[afterHeader:], "\n## [") + if nextSectionRelative == -1 { + nextSectionRelative = len(text[afterHeader:]) + } + nextSectionStart := afterHeader + nextSectionRelative + unreleasedBody := strings.TrimLeft(text[afterHeader:nextSectionStart], "\n") + + newSection := fmt.Sprintf("## [%s] - %s\n", version, releaseDate) + if strings.TrimSpace(unreleasedBody) != "" { + newSection += "\n" + unreleasedBody + if !strings.HasSuffix(newSection, "\n") { + newSection += "\n" + } + } + + updated := text[:afterHeader] + "\n" + newSection + text[nextSectionStart:] + if err := os.WriteFile(path, []byte(updated), 0o644); err != nil { + return fmt.Errorf("write changelog: %w", err) + } + + return nil +} \ No newline at end of file diff --git a/justfile b/justfile index ea5dabb..544d7df 100644 --- a/justfile +++ b/justfile @@ -19,3 +19,6 @@ behavior: behavior-verbose: ./script/run-behavior-suite-docker.sh --verbose + +prepare-release version: + ./script/prepare-release.sh "{{version}}" diff --git a/script/prepare-release.sh b/script/prepare-release.sh new file mode 100755 index 0000000..2fb4f7f --- /dev/null +++ b/script/prepare-release.sh @@ -0,0 +1,12 @@ +#!/usr/bin/env bash +set -euo pipefail + +if [[ $# -ne 1 ]]; then + echo "usage: $0 " >&2 + exit 2 +fi + +repo_root="$(cd "$(dirname "$0")/.." && pwd)" +release_date="$(date -u +%F)" + +go run ./cmd/releaseprep --root "$repo_root" --version "$1" --date "$release_date" \ No newline at end of file -- 2.49.1 From f9c853a4e9529e779aa143eeceb8ab982223aa27 Mon Sep 17 00:00:00 2001 From: Micheal Wilkinson Date: Fri, 20 Mar 2026 14:55:16 +0000 Subject: [PATCH 045/162] docs(changelog): note release preparation automation --- changelog.md | 1 + 1 file changed, 1 insertion(+) diff --git a/changelog.md b/changelog.md index 0a979eb..3f4ea61 100644 --- a/changelog.md +++ b/changelog.md @@ -15,6 +15,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Just workflow support for building and running the Linux behavior binary. - Coverage reports and badges published to shared object storage for branches and pull requests. - Pull requests now receive coverage report links in CI comments. +- Manual release preparation workflow and script to bump the reported version, promote unreleased changelog notes, and create release tags. ### Changed -- 2.49.1 From 3b8dadbd2991e8b0f965666a02511ff37e999d97 Mon Sep 17 00:00:00 2001 From: Micheal Wilkinson Date: Fri, 20 Mar 2026 14:58:31 +0000 Subject: [PATCH 046/162] test(release): guard empty notes and suggest next tag --- internal/releaseprep/releaseprep_test.go | 45 ++++++++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/internal/releaseprep/releaseprep_test.go b/internal/releaseprep/releaseprep_test.go index 0b94608..1e69ff4 100644 --- a/internal/releaseprep/releaseprep_test.go +++ b/internal/releaseprep/releaseprep_test.go @@ -62,3 +62,48 @@ func (s *PrepareSuite) TestPrepare_ReturnsErrorWhenUnreleasedSectionMissing() { require.ErrorContains(s.T(), err, "unreleased section") } + +func (s *PrepareSuite) TestPrepare_ReturnsErrorWhenUnreleasedSectionIsEmpty() { + require.NoError(s.T(), os.WriteFile( + filepath.Join(s.rootDir, "changelog.md"), + []byte("# Changelog\n\n## [Unreleased]\n\n## [1.1.6] - 2017-12-20\n"), + 0o644, + )) + + err := releaseprep.Prepare(s.rootDir, "1.1.7", "2026-03-20") + + require.ErrorContains(s.T(), err, "unreleased section is empty") +} + +func (s *PrepareSuite) TestRecommendedTag_UsesMinorBumpWhenAddedEntriesExist() { + tag, err := releaseprep.RecommendedTag(s.rootDir) + + require.NoError(s.T(), err) + require.Equal(s.T(), "v1.2.0", tag) +} + +func (s *PrepareSuite) TestRecommendedTag_UsesPatchBumpForFixOnlyChanges() { + require.NoError(s.T(), os.WriteFile( + filepath.Join(s.rootDir, "changelog.md"), + []byte("# Changelog\n\n## [Unreleased]\n\n### Fixed\n\n- Patch note.\n\n## [1.1.6] - 2017-12-20\n"), + 0o644, + )) + + tag, err := releaseprep.RecommendedTag(s.rootDir) + + require.NoError(s.T(), err) + require.Equal(s.T(), "v1.1.7", tag) +} + +func (s *PrepareSuite) TestRecommendedTag_UsesMajorBumpWhenRemovedEntriesExist() { + require.NoError(s.T(), os.WriteFile( + filepath.Join(s.rootDir, "changelog.md"), + []byte("# Changelog\n\n## [Unreleased]\n\n### Removed\n\n- Breaking removal.\n\n## [1.1.6] - 2017-12-20\n"), + 0o644, + )) + + tag, err := releaseprep.RecommendedTag(s.rootDir) + + require.NoError(s.T(), err) + require.Equal(s.T(), "v2.0.0", tag) +} -- 2.49.1 From 93918f3a393cdcd954b5ba83c1b32aee84b584fc Mon Sep 17 00:00:00 2001 From: Micheal Wilkinson Date: Fri, 20 Mar 2026 14:59:46 +0000 Subject: [PATCH 047/162] feat(release): guard empty notes and recommend next tag --- .gitea/workflows/push-validation.yml | 23 +++++ cmd/releaseprep/main.go | 23 +++-- internal/releaseprep/releaseprep.go | 126 ++++++++++++++++++++++++--- 3 files changed, 153 insertions(+), 19 deletions(-) diff --git a/.gitea/workflows/push-validation.yml b/.gitea/workflows/push-validation.yml index f069133..4dadd65 100644 --- a/.gitea/workflows/push-validation.yml +++ b/.gitea/workflows/push-validation.yml @@ -121,3 +121,26 @@ jobs: - name: Run behavior suite on main pushes if: ${{ github.ref == 'refs/heads/main' }} run: ./script/run-behavior-suite-docker.sh + + - name: Recommend next release tag on main pushes + if: ${{ github.ref == 'refs/heads/main' }} + run: | + set -euo pipefail + + if recommended_tag="$(go run ./cmd/releaseprep --recommend --root . 2>release-recommendation.err)"; then + { + echo + echo '## Release Recommendation' + echo + echo "- Recommended next tag: \\`${recommended_tag}\\`" + } >> "$GITHUB_STEP_SUMMARY" + else + recommendation_error="$(tr '\n' ' ' < release-recommendation.err | sed 's/[[:space:]]\+/ /g' | sed 's/^ //; s/ $//')" + echo "::warning::${recommendation_error}" + { + echo + echo '## Release Recommendation' + echo + echo "- No recommended tag emitted: ${recommendation_error}" + } >> "$GITHUB_STEP_SUMMARY" + fi diff --git a/cmd/releaseprep/main.go b/cmd/releaseprep/main.go index d1c8059..8d9d047 100644 --- a/cmd/releaseprep/main.go +++ b/cmd/releaseprep/main.go @@ -12,22 +12,33 @@ import ( func main() { version := flag.String("version", "", "semantic version to release, with or without leading v") date := flag.String("date", "", "release date in YYYY-MM-DD format") + recommend := flag.Bool("recommend", false, "print the recommended next release tag based on the changelog") root := flag.String("root", ".", "repository root to update") flag.Parse() - if *version == "" || *date == "" { - fmt.Fprintln(os.Stderr, "usage: releaseprep --version --date [--root ]") - os.Exit(2) - } - absRoot, err := filepath.Abs(*root) if err != nil { fmt.Fprintf(os.Stderr, "resolve root: %v\n", err) os.Exit(1) } + if *recommend { + tag, err := releaseprep.RecommendedTag(absRoot) + if err != nil { + fmt.Fprintf(os.Stderr, "recommend release: %v\n", err) + os.Exit(1) + } + fmt.Println(tag) + return + } + + if *version == "" || *date == "" { + fmt.Fprintln(os.Stderr, "usage: releaseprep --version --date [--root ] | --recommend [--root ]") + os.Exit(2) + } + if err := releaseprep.Prepare(absRoot, *version, *date); err != nil { fmt.Fprintf(os.Stderr, "prepare release: %v\n", err) os.Exit(1) } -} \ No newline at end of file +} diff --git a/internal/releaseprep/releaseprep.go b/internal/releaseprep/releaseprep.go index 5a43a6a..17e5e08 100644 --- a/internal/releaseprep/releaseprep.go +++ b/internal/releaseprep/releaseprep.go @@ -5,11 +5,18 @@ import ( "os" "path/filepath" "regexp" + "strconv" "strings" ) var versionPattern = regexp.MustCompile(`const String = "[^"]+"`) +type semver struct { + major int + minor int + patch int +} + func Prepare(rootDir, version, releaseDate string) error { normalizedVersion := strings.TrimPrefix(strings.TrimSpace(version), "v") if normalizedVersion == "" { @@ -32,6 +39,37 @@ func Prepare(rootDir, version, releaseDate string) error { return nil } +func RecommendedTag(rootDir string) (string, error) { + currentVersion, err := readCurrentVersion(rootDir) + if err != nil { + return "", err + } + + unreleasedBody, err := readUnreleasedBody(rootDir) + if err != nil { + return "", err + } + + parsed, err := parseSemver(currentVersion) + if err != nil { + return "", err + } + + switch { + case strings.Contains(unreleasedBody, "### Removed"): + parsed.major++ + parsed.minor = 0 + parsed.patch = 0 + case strings.Contains(unreleasedBody, "### Added"): + parsed.minor++ + parsed.patch = 0 + default: + parsed.patch++ + } + + return fmt.Sprintf("v%d.%d.%d", parsed.major, parsed.minor, parsed.patch), nil +} + func updateVersionFile(rootDir, version string) error { path := filepath.Join(rootDir, "internal", "homesick", "version", "version.go") contents, err := os.ReadFile(path) @@ -52,17 +90,70 @@ func updateVersionFile(rootDir, version string) error { } func updateChangelog(rootDir, version, releaseDate string) error { + unreleasedBody, text, afterHeader, nextSectionStart, err := readChangelogState(rootDir) + if err != nil { + return err + } + + if strings.TrimSpace(unreleasedBody) == "" { + return fmt.Errorf("unreleased section is empty") + } + + path := filepath.Join(rootDir, "changelog.md") + newSection := fmt.Sprintf("## [%s] - %s\n", version, releaseDate) + newSection += "\n" + unreleasedBody + if !strings.HasSuffix(newSection, "\n") { + newSection += "\n" + } + + updated := text[:afterHeader] + "\n" + newSection + text[nextSectionStart:] + if err := os.WriteFile(path, []byte(updated), 0o644); err != nil { + return fmt.Errorf("write changelog: %w", err) + } + + return nil +} + +func readCurrentVersion(rootDir string) (string, error) { + path := filepath.Join(rootDir, "internal", "homesick", "version", "version.go") + contents, err := os.ReadFile(path) + if err != nil { + return "", fmt.Errorf("read version file: %w", err) + } + + match := versionPattern.FindString(string(contents)) + if match == "" { + return "", fmt.Errorf("version constant not found in %s", path) + } + + return strings.TrimSuffix(strings.TrimPrefix(match, `const String = "`), `"`), nil +} + +func readUnreleasedBody(rootDir string) (string, error) { + unreleasedBody, _, _, _, err := readChangelogState(rootDir) + if err != nil { + return "", err + } + + if strings.TrimSpace(unreleasedBody) == "" { + return "", fmt.Errorf("unreleased section is empty") + } + + return unreleasedBody, nil +} + +func readChangelogState(rootDir string) (string, string, int, int, error) { path := filepath.Join(rootDir, "changelog.md") contents, err := os.ReadFile(path) if err != nil { - return fmt.Errorf("read changelog: %w", err) + return "", "", 0, 0, fmt.Errorf("read changelog: %w", err) } text := string(contents) unreleasedHeader := "## [Unreleased]\n" start := strings.Index(text, unreleasedHeader) if start == -1 { - return fmt.Errorf("unreleased section not found in changelog") + return "", "", 0, 0, fmt.Errorf("unreleased section not found in changelog") } afterHeader := start + len(unreleasedHeader) @@ -73,18 +164,27 @@ func updateChangelog(rootDir, version, releaseDate string) error { nextSectionStart := afterHeader + nextSectionRelative unreleasedBody := strings.TrimLeft(text[afterHeader:nextSectionStart], "\n") - newSection := fmt.Sprintf("## [%s] - %s\n", version, releaseDate) - if strings.TrimSpace(unreleasedBody) != "" { - newSection += "\n" + unreleasedBody - if !strings.HasSuffix(newSection, "\n") { - newSection += "\n" - } + return unreleasedBody, text, afterHeader, nextSectionStart, nil +} + +func parseSemver(version string) (semver, error) { + parts := strings.Split(strings.TrimSpace(version), ".") + if len(parts) != 3 { + return semver{}, fmt.Errorf("version %q is not semantic major.minor.patch", version) } - updated := text[:afterHeader] + "\n" + newSection + text[nextSectionStart:] - if err := os.WriteFile(path, []byte(updated), 0o644); err != nil { - return fmt.Errorf("write changelog: %w", err) + major, err := strconv.Atoi(parts[0]) + if err != nil { + return semver{}, fmt.Errorf("parse major version: %w", err) + } + minor, err := strconv.Atoi(parts[1]) + if err != nil { + return semver{}, fmt.Errorf("parse minor version: %w", err) + } + patch, err := strconv.Atoi(parts[2]) + if err != nil { + return semver{}, fmt.Errorf("parse patch version: %w", err) } - return nil -} \ No newline at end of file + return semver{major: major, minor: minor, patch: patch}, nil +} -- 2.49.1 From 0dd38e52677ebfa8f8890c627917185ec0b9587d Mon Sep 17 00:00:00 2001 From: Micheal Wilkinson Date: Fri, 20 Mar 2026 14:59:57 +0000 Subject: [PATCH 048/162] docs(changelog): note release tag recommendation guard --- changelog.md | 1 + 1 file changed, 1 insertion(+) diff --git a/changelog.md b/changelog.md index 3f4ea61..42a568e 100644 --- a/changelog.md +++ b/changelog.md @@ -16,6 +16,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Coverage reports and badges published to shared object storage for branches and pull requests. - Pull requests now receive coverage report links in CI comments. - Manual release preparation workflow and script to bump the reported version, promote unreleased changelog notes, and create release tags. +- Main branch validation now emits a recommended next release tag based on unreleased changelog sections, and release preparation now rejects empty unreleased notes. ### Changed -- 2.49.1 From af491aa267f554a3bfb29d3ade038ede95f17083 Mon Sep 17 00:00:00 2001 From: Micheal Wilkinson Date: Fri, 20 Mar 2026 15:03:30 +0000 Subject: [PATCH 049/162] test(release): treat breaking notes as major bump --- internal/releaseprep/releaseprep_test.go | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/internal/releaseprep/releaseprep_test.go b/internal/releaseprep/releaseprep_test.go index 1e69ff4..9c1b8c9 100644 --- a/internal/releaseprep/releaseprep_test.go +++ b/internal/releaseprep/releaseprep_test.go @@ -107,3 +107,16 @@ func (s *PrepareSuite) TestRecommendedTag_UsesMajorBumpWhenRemovedEntriesExist() require.NoError(s.T(), err) require.Equal(s.T(), "v2.0.0", tag) } + +func (s *PrepareSuite) TestRecommendedTag_UsesMajorBumpWhenBreakingEntriesExist() { + require.NoError(s.T(), os.WriteFile( + filepath.Join(s.rootDir, "changelog.md"), + []byte("# Changelog\n\n## [Unreleased]\n\n### Breaking\n\n- Changed API contract.\n\n### Changed\n\n- Updated defaults.\n\n## [1.1.6] - 2017-12-20\n"), + 0o644, + )) + + tag, err := releaseprep.RecommendedTag(s.rootDir) + + require.NoError(s.T(), err) + require.Equal(s.T(), "v2.0.0", tag) +} -- 2.49.1 From 38f649e99b3ae2f11af7ba00526a6577cf2b644f Mon Sep 17 00:00:00 2001 From: Micheal Wilkinson Date: Fri, 20 Mar 2026 15:04:15 +0000 Subject: [PATCH 050/162] feat(release): support breaking changelog notes --- internal/releaseprep/releaseprep.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/releaseprep/releaseprep.go b/internal/releaseprep/releaseprep.go index 17e5e08..3177170 100644 --- a/internal/releaseprep/releaseprep.go +++ b/internal/releaseprep/releaseprep.go @@ -56,7 +56,7 @@ func RecommendedTag(rootDir string) (string, error) { } switch { - case strings.Contains(unreleasedBody, "### Removed"): + case strings.Contains(unreleasedBody, "### Breaking"), strings.Contains(unreleasedBody, "### Removed"): parsed.major++ parsed.minor = 0 parsed.patch = 0 -- 2.49.1 From 029175cb5551ddf72319758a46556f6518dc0f00 Mon Sep 17 00:00:00 2001 From: Micheal Wilkinson Date: Fri, 20 Mar 2026 15:04:25 +0000 Subject: [PATCH 051/162] docs(changelog): note breaking release section --- changelog.md | 1 + 1 file changed, 1 insertion(+) diff --git a/changelog.md b/changelog.md index 42a568e..ad4569b 100644 --- a/changelog.md +++ b/changelog.md @@ -17,6 +17,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Pull requests now receive coverage report links in CI comments. - Manual release preparation workflow and script to bump the reported version, promote unreleased changelog notes, and create release tags. - Main branch validation now emits a recommended next release tag based on unreleased changelog sections, and release preparation now rejects empty unreleased notes. +- Release recommendations now support an explicit `### Breaking` section for major-version changes that would otherwise be described under `### Changed`. ### Changed -- 2.49.1 From 07d73660eb0762f78e1759e1939399e77358ed7e Mon Sep 17 00:00:00 2001 From: Micheal Wilkinson Date: Fri, 20 Mar 2026 15:05:20 +0000 Subject: [PATCH 052/162] docs(changelog): seed unreleased section with breaking heading --- changelog.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/changelog.md b/changelog.md index ad4569b..77122af 100644 --- a/changelog.md +++ b/changelog.md @@ -7,6 +7,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Breaking + ### Added - Native Go implementations for `clone`, `link`, `unlink`, and `track`. -- 2.49.1 From 1e5de20a41a803bca350ce4c33c1370b59b243b6 Mon Sep 17 00:00:00 2001 From: Micheal Wilkinson Date: Fri, 20 Mar 2026 15:06:33 +0000 Subject: [PATCH 053/162] docs(changelog): document breaking change section convention --- changelog.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/changelog.md b/changelog.md index 77122af..f4c565a 100644 --- a/changelog.md +++ b/changelog.md @@ -5,6 +5,8 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +A `### Breaking` section is used in addition to Keep a Changelog's standard sections to explicitly document changes that are backwards-incompatible but would otherwise appear under `### Changed`. Entries under `### Breaking` trigger a major version bump in the automated release tooling. + ## [Unreleased] ### Breaking -- 2.49.1 From 75f636f9bac240088878d55c8d0f6fcb31553c2d Mon Sep 17 00:00:00 2001 From: Micheal Wilkinson Date: Fri, 20 Mar 2026 15:26:50 +0000 Subject: [PATCH 054/162] test(rc): add failing tests for Rc command --- internal/homesick/core/rc_test.go | 190 ++++++++++++++++++++++++++++++ 1 file changed, 190 insertions(+) create mode 100644 internal/homesick/core/rc_test.go diff --git a/internal/homesick/core/rc_test.go b/internal/homesick/core/rc_test.go new file mode 100644 index 0000000..89bff37 --- /dev/null +++ b/internal/homesick/core/rc_test.go @@ -0,0 +1,190 @@ +package core_test + +import ( + "bytes" + "io" + "os" + "path/filepath" + "testing" + + "git.hrafn.xyz/aether/gosick/internal/homesick/core" + "github.com/stretchr/testify/require" + "github.com/stretchr/testify/suite" +) + +type RcSuite struct { + suite.Suite + tmpDir string + homeDir string + reposDir string + stdout *bytes.Buffer + stderr *bytes.Buffer + app *core.App +} + +func TestRcSuite(t *testing.T) { + suite.Run(t, new(RcSuite)) +} + +func (s *RcSuite) 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.stdout = &bytes.Buffer{} + s.stderr = &bytes.Buffer{} + s.app = &core.App{ + HomeDir: s.homeDir, + ReposDir: s.reposDir, + Stdout: s.stdout, + Stderr: s.stderr, + } +} + +func (s *RcSuite) createCastle(name string) string { + castleRoot := filepath.Join(s.reposDir, name) + require.NoError(s.T(), os.MkdirAll(castleRoot, 0o755)) + return castleRoot +} + +var _ io.Writer + +// TestRc_UnknownCastleReturnsError ensures Rc returns an error when the +// castle directory does not exist. +func (s *RcSuite) TestRc_UnknownCastleReturnsError() { + err := s.app.Rc("nonexistent") + require.Error(s.T(), err) +} + +// TestRc_NoScriptsAndNoHomesickrc is a no-op when neither .homesick.d nor +// .homesickrc are present. +func (s *RcSuite) TestRc_NoScriptsAndNoHomesickrcIsNoop() { + s.createCastle("dotfiles") + require.NoError(s.T(), s.app.Rc("dotfiles")) +} + +// TestRc_ExecutesScriptsInSortedOrder verifies that executable scripts inside +// .homesick.d are run in lexicographic (sorted) order. +func (s *RcSuite) TestRc_ExecutesScriptsInSortedOrder() { + castleRoot := s.createCastle("dotfiles") + homesickD := filepath.Join(castleRoot, ".homesick.d") + require.NoError(s.T(), os.MkdirAll(homesickD, 0o755)) + + orderFile := filepath.Join(s.tmpDir, "order.txt") + scriptA := filepath.Join(homesickD, "10_a.sh") + scriptB := filepath.Join(homesickD, "20_b.sh") + + require.NoError(s.T(), os.WriteFile(scriptA, []byte("#!/bin/sh\necho a >> "+orderFile+"\n"), 0o755)) + require.NoError(s.T(), os.WriteFile(scriptB, []byte("#!/bin/sh\necho b >> "+orderFile+"\n"), 0o755)) + + require.NoError(s.T(), s.app.Rc("dotfiles")) + + content, err := os.ReadFile(orderFile) + require.NoError(s.T(), err) + require.Equal(s.T(), "a\nb\n", string(content)) +} + +// TestRc_SkipsNonExecutableFiles ensures that files without the executable bit +// are not run. +func (s *RcSuite) TestRc_SkipsNonExecutableFiles() { + castleRoot := s.createCastle("dotfiles") + homesickD := filepath.Join(castleRoot, ".homesick.d") + require.NoError(s.T(), os.MkdirAll(homesickD, 0o755)) + + notExec := filepath.Join(homesickD, "10_script.sh") + // Write a script that would exit 1 if actually run — verify it is skipped. + require.NoError(s.T(), os.WriteFile(notExec, []byte("#!/bin/sh\nexit 1\n"), 0o644)) + + require.NoError(s.T(), s.app.Rc("dotfiles")) +} + +// TestRc_HomesickrcCreatesRubyWrapper verifies that a .homesickrc file causes +// a Ruby wrapper to be written into .homesick.d before execution. +func (s *RcSuite) TestRc_HomesickrcCreatesRubyWrapper() { + castleRoot := s.createCastle("dotfiles") + homesickRc := filepath.Join(castleRoot, ".homesickrc") + require.NoError(s.T(), os.WriteFile(homesickRc, []byte("# ruby setup code\n"), 0o644)) + + require.NoError(s.T(), s.app.Rc("dotfiles")) + + wrapperPath := filepath.Join(castleRoot, ".homesick.d", "00_homesickrc.rb") + require.FileExists(s.T(), wrapperPath) + + info, err := os.Stat(wrapperPath) + require.NoError(s.T(), err) + require.NotZero(s.T(), info.Mode()&0o111, "wrapper must be executable") + + content, err := os.ReadFile(wrapperPath) + require.NoError(s.T(), err) + require.Contains(s.T(), string(content), ".homesickrc") +} + +// TestRc_HomesickrcWrapperRunsBeforeOtherScripts ensures the wrapper file +// (00_homesickrc.rb) sorts before typical user scripts and is present in +// .homesick.d after Rc returns. +func (s *RcSuite) TestRc_HomesickrcWrapperCreatedBeforeExecution() { + castleRoot := s.createCastle("dotfiles") + homesickRc := filepath.Join(castleRoot, ".homesickrc") + require.NoError(s.T(), os.WriteFile(homesickRc, []byte("# ruby setup code\n"), 0o644)) + + homesickD := filepath.Join(castleRoot, ".homesick.d") + require.NoError(s.T(), os.MkdirAll(homesickD, 0o755)) + + // A sentinel script that records whether the wrapper already exists. + orderFile := filepath.Join(s.tmpDir, "check.txt") + sentinel := filepath.Join(homesickD, "50_check.sh") + wrapperPath := filepath.Join(homesickD, "00_homesickrc.rb") + require.NoError(s.T(), os.WriteFile(sentinel, []byte( + "#!/bin/sh\n[ -f "+wrapperPath+" ] && echo present >> "+orderFile+"\n", + ), 0o755)) + + require.NoError(s.T(), s.app.Rc("dotfiles")) + + content, err := os.ReadFile(orderFile) + require.NoError(s.T(), err) + require.Equal(s.T(), "present\n", string(content)) +} + +// TestRc_FailingScriptReturnsError ensures that a non-zero exit from a script +// propagates as an error. +func (s *RcSuite) TestRc_FailingScriptReturnsError() { + castleRoot := s.createCastle("dotfiles") + homesickD := filepath.Join(castleRoot, ".homesick.d") + require.NoError(s.T(), os.MkdirAll(homesickD, 0o755)) + + failing := filepath.Join(homesickD, "10_fail.sh") + require.NoError(s.T(), os.WriteFile(failing, []byte("#!/bin/sh\nexit 42\n"), 0o755)) + + err := s.app.Rc("dotfiles") + require.Error(s.T(), err) +} + +// TestRc_ScriptOutputForwarded verifies that stdout and stderr from scripts +// are forwarded to the App's writers. +func (s *RcSuite) TestRc_ScriptOutputForwarded() { + castleRoot := s.createCastle("dotfiles") + homesickD := filepath.Join(castleRoot, ".homesick.d") + require.NoError(s.T(), os.MkdirAll(homesickD, 0o755)) + + script := filepath.Join(homesickD, "10_output.sh") + require.NoError(s.T(), os.WriteFile(script, []byte("#!/bin/sh\necho hello\necho world >&2\n"), 0o755)) + + require.NoError(s.T(), s.app.Rc("dotfiles")) + require.Contains(s.T(), s.stdout.String(), "hello") + require.Contains(s.T(), s.stderr.String(), "world") +} + +// TestRc_ScriptsRunWithCwdSetToCastleRoot verifies scripts execute with the +// castle root as the working directory. +func (s *RcSuite) TestRc_ScriptsRunWithCwdSetToCastleRoot() { + castleRoot := s.createCastle("dotfiles") + homesickD := filepath.Join(castleRoot, ".homesick.d") + require.NoError(s.T(), os.MkdirAll(homesickD, 0o755)) + + script := filepath.Join(homesickD, "10_pwd.sh") + require.NoError(s.T(), os.WriteFile(script, []byte("#!/bin/sh\npwd\n"), 0o755)) + + require.NoError(s.T(), s.app.Rc("dotfiles")) + require.Contains(s.T(), s.stdout.String(), castleRoot) +} -- 2.49.1 From a381746cef8cba1f4759299f41288e91a5151433 Mon Sep 17 00:00:00 2001 From: Micheal Wilkinson Date: Fri, 20 Mar 2026 15:29:58 +0000 Subject: [PATCH 055/162] feat(rc): implement rc command with .homesick.d script execution - App.Rc runs all executable files in /.homesick.d in sorted (lexicographic) order with the castle root as cwd - Non-executable files are skipped - stdout/stderr from scripts forward to App writers - If .homesickrc exists and parity.rb does not yet exist in .homesick.d, a Ruby wrapper (parity.rb) is generated before execution - Existing parity.rb is never overwritten - Wire rcCmd in CLI with optional CASTLE argument (defaults to dotfiles) --- internal/homesick/cli/cli.go | 24 +++++----- internal/homesick/core/core.go | 76 +++++++++++++++++++++++++++++++ internal/homesick/core/rc_test.go | 31 ++++++++++--- 3 files changed, 114 insertions(+), 17 deletions(-) diff --git a/internal/homesick/cli/cli.go b/internal/homesick/cli/cli.go index 0cd516f..e2110ad 100644 --- a/internal/homesick/cli/cli.go +++ b/internal/homesick/cli/cli.go @@ -171,20 +171,22 @@ type execCmd struct{} type execAllCmd struct{} -type rcCmd struct{} +type rcCmd struct { + Castle string `arg:"" optional:"" name:"CASTLE" help:"Castle name."` +} type generateCmd struct{} -func (c *pullCmd) Run() error { return notImplemented("pull") } -func (c *pushCmd) Run() error { return notImplemented("push") } -func (c *commitCmd) Run() error { return notImplemented("commit") } -func (c *destroyCmd) Run() error { return notImplemented("destroy") } -func (c *cdCmd) Run() error { return notImplemented("cd") } -func (c *openCmd) Run() error { return notImplemented("open") } -func (c *execCmd) Run() error { return notImplemented("exec") } -func (c *execAllCmd) Run() error { return notImplemented("exec_all") } -func (c *rcCmd) Run() error { return notImplemented("rc") } -func (c *generateCmd) Run() error { return notImplemented("generate") } +func (c *pullCmd) Run() error { return notImplemented("pull") } +func (c *pushCmd) Run() error { return notImplemented("push") } +func (c *commitCmd) Run() error { return notImplemented("commit") } +func (c *destroyCmd) Run() error { return notImplemented("destroy") } +func (c *cdCmd) Run() error { return notImplemented("cd") } +func (c *openCmd) Run() error { return notImplemented("open") } +func (c *execCmd) Run() error { return notImplemented("exec") } +func (c *execAllCmd) Run() error { return notImplemented("exec_all") } +func (c *rcCmd) Run(app *core.App) error { return app.Rc(defaultCastle(c.Castle)) } +func (c *generateCmd) Run() error { return notImplemented("generate") } func defaultCastle(castle string) string { if strings.TrimSpace(castle) == "" { diff --git a/internal/homesick/core/core.go b/internal/homesick/core/core.go index 8c31e1d..1d11e65 100644 --- a/internal/homesick/core/core.go +++ b/internal/homesick/core/core.go @@ -531,6 +531,82 @@ func gitOutput(dir string, args ...string) (string, error) { return string(out), nil } +// Rc runs the rc hooks for the given castle. It looks for executable files +// inside /.homesick.d and runs them in sorted (lexicographic) order +// with the castle root as the working directory, forwarding stdout and stderr +// to the App writers. +// +// If a .homesickrc file exists in the castle root and no parity.rb wrapper +// already exists in .homesick.d, a Ruby wrapper script named parity.rb is +// written there before execution so that it sorts first. +func (a *App) Rc(castle string) error { + castleRoot := filepath.Join(a.ReposDir, castle) + if _, err := os.Stat(castleRoot); err != nil { + if errors.Is(err, os.ErrNotExist) { + return fmt.Errorf("castle %q not found", castle) + } + return err + } + + homesickD := filepath.Join(castleRoot, ".homesick.d") + homesickRc := filepath.Join(castleRoot, ".homesickrc") + + // If .homesickrc exists, ensure .homesick.d/parity.rb wrapper is created + // (but do not overwrite an existing parity.rb). + if _, err := os.Stat(homesickRc); err == nil { + wrapperPath := filepath.Join(homesickD, "parity.rb") + if _, err := os.Stat(wrapperPath); errors.Is(err, os.ErrNotExist) { + if mkErr := os.MkdirAll(homesickD, 0o755); mkErr != nil { + return fmt.Errorf("create .homesick.d: %w", mkErr) + } + wrapperContent := "#!/usr/bin/env ruby\n" + + "# parity.rb — generated wrapper for legacy .homesickrc\n" + + "# Evaluates .homesickrc in the context of the castle root.\n" + + "rc_file = File.join(__dir__, '..', '.homesickrc')\n" + + "eval(File.read(rc_file), binding, rc_file) if File.exist?(rc_file)\n" + if writeErr := os.WriteFile(wrapperPath, []byte(wrapperContent), 0o755); writeErr != nil { + return fmt.Errorf("write parity.rb: %w", writeErr) + } + } + } + + if _, err := os.Stat(homesickD); err != nil { + if errors.Is(err, os.ErrNotExist) { + return nil + } + return err + } + + entries, err := os.ReadDir(homesickD) + if err != nil { + return err + } + + // ReadDir returns entries in sorted order already. + for _, entry := range entries { + if entry.IsDir() { + continue + } + info, infoErr := entry.Info() + if infoErr != nil { + return infoErr + } + if info.Mode()&0o111 == 0 { + // Not executable — skip. + continue + } + scriptPath := filepath.Join(homesickD, entry.Name()) + cmd := exec.Command(scriptPath) + cmd.Dir = castleRoot + cmd.Stdout = a.Stdout + cmd.Stderr = a.Stderr + if runErr := cmd.Run(); runErr != nil { + return fmt.Errorf("rc script %q failed: %w", entry.Name(), runErr) + } + } + return nil +} + func deriveDestination(uri string) string { candidate := strings.TrimSpace(uri) candidate = strings.TrimPrefix(candidate, "https://github.com/") diff --git a/internal/homesick/core/rc_test.go b/internal/homesick/core/rc_test.go index 89bff37..b9db070 100644 --- a/internal/homesick/core/rc_test.go +++ b/internal/homesick/core/rc_test.go @@ -100,7 +100,7 @@ func (s *RcSuite) TestRc_SkipsNonExecutableFiles() { } // TestRc_HomesickrcCreatesRubyWrapper verifies that a .homesickrc file causes -// a Ruby wrapper to be written into .homesick.d before execution. +// a Ruby wrapper called parity.rb to be written into .homesick.d before execution. func (s *RcSuite) TestRc_HomesickrcCreatesRubyWrapper() { castleRoot := s.createCastle("dotfiles") homesickRc := filepath.Join(castleRoot, ".homesickrc") @@ -108,7 +108,7 @@ func (s *RcSuite) TestRc_HomesickrcCreatesRubyWrapper() { require.NoError(s.T(), s.app.Rc("dotfiles")) - wrapperPath := filepath.Join(castleRoot, ".homesick.d", "00_homesickrc.rb") + wrapperPath := filepath.Join(castleRoot, ".homesick.d", "parity.rb") require.FileExists(s.T(), wrapperPath) info, err := os.Stat(wrapperPath) @@ -120,9 +120,28 @@ func (s *RcSuite) TestRc_HomesickrcCreatesRubyWrapper() { require.Contains(s.T(), string(content), ".homesickrc") } -// TestRc_HomesickrcWrapperRunsBeforeOtherScripts ensures the wrapper file -// (00_homesickrc.rb) sorts before typical user scripts and is present in -// .homesick.d after Rc returns. +// TestRc_HomesickrcWrapperNotOverwrittenIfExists verifies that an existing +// parity.rb is not overwritten when Rc is called again. +func (s *RcSuite) TestRc_HomesickrcWrapperNotOverwrittenIfExists() { + castleRoot := s.createCastle("dotfiles") + homesickRc := filepath.Join(castleRoot, ".homesickrc") + require.NoError(s.T(), os.WriteFile(homesickRc, []byte("# ruby setup code\n"), 0o644)) + + homesickD := filepath.Join(castleRoot, ".homesick.d") + require.NoError(s.T(), os.MkdirAll(homesickD, 0o755)) + wrapperPath := filepath.Join(homesickD, "parity.rb") + originalContent := []byte("#!/bin/sh\n# pre-existing wrapper\n") + require.NoError(s.T(), os.WriteFile(wrapperPath, originalContent, 0o755)) + + require.NoError(s.T(), s.app.Rc("dotfiles")) + + content, err := os.ReadFile(wrapperPath) + require.NoError(s.T(), err) + require.Equal(s.T(), originalContent, content, "existing parity.rb must not be overwritten") +} + +// TestRc_HomesickrcWrapperCreatedBeforeExecution ensures parity.rb is present +// in .homesick.d before any scripts in that directory are executed. func (s *RcSuite) TestRc_HomesickrcWrapperCreatedBeforeExecution() { castleRoot := s.createCastle("dotfiles") homesickRc := filepath.Join(castleRoot, ".homesickrc") @@ -134,7 +153,7 @@ func (s *RcSuite) TestRc_HomesickrcWrapperCreatedBeforeExecution() { // A sentinel script that records whether the wrapper already exists. orderFile := filepath.Join(s.tmpDir, "check.txt") sentinel := filepath.Join(homesickD, "50_check.sh") - wrapperPath := filepath.Join(homesickD, "00_homesickrc.rb") + wrapperPath := filepath.Join(homesickD, "parity.rb") require.NoError(s.T(), os.WriteFile(sentinel, []byte( "#!/bin/sh\n[ -f "+wrapperPath+" ] && echo present >> "+orderFile+"\n", ), 0o755)) -- 2.49.1 From f3b1a7707adcad963c6456374e3dbe80cefd88e0 Mon Sep 17 00:00:00 2001 From: Micheal Wilkinson Date: Fri, 20 Mar 2026 15:30:13 +0000 Subject: [PATCH 056/162] docs(changelog): document rc command implementation --- changelog.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/changelog.md b/changelog.md index f4c565a..b6ed88a 100644 --- a/changelog.md +++ b/changelog.md @@ -13,6 +13,8 @@ A `### Breaking` section is used in addition to Keep a Changelog's standard sect ### Added +- `rc` command: executes all executable scripts inside a castle's `.homesick.d/` directory in sorted order, with the castle root as the working directory. stdout/stderr from each script is forwarded to the caller. +- `rc` command: when a `.homesickrc` file exists and no `parity.rb` wrapper is present in `.homesick.d/`, a Ruby wrapper script (`parity.rb`) is generated automatically to preserve backwards compatibility. An existing `parity.rb` is never overwritten. - Native Go implementations for `clone`, `link`, `unlink`, and `track`. - Containerized behavior test suite for command parity validation. - Dedicated test suites for `list`, `show_path`, `status`, `diff`, and `version`. -- 2.49.1 From 6719fb170b8387e63b9b9c947388b818061109ef Mon Sep 17 00:00:00 2001 From: Micheal Wilkinson Date: Fri, 20 Mar 2026 17:43:12 +0000 Subject: [PATCH 057/162] docs(readme): document rc implementation and parity checklist --- README.md | 30 +++++++++++++++++++++++++++++- 1 file changed, 29 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 17a5764..6e91ae2 100644 --- a/README.md +++ b/README.md @@ -35,8 +35,17 @@ Implemented commands: - `link [CASTLE]` - `unlink [CASTLE]` - `track FILE [CASTLE]` +- `rc [CASTLE]` - `version` +### rc behavior + +- Runs executable scripts in `/.homesick.d/` in lexicographic order. +- Executes scripts with the castle root as the current working directory. +- Forwards script stdout/stderr to command output. +- If `/.homesickrc` exists and `/.homesick.d/parity.rb` does not, generates `parity.rb` before execution. +- Never overwrites an existing `parity.rb` wrapper. + Not yet implemented: - `pull` @@ -47,9 +56,28 @@ Not yet implemented: - `open` - `exec` - `exec_all` -- `rc` - `generate` +## Outstanding Feature Checklist + +Command parity: + +- [ ] `pull` +- [ ] `push` +- [ ] `commit` +- [ ] `destroy` +- [ ] `cd` +- [ ] `open` +- [ ] `exec` +- [ ] `exec_all` +- [ ] `generate` + +Historical flag/behavior parity: + +- [ ] `pull --all` +- [ ] `rc --force` +- [ ] Evaluate whether global `pretend`/`quiet` modes should be restored + ## Behavior Suite The repository includes a Docker-based behavior suite that validates filesystem and git outcomes for the implemented commands. -- 2.49.1 From 4a422bd241fc9f99bb8c326d4a61deb58aca2292 Mon Sep 17 00:00:00 2001 From: Micheal Wilkinson Date: Fri, 20 Mar 2026 17:43:39 +0000 Subject: [PATCH 058/162] test(pull): add failing pull parity tests --- internal/homesick/core/pull_test.go | 114 ++++++++++++++++++++++++++++ 1 file changed, 114 insertions(+) create mode 100644 internal/homesick/core/pull_test.go diff --git a/internal/homesick/core/pull_test.go b/internal/homesick/core/pull_test.go new file mode 100644 index 0000000..6bc5824 --- /dev/null +++ b/internal/homesick/core/pull_test.go @@ -0,0 +1,114 @@ +package core_test + +import ( + "io" + "os" + "path/filepath" + "testing" + "time" + + "git.hrafn.xyz/aether/gosick/internal/homesick/core" + git "github.com/go-git/go-git/v5" + "github.com/go-git/go-git/v5/config" + "github.com/go-git/go-git/v5/plumbing/object" + "github.com/stretchr/testify/require" + "github.com/stretchr/testify/suite" +) + +type PullSuite struct { + suite.Suite + tmpDir string + homeDir string + reposDir string + app *core.App +} + +func TestPullSuite(t *testing.T) { + suite.Run(t, new(PullSuite)) +} + +func (s *PullSuite) 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 *PullSuite) createRemoteWithClone(castle string) (string, string) { + remotePath := filepath.Join(s.tmpDir, castle+".git") + _, err := git.PlainInit(remotePath, true) + require.NoError(s.T(), err) + + seedPath := filepath.Join(s.tmpDir, castle+"-seed") + seedRepo, err := git.PlainInit(seedPath, false) + require.NoError(s.T(), err) + + seedFile := filepath.Join(seedPath, "home", ".vimrc") + require.NoError(s.T(), os.MkdirAll(filepath.Dir(seedFile), 0o755)) + require.NoError(s.T(), os.WriteFile(seedFile, []byte("set number\n"), 0o644)) + + wt, err := seedRepo.Worktree() + require.NoError(s.T(), err) + _, err = wt.Add("home/.vimrc") + require.NoError(s.T(), err) + + _, err = wt.Commit("initial", &git.CommitOptions{Author: &object.Signature{ + Name: "Pull Test", + Email: "pull@test.local", + When: time.Now(), + }}) + require.NoError(s.T(), err) + + _, err = seedRepo.CreateRemote(&config.RemoteConfig{Name: "origin", URLs: []string{remotePath}}) + require.NoError(s.T(), err) + require.NoError(s.T(), seedRepo.Push(&git.PushOptions{RemoteName: "origin"})) + + clonePath := filepath.Join(s.reposDir, castle) + _, err = git.PlainClone(clonePath, false, &git.CloneOptions{URL: remotePath}) + require.NoError(s.T(), err) + + return remotePath, clonePath +} + +func (s *PullSuite) addRemoteCommit(remotePath string, castle string) { + workPath := filepath.Join(s.tmpDir, castle+"-work") + repo, err := git.PlainClone(workPath, false, &git.CloneOptions{URL: remotePath}) + require.NoError(s.T(), err) + + filePath := filepath.Join(workPath, "home", ".zshrc") + require.NoError(s.T(), os.MkdirAll(filepath.Dir(filePath), 0o755)) + require.NoError(s.T(), os.WriteFile(filePath, []byte("export EDITOR=vim\n"), 0o644)) + + wt, err := repo.Worktree() + require.NoError(s.T(), err) + _, err = wt.Add("home/.zshrc") + require.NoError(s.T(), err) + + _, err = wt.Commit("add zshrc", &git.CommitOptions{Author: &object.Signature{ + Name: "Pull Test", + Email: "pull@test.local", + When: time.Now(), + }}) + require.NoError(s.T(), err) + require.NoError(s.T(), repo.Push(&git.PushOptions{RemoteName: "origin"})) +} + +func (s *PullSuite) TestPull_UpdatesCastleFromOrigin() { + remotePath, clonePath := s.createRemoteWithClone("dotfiles") + s.addRemoteCommit(remotePath, "dotfiles") + + require.NoError(s.T(), s.app.Pull("dotfiles")) + require.FileExists(s.T(), filepath.Join(clonePath, "home", ".zshrc")) +} + +func (s *PullSuite) TestPull_MissingCastleReturnsError() { + err := s.app.Pull("missing") + require.Error(s.T(), err) +} -- 2.49.1 From 4fb028cd81926500b6cad19b23cc7ef2de13a287 Mon Sep 17 00:00:00 2001 From: Micheal Wilkinson Date: Fri, 20 Mar 2026 17:44:13 +0000 Subject: [PATCH 059/162] feat(pull): implement pull command parity --- internal/homesick/cli/cli.go | 24 +++++++++++++----------- internal/homesick/core/core.go | 7 +++++++ 2 files changed, 20 insertions(+), 11 deletions(-) diff --git a/internal/homesick/cli/cli.go b/internal/homesick/cli/cli.go index e2110ad..b0d4bc7 100644 --- a/internal/homesick/cli/cli.go +++ b/internal/homesick/cli/cli.go @@ -155,7 +155,9 @@ func (c *versionCmd) Run(app *core.App) error { return app.Version(version.String) } -type pullCmd struct{} +type pullCmd struct { + Castle string `arg:"" optional:"" name:"CASTLE" help:"Castle name."` +} type pushCmd struct{} @@ -177,16 +179,16 @@ type rcCmd struct { type generateCmd struct{} -func (c *pullCmd) Run() error { return notImplemented("pull") } -func (c *pushCmd) Run() error { return notImplemented("push") } -func (c *commitCmd) Run() error { return notImplemented("commit") } -func (c *destroyCmd) Run() error { return notImplemented("destroy") } -func (c *cdCmd) Run() error { return notImplemented("cd") } -func (c *openCmd) Run() error { return notImplemented("open") } -func (c *execCmd) Run() error { return notImplemented("exec") } -func (c *execAllCmd) Run() error { return notImplemented("exec_all") } -func (c *rcCmd) Run(app *core.App) error { return app.Rc(defaultCastle(c.Castle)) } -func (c *generateCmd) Run() error { return notImplemented("generate") } +func (c *pullCmd) Run(app *core.App) error { return app.Pull(defaultCastle(c.Castle)) } +func (c *pushCmd) Run() error { return notImplemented("push") } +func (c *commitCmd) Run() error { return notImplemented("commit") } +func (c *destroyCmd) Run() error { return notImplemented("destroy") } +func (c *cdCmd) Run() error { return notImplemented("cd") } +func (c *openCmd) Run() error { return notImplemented("open") } +func (c *execCmd) Run() error { return notImplemented("exec") } +func (c *execAllCmd) Run() error { return notImplemented("exec_all") } +func (c *rcCmd) Run(app *core.App) error { return app.Rc(defaultCastle(c.Castle)) } +func (c *generateCmd) Run() error { return notImplemented("generate") } func defaultCastle(castle string) string { if strings.TrimSpace(castle) == "" { diff --git a/internal/homesick/core/core.go b/internal/homesick/core/core.go index 1d11e65..549428a 100644 --- a/internal/homesick/core/core.go +++ b/internal/homesick/core/core.go @@ -133,6 +133,13 @@ func (a *App) Diff(castle string) error { return runGitWithIO(filepath.Join(a.ReposDir, castle), a.Stdout, a.Stderr, "diff") } +func (a *App) Pull(castle string) error { + if strings.TrimSpace(castle) == "" { + castle = "dotfiles" + } + return runGitWithIO(filepath.Join(a.ReposDir, castle), a.Stdout, a.Stderr, "pull") +} + func (a *App) Link(castle string) error { if strings.TrimSpace(castle) == "" { castle = "dotfiles" -- 2.49.1 From 4a2f0ff0b81d59d5d7cf864f1a0dfe7ef53786a5 Mon Sep 17 00:00:00 2001 From: Micheal Wilkinson Date: Fri, 20 Mar 2026 17:44:35 +0000 Subject: [PATCH 060/162] docs: update parity checklist and changelog for pull --- README.md | 4 ++-- changelog.md | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 6e91ae2..60f3664 100644 --- a/README.md +++ b/README.md @@ -35,6 +35,7 @@ Implemented commands: - `link [CASTLE]` - `unlink [CASTLE]` - `track FILE [CASTLE]` +- `pull [CASTLE]` - `rc [CASTLE]` - `version` @@ -48,7 +49,6 @@ Implemented commands: Not yet implemented: -- `pull` - `push` - `commit` - `destroy` @@ -62,7 +62,7 @@ Not yet implemented: Command parity: -- [ ] `pull` +- [x] `pull` - [ ] `push` - [ ] `commit` - [ ] `destroy` diff --git a/changelog.md b/changelog.md index b6ed88a..d204f35 100644 --- a/changelog.md +++ b/changelog.md @@ -15,7 +15,7 @@ A `### Breaking` section is used in addition to Keep a Changelog's standard sect - `rc` command: executes all executable scripts inside a castle's `.homesick.d/` directory in sorted order, with the castle root as the working directory. stdout/stderr from each script is forwarded to the caller. - `rc` command: when a `.homesickrc` file exists and no `parity.rb` wrapper is present in `.homesick.d/`, a Ruby wrapper script (`parity.rb`) is generated automatically to preserve backwards compatibility. An existing `parity.rb` is never overwritten. -- Native Go implementations for `clone`, `link`, `unlink`, and `track`. +- Native Go implementations for `clone`, `link`, `unlink`, `track`, and `pull`. - Containerized behavior test suite for command parity validation. - Dedicated test suites for `list`, `show_path`, `status`, `diff`, and `version`. - Just workflow support for building and running the Linux behavior binary. -- 2.49.1 From e60000680b54273e1933693e8e4d78a2bffba1e6 Mon Sep 17 00:00:00 2001 From: Micheal Wilkinson Date: Fri, 20 Mar 2026 17:45:41 +0000 Subject: [PATCH 061/162] test(push): add failing push parity tests --- internal/homesick/core/push_test.go | 116 ++++++++++++++++++++++++++++ 1 file changed, 116 insertions(+) create mode 100644 internal/homesick/core/push_test.go diff --git a/internal/homesick/core/push_test.go b/internal/homesick/core/push_test.go new file mode 100644 index 0000000..c003ce5 --- /dev/null +++ b/internal/homesick/core/push_test.go @@ -0,0 +1,116 @@ +package core_test + +import ( + "io" + "os" + "path/filepath" + "testing" + "time" + + "git.hrafn.xyz/aether/gosick/internal/homesick/core" + git "github.com/go-git/go-git/v5" + "github.com/go-git/go-git/v5/config" + "github.com/go-git/go-git/v5/plumbing/object" + "github.com/stretchr/testify/require" + "github.com/stretchr/testify/suite" +) + +type PushSuite struct { + suite.Suite + tmpDir string + homeDir string + reposDir string + app *core.App +} + +func TestPushSuite(t *testing.T) { + suite.Run(t, new(PushSuite)) +} + +func (s *PushSuite) 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 *PushSuite) createRemoteAndClone(castle string) (string, string) { + remotePath := filepath.Join(s.tmpDir, castle+".git") + _, err := git.PlainInit(remotePath, true) + require.NoError(s.T(), err) + + seedPath := filepath.Join(s.tmpDir, castle+"-seed") + seedRepo, err := git.PlainInit(seedPath, false) + require.NoError(s.T(), err) + + seedFile := filepath.Join(seedPath, "home", ".vimrc") + require.NoError(s.T(), os.MkdirAll(filepath.Dir(seedFile), 0o755)) + require.NoError(s.T(), os.WriteFile(seedFile, []byte("set number\n"), 0o644)) + + wt, err := seedRepo.Worktree() + require.NoError(s.T(), err) + _, err = wt.Add("home/.vimrc") + require.NoError(s.T(), err) + + _, err = wt.Commit("initial", &git.CommitOptions{Author: &object.Signature{ + Name: "Push Test", + Email: "push@test.local", + When: time.Now(), + }}) + require.NoError(s.T(), err) + + _, err = seedRepo.CreateRemote(&config.RemoteConfig{Name: "origin", URLs: []string{remotePath}}) + require.NoError(s.T(), err) + require.NoError(s.T(), seedRepo.Push(&git.PushOptions{RemoteName: "origin"})) + + clonePath := filepath.Join(s.reposDir, castle) + _, err = git.PlainClone(clonePath, false, &git.CloneOptions{URL: remotePath}) + require.NoError(s.T(), err) + + return remotePath, clonePath +} + +func (s *PushSuite) createLocalCommit(clonePath string) { + repo, err := git.PlainOpen(clonePath) + require.NoError(s.T(), err) + + localFile := filepath.Join(clonePath, "home", ".zshrc") + require.NoError(s.T(), os.MkdirAll(filepath.Dir(localFile), 0o755)) + require.NoError(s.T(), os.WriteFile(localFile, []byte("export EDITOR=vim\n"), 0o644)) + + wt, err := repo.Worktree() + require.NoError(s.T(), err) + _, err = wt.Add("home/.zshrc") + require.NoError(s.T(), err) + + _, err = wt.Commit("add zshrc", &git.CommitOptions{Author: &object.Signature{ + Name: "Push Test", + Email: "push@test.local", + When: time.Now(), + }}) + require.NoError(s.T(), err) +} + +func (s *PushSuite) TestPush_UpdatesRemoteFromLocalChanges() { + remotePath, clonePath := s.createRemoteAndClone("dotfiles") + s.createLocalCommit(clonePath) + + require.NoError(s.T(), s.app.Push("dotfiles")) + + verifyPath := filepath.Join(s.tmpDir, "dotfiles-verify") + _, err := git.PlainClone(verifyPath, false, &git.CloneOptions{URL: remotePath}) + require.NoError(s.T(), err) + require.FileExists(s.T(), filepath.Join(verifyPath, "home", ".zshrc")) +} + +func (s *PushSuite) TestPush_MissingCastleReturnsError() { + err := s.app.Push("missing") + require.Error(s.T(), err) +} -- 2.49.1 From 8a451cbaeec63565b7e2c7e60207f24b71100449 Mon Sep 17 00:00:00 2001 From: Micheal Wilkinson Date: Fri, 20 Mar 2026 17:46:07 +0000 Subject: [PATCH 062/162] feat(push): implement push command parity --- internal/homesick/cli/cli.go | 6 ++++-- internal/homesick/core/core.go | 7 +++++++ 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/internal/homesick/cli/cli.go b/internal/homesick/cli/cli.go index b0d4bc7..701e508 100644 --- a/internal/homesick/cli/cli.go +++ b/internal/homesick/cli/cli.go @@ -159,7 +159,9 @@ type pullCmd struct { Castle string `arg:"" optional:"" name:"CASTLE" help:"Castle name."` } -type pushCmd struct{} +type pushCmd struct { + Castle string `arg:"" optional:"" name:"CASTLE" help:"Castle name."` +} type commitCmd struct{} @@ -180,7 +182,7 @@ type rcCmd struct { type generateCmd struct{} func (c *pullCmd) Run(app *core.App) error { return app.Pull(defaultCastle(c.Castle)) } -func (c *pushCmd) Run() error { return notImplemented("push") } +func (c *pushCmd) Run(app *core.App) error { return app.Push(defaultCastle(c.Castle)) } func (c *commitCmd) Run() error { return notImplemented("commit") } func (c *destroyCmd) Run() error { return notImplemented("destroy") } func (c *cdCmd) Run() error { return notImplemented("cd") } diff --git a/internal/homesick/core/core.go b/internal/homesick/core/core.go index 549428a..bd900f9 100644 --- a/internal/homesick/core/core.go +++ b/internal/homesick/core/core.go @@ -140,6 +140,13 @@ func (a *App) Pull(castle string) error { return runGitWithIO(filepath.Join(a.ReposDir, castle), a.Stdout, a.Stderr, "pull") } +func (a *App) Push(castle string) error { + if strings.TrimSpace(castle) == "" { + castle = "dotfiles" + } + return runGitWithIO(filepath.Join(a.ReposDir, castle), a.Stdout, a.Stderr, "push") +} + func (a *App) Link(castle string) error { if strings.TrimSpace(castle) == "" { castle = "dotfiles" -- 2.49.1 From f0dc55159b4f6ecf768de42208533288f3f93928 Mon Sep 17 00:00:00 2001 From: Micheal Wilkinson Date: Fri, 20 Mar 2026 17:46:17 +0000 Subject: [PATCH 063/162] docs: mark push parity complete --- README.md | 4 ++-- changelog.md | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 60f3664..ff950a1 100644 --- a/README.md +++ b/README.md @@ -36,6 +36,7 @@ Implemented commands: - `unlink [CASTLE]` - `track FILE [CASTLE]` - `pull [CASTLE]` +- `push [CASTLE]` - `rc [CASTLE]` - `version` @@ -49,7 +50,6 @@ Implemented commands: Not yet implemented: -- `push` - `commit` - `destroy` - `cd` @@ -63,7 +63,7 @@ Not yet implemented: Command parity: - [x] `pull` -- [ ] `push` +- [x] `push` - [ ] `commit` - [ ] `destroy` - [ ] `cd` diff --git a/changelog.md b/changelog.md index d204f35..f06ab7a 100644 --- a/changelog.md +++ b/changelog.md @@ -15,7 +15,7 @@ A `### Breaking` section is used in addition to Keep a Changelog's standard sect - `rc` command: executes all executable scripts inside a castle's `.homesick.d/` directory in sorted order, with the castle root as the working directory. stdout/stderr from each script is forwarded to the caller. - `rc` command: when a `.homesickrc` file exists and no `parity.rb` wrapper is present in `.homesick.d/`, a Ruby wrapper script (`parity.rb`) is generated automatically to preserve backwards compatibility. An existing `parity.rb` is never overwritten. -- Native Go implementations for `clone`, `link`, `unlink`, `track`, and `pull`. +- Native Go implementations for `clone`, `link`, `unlink`, `track`, `pull`, and `push`. - Containerized behavior test suite for command parity validation. - Dedicated test suites for `list`, `show_path`, `status`, `diff`, and `version`. - Just workflow support for building and running the Linux behavior binary. -- 2.49.1 From eeeb9f7d8e62298537cdd93e20e8475ffa6b28e6 Mon Sep 17 00:00:00 2001 From: Micheal Wilkinson Date: Fri, 20 Mar 2026 17:47:33 +0000 Subject: [PATCH 064/162] test(commit): add failing commit parity tests --- internal/homesick/core/commit_test.go | 112 ++++++++++++++++++++++++++ 1 file changed, 112 insertions(+) create mode 100644 internal/homesick/core/commit_test.go diff --git a/internal/homesick/core/commit_test.go b/internal/homesick/core/commit_test.go new file mode 100644 index 0000000..20064e5 --- /dev/null +++ b/internal/homesick/core/commit_test.go @@ -0,0 +1,112 @@ +package core_test + +import ( + "io" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + "time" + + "git.hrafn.xyz/aether/gosick/internal/homesick/core" + git "github.com/go-git/go-git/v5" + "github.com/go-git/go-git/v5/plumbing/object" + "github.com/stretchr/testify/require" + "github.com/stretchr/testify/suite" +) + +type CommitSuite struct { + suite.Suite + tmpDir string + homeDir string + reposDir string + app *core.App +} + +func TestCommitSuite(t *testing.T) { + suite.Run(t, new(CommitSuite)) +} + +func (s *CommitSuite) 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 *CommitSuite) createCastleRepo(castle string) string { + castleRoot := filepath.Join(s.reposDir, castle) + repo, err := git.PlainInit(castleRoot, false) + require.NoError(s.T(), err) + + filePath := filepath.Join(castleRoot, "home", ".vimrc") + require.NoError(s.T(), os.MkdirAll(filepath.Dir(filePath), 0o755)) + require.NoError(s.T(), os.WriteFile(filePath, []byte("set number\n"), 0o644)) + + wt, err := repo.Worktree() + require.NoError(s.T(), err) + _, err = wt.Add("home/.vimrc") + require.NoError(s.T(), err) + _, err = wt.Commit("initial", &git.CommitOptions{Author: &object.Signature{ + Name: "Commit Test", + Email: "commit@test.local", + When: time.Now(), + }}) + require.NoError(s.T(), err) + + return castleRoot +} + +func gitOutputAt(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 (s *CommitSuite) TestCommit_CreatesCommitWithMessage() { + castleRoot := s.createCastleRepo("dotfiles") + target := filepath.Join(castleRoot, "home", ".vimrc") + require.NoError(s.T(), os.WriteFile(target, []byte("set number\nsyntax on\n"), 0o644)) + + require.NoError(s.T(), s.app.Commit("dotfiles", "update vimrc")) + + subject, err := gitOutputAt(castleRoot, "log", "-1", "--pretty=%s") + require.NoError(s.T(), err) + require.Equal(s.T(), "update vimrc\n", subject) +} + +func (s *CommitSuite) TestCommit_MessageEscaping() { + castleRoot := s.createCastleRepo("dotfiles") + target := filepath.Join(castleRoot, "home", ".vimrc") + require.NoError(s.T(), os.WriteFile(target, []byte("set number\nset relativenumber\n"), 0o644)) + + msg := "fix \"quoted\" message: keep spaces" + require.NoError(s.T(), s.app.Commit("dotfiles", msg)) + + subject, err := gitOutputAt(castleRoot, "log", "-1", "--pretty=%s") + require.NoError(s.T(), err) + require.Equal(s.T(), msg+"\n", subject) +} + +func (s *CommitSuite) TestCommit_RequiresMessage() { + err := s.app.Commit("dotfiles", " ") + require.Error(s.T(), err) + require.Contains(s.T(), strings.ToLower(err.Error()), "message") +} + +func (s *CommitSuite) TestCommit_MissingCastleReturnsError() { + err := s.app.Commit("missing", "msg") + require.Error(s.T(), err) +} -- 2.49.1 From d8eaf4d0589b8e69e5a58dfefb634b74e6248ef7 Mon Sep 17 00:00:00 2001 From: Micheal Wilkinson Date: Fri, 20 Mar 2026 17:48:04 +0000 Subject: [PATCH 065/162] feat(commit): implement commit command parity --- internal/homesick/cli/cli.go | 23 ++++++++++++++--------- internal/homesick/core/core.go | 18 ++++++++++++++++++ 2 files changed, 32 insertions(+), 9 deletions(-) diff --git a/internal/homesick/cli/cli.go b/internal/homesick/cli/cli.go index 701e508..4e90d75 100644 --- a/internal/homesick/cli/cli.go +++ b/internal/homesick/cli/cli.go @@ -163,7 +163,10 @@ type pushCmd struct { Castle string `arg:"" optional:"" name:"CASTLE" help:"Castle name."` } -type commitCmd struct{} +type commitCmd struct { + Message string `short:"m" required:"" name:"MESSAGE" help:"Commit message."` + Castle string `arg:"" optional:"" name:"CASTLE" help:"Castle name."` +} type destroyCmd struct{} @@ -183,14 +186,16 @@ type generateCmd struct{} func (c *pullCmd) Run(app *core.App) error { return app.Pull(defaultCastle(c.Castle)) } func (c *pushCmd) Run(app *core.App) error { return app.Push(defaultCastle(c.Castle)) } -func (c *commitCmd) Run() error { return notImplemented("commit") } -func (c *destroyCmd) Run() error { return notImplemented("destroy") } -func (c *cdCmd) Run() error { return notImplemented("cd") } -func (c *openCmd) Run() error { return notImplemented("open") } -func (c *execCmd) Run() error { return notImplemented("exec") } -func (c *execAllCmd) Run() error { return notImplemented("exec_all") } -func (c *rcCmd) Run(app *core.App) error { return app.Rc(defaultCastle(c.Castle)) } -func (c *generateCmd) Run() error { return notImplemented("generate") } +func (c *commitCmd) Run(app *core.App) error { + return app.Commit(defaultCastle(c.Castle), c.Message) +} +func (c *destroyCmd) Run() error { return notImplemented("destroy") } +func (c *cdCmd) Run() error { return notImplemented("cd") } +func (c *openCmd) Run() error { return notImplemented("open") } +func (c *execCmd) Run() error { return notImplemented("exec") } +func (c *execAllCmd) Run() error { return notImplemented("exec_all") } +func (c *rcCmd) Run(app *core.App) error { return app.Rc(defaultCastle(c.Castle)) } +func (c *generateCmd) Run() error { return notImplemented("generate") } func defaultCastle(castle string) string { if strings.TrimSpace(castle) == "" { diff --git a/internal/homesick/core/core.go b/internal/homesick/core/core.go index bd900f9..8b7a282 100644 --- a/internal/homesick/core/core.go +++ b/internal/homesick/core/core.go @@ -147,6 +147,24 @@ func (a *App) Push(castle string) error { return runGitWithIO(filepath.Join(a.ReposDir, castle), a.Stdout, a.Stderr, "push") } +func (a *App) Commit(castle string, message string) error { + if strings.TrimSpace(castle) == "" { + castle = "dotfiles" + } + + trimmedMessage := strings.TrimSpace(message) + if trimmedMessage == "" { + return errors.New("commit requires message") + } + + castledir := filepath.Join(a.ReposDir, castle) + if err := runGitWithIO(castledir, a.Stdout, a.Stderr, "add", "--all"); err != nil { + return err + } + + return runGitWithIO(castledir, a.Stdout, a.Stderr, "commit", "-m", trimmedMessage) +} + func (a *App) Link(castle string) error { if strings.TrimSpace(castle) == "" { castle = "dotfiles" -- 2.49.1 From f186286a7e051492bcf9b5bc3b129e5487211033 Mon Sep 17 00:00:00 2001 From: Micheal Wilkinson Date: Fri, 20 Mar 2026 17:48:16 +0000 Subject: [PATCH 066/162] docs: mark commit parity complete --- README.md | 4 ++-- changelog.md | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index ff950a1..0047d48 100644 --- a/README.md +++ b/README.md @@ -37,6 +37,7 @@ Implemented commands: - `track FILE [CASTLE]` - `pull [CASTLE]` - `push [CASTLE]` +- `commit -m MESSAGE [CASTLE]` - `rc [CASTLE]` - `version` @@ -50,7 +51,6 @@ Implemented commands: Not yet implemented: -- `commit` - `destroy` - `cd` - `open` @@ -64,7 +64,7 @@ Command parity: - [x] `pull` - [x] `push` -- [ ] `commit` +- [x] `commit` - [ ] `destroy` - [ ] `cd` - [ ] `open` diff --git a/changelog.md b/changelog.md index f06ab7a..b61eef2 100644 --- a/changelog.md +++ b/changelog.md @@ -15,7 +15,7 @@ A `### Breaking` section is used in addition to Keep a Changelog's standard sect - `rc` command: executes all executable scripts inside a castle's `.homesick.d/` directory in sorted order, with the castle root as the working directory. stdout/stderr from each script is forwarded to the caller. - `rc` command: when a `.homesickrc` file exists and no `parity.rb` wrapper is present in `.homesick.d/`, a Ruby wrapper script (`parity.rb`) is generated automatically to preserve backwards compatibility. An existing `parity.rb` is never overwritten. -- Native Go implementations for `clone`, `link`, `unlink`, `track`, `pull`, and `push`. +- Native Go implementations for `clone`, `link`, `unlink`, `track`, `pull`, `push`, and `commit`. - Containerized behavior test suite for command parity validation. - Dedicated test suites for `list`, `show_path`, `status`, `diff`, and `version`. - Just workflow support for building and running the Linux behavior binary. -- 2.49.1 From 4901f7b6646c55df99a0753d13caf1f862b707f8 Mon Sep 17 00:00:00 2001 From: Micheal Wilkinson Date: Fri, 20 Mar 2026 17:49:26 +0000 Subject: [PATCH 067/162] test(destroy): add failing destroy parity tests --- internal/homesick/core/destroy_test.go | 91 ++++++++++++++++++++++++++ 1 file changed, 91 insertions(+) create mode 100644 internal/homesick/core/destroy_test.go diff --git a/internal/homesick/core/destroy_test.go b/internal/homesick/core/destroy_test.go new file mode 100644 index 0000000..fe4400b --- /dev/null +++ b/internal/homesick/core/destroy_test.go @@ -0,0 +1,91 @@ +package core_test + +import ( + "io" + "os" + "path/filepath" + "testing" + + "git.hrafn.xyz/aether/gosick/internal/homesick/core" + git "github.com/go-git/go-git/v5" + "github.com/stretchr/testify/require" + "github.com/stretchr/testify/suite" +) + +type DestroySuite struct { + suite.Suite + tmpDir string + homeDir string + reposDir string + app *core.App +} + +func TestDestroySuite(t *testing.T) { + suite.Run(t, new(DestroySuite)) +} + +func (s *DestroySuite) 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 *DestroySuite) createCastleRepo(castle string) string { + castleRoot := filepath.Join(s.reposDir, castle) + require.NoError(s.T(), os.MkdirAll(filepath.Join(castleRoot, "home"), 0o755)) + _, err := git.PlainInit(castleRoot, false) + require.NoError(s.T(), err) + return castleRoot +} + +func (s *DestroySuite) TestDestroy_RemovesCastleDirectory() { + castleRoot := s.createCastleRepo("dotfiles") + require.DirExists(s.T(), castleRoot) + + require.NoError(s.T(), s.app.Destroy("dotfiles")) + require.NoDirExists(s.T(), castleRoot) +} + +func (s *DestroySuite) TestDestroy_MissingCastleReturnsError() { + err := s.app.Destroy("missing") + require.Error(s.T(), err) +} + +func (s *DestroySuite) TestDestroy_UnlinksDotfilesBeforeRemoval() { + castleRoot := s.createCastleRepo("dotfiles") + tracked := filepath.Join(castleRoot, "home", ".vimrc") + require.NoError(s.T(), os.WriteFile(tracked, []byte("set number\n"), 0o644)) + + require.NoError(s.T(), s.app.LinkCastle("dotfiles")) + homePath := filepath.Join(s.homeDir, ".vimrc") + info, err := os.Lstat(homePath) + require.NoError(s.T(), err) + require.NotZero(s.T(), info.Mode()&os.ModeSymlink) + + require.NoError(s.T(), s.app.Destroy("dotfiles")) + + _, err = os.Lstat(homePath) + require.Error(s.T(), err) + require.True(s.T(), os.IsNotExist(err)) + require.NoDirExists(s.T(), castleRoot) +} + +func (s *DestroySuite) TestDestroy_RemovesSymlinkedCastleOnly() { + target := filepath.Join(s.tmpDir, "local-castle") + require.NoError(s.T(), os.MkdirAll(target, 0o755)) + + symlinkCastle := filepath.Join(s.reposDir, "dotfiles") + require.NoError(s.T(), os.Symlink(target, symlinkCastle)) + + require.NoError(s.T(), s.app.Destroy("dotfiles")) + require.NoFileExists(s.T(), symlinkCastle) + require.DirExists(s.T(), target) +} -- 2.49.1 From 88b07ea934c9e2c1fa20dfd3e72ea93bd0059f32 Mon Sep 17 00:00:00 2001 From: Micheal Wilkinson Date: Fri, 20 Mar 2026 17:49:55 +0000 Subject: [PATCH 068/162] feat(destroy): implement destroy command parity --- internal/homesick/cli/cli.go | 18 ++++++++++-------- internal/homesick/core/core.go | 27 +++++++++++++++++++++++++++ 2 files changed, 37 insertions(+), 8 deletions(-) diff --git a/internal/homesick/cli/cli.go b/internal/homesick/cli/cli.go index 4e90d75..de5a062 100644 --- a/internal/homesick/cli/cli.go +++ b/internal/homesick/cli/cli.go @@ -168,7 +168,9 @@ type commitCmd struct { Castle string `arg:"" optional:"" name:"CASTLE" help:"Castle name."` } -type destroyCmd struct{} +type destroyCmd struct { + Castle string `arg:"" optional:"" name:"CASTLE" help:"Castle name."` +} type cdCmd struct{} @@ -189,13 +191,13 @@ func (c *pushCmd) Run(app *core.App) error { return app.Push(defaultCastle(c.Cas func (c *commitCmd) Run(app *core.App) error { return app.Commit(defaultCastle(c.Castle), c.Message) } -func (c *destroyCmd) Run() error { return notImplemented("destroy") } -func (c *cdCmd) Run() error { return notImplemented("cd") } -func (c *openCmd) Run() error { return notImplemented("open") } -func (c *execCmd) Run() error { return notImplemented("exec") } -func (c *execAllCmd) Run() error { return notImplemented("exec_all") } -func (c *rcCmd) Run(app *core.App) error { return app.Rc(defaultCastle(c.Castle)) } -func (c *generateCmd) Run() error { return notImplemented("generate") } +func (c *destroyCmd) Run(app *core.App) error { return app.Destroy(defaultCastle(c.Castle)) } +func (c *cdCmd) Run() error { return notImplemented("cd") } +func (c *openCmd) Run() error { return notImplemented("open") } +func (c *execCmd) Run() error { return notImplemented("exec") } +func (c *execAllCmd) Run() error { return notImplemented("exec_all") } +func (c *rcCmd) Run(app *core.App) error { return app.Rc(defaultCastle(c.Castle)) } +func (c *generateCmd) Run() error { return notImplemented("generate") } func defaultCastle(castle string) string { if strings.TrimSpace(castle) == "" { diff --git a/internal/homesick/core/core.go b/internal/homesick/core/core.go index 8b7a282..a16cd6e 100644 --- a/internal/homesick/core/core.go +++ b/internal/homesick/core/core.go @@ -165,6 +165,33 @@ func (a *App) Commit(castle string, message string) error { return runGitWithIO(castledir, a.Stdout, a.Stderr, "commit", "-m", trimmedMessage) } +func (a *App) Destroy(castle string) error { + if strings.TrimSpace(castle) == "" { + castle = "dotfiles" + } + + castleRoot := filepath.Join(a.ReposDir, castle) + castleInfo, err := os.Lstat(castleRoot) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return fmt.Errorf("castle %q not found", castle) + } + return err + } + + // Only attempt unlinking managed home files for regular castle directories. + if castleInfo.Mode()&os.ModeSymlink == 0 { + castleHome := filepath.Join(castleRoot, "home") + if info, statErr := os.Stat(castleHome); statErr == nil && info.IsDir() { + if unlinkErr := a.UnlinkCastle(castle); unlinkErr != nil { + return unlinkErr + } + } + } + + return os.RemoveAll(castleRoot) +} + func (a *App) Link(castle string) error { if strings.TrimSpace(castle) == "" { castle = "dotfiles" -- 2.49.1 From 82dde43f24641f264f90dd65b156d1121d005f26 Mon Sep 17 00:00:00 2001 From: Micheal Wilkinson Date: Fri, 20 Mar 2026 17:50:06 +0000 Subject: [PATCH 069/162] docs: mark destroy parity complete --- README.md | 4 ++-- changelog.md | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 0047d48..ba141b5 100644 --- a/README.md +++ b/README.md @@ -38,6 +38,7 @@ Implemented commands: - `pull [CASTLE]` - `push [CASTLE]` - `commit -m MESSAGE [CASTLE]` +- `destroy [CASTLE]` - `rc [CASTLE]` - `version` @@ -51,7 +52,6 @@ Implemented commands: Not yet implemented: -- `destroy` - `cd` - `open` - `exec` @@ -65,7 +65,7 @@ Command parity: - [x] `pull` - [x] `push` - [x] `commit` -- [ ] `destroy` +- [x] `destroy` - [ ] `cd` - [ ] `open` - [ ] `exec` diff --git a/changelog.md b/changelog.md index b61eef2..bd9dba5 100644 --- a/changelog.md +++ b/changelog.md @@ -15,7 +15,7 @@ A `### Breaking` section is used in addition to Keep a Changelog's standard sect - `rc` command: executes all executable scripts inside a castle's `.homesick.d/` directory in sorted order, with the castle root as the working directory. stdout/stderr from each script is forwarded to the caller. - `rc` command: when a `.homesickrc` file exists and no `parity.rb` wrapper is present in `.homesick.d/`, a Ruby wrapper script (`parity.rb`) is generated automatically to preserve backwards compatibility. An existing `parity.rb` is never overwritten. -- Native Go implementations for `clone`, `link`, `unlink`, `track`, `pull`, `push`, and `commit`. +- Native Go implementations for `clone`, `link`, `unlink`, `track`, `pull`, `push`, `commit`, and `destroy`. - Containerized behavior test suite for command parity validation. - Dedicated test suites for `list`, `show_path`, `status`, `diff`, and `version`. - Just workflow support for building and running the Linux behavior binary. -- 2.49.1 From 7f46ab43ac965324663c8fe15238e2e86695a95a Mon Sep 17 00:00:00 2001 From: Micheal Wilkinson Date: Fri, 20 Mar 2026 17:50:52 +0000 Subject: [PATCH 070/162] test(cd): add failing CLI parity tests --- internal/homesick/cli/cli_test.go | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/internal/homesick/cli/cli_test.go b/internal/homesick/cli/cli_test.go index 3fa5d51..20df130 100644 --- a/internal/homesick/cli/cli_test.go +++ b/internal/homesick/cli/cli_test.go @@ -52,6 +52,22 @@ func (s *CLISuite) TestRun_ShowPath_DefaultCastle() { require.Empty(s.T(), s.stderr.String()) } +func (s *CLISuite) TestRun_Cd_DefaultCastle() { + exitCode := cli.Run([]string{"cd"}, s.stdout, s.stderr) + + require.Equal(s.T(), 0, exitCode) + require.Equal(s.T(), filepath.Join(s.homeDir, ".homesick", "repos", "dotfiles")+"\n", s.stdout.String()) + require.Empty(s.T(), s.stderr.String()) +} + +func (s *CLISuite) TestRun_Cd_ExplicitCastle() { + exitCode := cli.Run([]string{"cd", "work"}, s.stdout, s.stderr) + + require.Equal(s.T(), 0, exitCode) + require.Equal(s.T(), filepath.Join(s.homeDir, ".homesick", "repos", "work")+"\n", s.stdout.String()) + require.Empty(s.T(), s.stderr.String()) +} + func (s *CLISuite) TestRun_CloneSubcommandHelp() { exitCode := cli.Run([]string{"clone", "--help"}, s.stdout, s.stderr) -- 2.49.1 From 5fe37a7f129bb1245ea5f26fc16e168e6659cd5b Mon Sep 17 00:00:00 2001 From: Micheal Wilkinson Date: Fri, 20 Mar 2026 17:51:09 +0000 Subject: [PATCH 071/162] feat(cd): implement cd command parity --- internal/homesick/cli/cli.go | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/internal/homesick/cli/cli.go b/internal/homesick/cli/cli.go index de5a062..d18dd41 100644 --- a/internal/homesick/cli/cli.go +++ b/internal/homesick/cli/cli.go @@ -172,7 +172,9 @@ type destroyCmd struct { Castle string `arg:"" optional:"" name:"CASTLE" help:"Castle name."` } -type cdCmd struct{} +type cdCmd struct { + Castle string `arg:"" optional:"" name:"CASTLE" help:"Castle name."` +} type openCmd struct{} @@ -192,7 +194,7 @@ func (c *commitCmd) Run(app *core.App) error { return app.Commit(defaultCastle(c.Castle), c.Message) } func (c *destroyCmd) Run(app *core.App) error { return app.Destroy(defaultCastle(c.Castle)) } -func (c *cdCmd) Run() error { return notImplemented("cd") } +func (c *cdCmd) Run(app *core.App) error { return app.ShowPath(defaultCastle(c.Castle)) } func (c *openCmd) Run() error { return notImplemented("open") } func (c *execCmd) Run() error { return notImplemented("exec") } func (c *execAllCmd) Run() error { return notImplemented("exec_all") } -- 2.49.1 From c36cae2e33d2e5b14b888a1561545d4ed5b4aac3 Mon Sep 17 00:00:00 2001 From: Micheal Wilkinson Date: Fri, 20 Mar 2026 17:51:16 +0000 Subject: [PATCH 072/162] docs: mark cd parity complete --- README.md | 4 ++-- changelog.md | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index ba141b5..34ccb56 100644 --- a/README.md +++ b/README.md @@ -39,6 +39,7 @@ Implemented commands: - `push [CASTLE]` - `commit -m MESSAGE [CASTLE]` - `destroy [CASTLE]` +- `cd [CASTLE]` - `rc [CASTLE]` - `version` @@ -52,7 +53,6 @@ Implemented commands: Not yet implemented: -- `cd` - `open` - `exec` - `exec_all` @@ -66,7 +66,7 @@ Command parity: - [x] `push` - [x] `commit` - [x] `destroy` -- [ ] `cd` +- [x] `cd` - [ ] `open` - [ ] `exec` - [ ] `exec_all` diff --git a/changelog.md b/changelog.md index bd9dba5..d410e49 100644 --- a/changelog.md +++ b/changelog.md @@ -15,7 +15,7 @@ A `### Breaking` section is used in addition to Keep a Changelog's standard sect - `rc` command: executes all executable scripts inside a castle's `.homesick.d/` directory in sorted order, with the castle root as the working directory. stdout/stderr from each script is forwarded to the caller. - `rc` command: when a `.homesickrc` file exists and no `parity.rb` wrapper is present in `.homesick.d/`, a Ruby wrapper script (`parity.rb`) is generated automatically to preserve backwards compatibility. An existing `parity.rb` is never overwritten. -- Native Go implementations for `clone`, `link`, `unlink`, `track`, `pull`, `push`, `commit`, and `destroy`. +- Native Go implementations for `clone`, `link`, `unlink`, `track`, `pull`, `push`, `commit`, `destroy`, and `cd`. - Containerized behavior test suite for command parity validation. - Dedicated test suites for `list`, `show_path`, `status`, `diff`, and `version`. - Just workflow support for building and running the Linux behavior binary. -- 2.49.1 From 043b859a420095bcb103097993a0712d6275ada9 Mon Sep 17 00:00:00 2001 From: Micheal Wilkinson Date: Fri, 20 Mar 2026 17:52:58 +0000 Subject: [PATCH 073/162] test(open,generate): add failing parity tests --- internal/homesick/core/generate_test.go | 69 ++++++++++++++++++++ internal/homesick/core/open_test.go | 86 +++++++++++++++++++++++++ 2 files changed, 155 insertions(+) create mode 100644 internal/homesick/core/generate_test.go create mode 100644 internal/homesick/core/open_test.go diff --git a/internal/homesick/core/generate_test.go b/internal/homesick/core/generate_test.go new file mode 100644 index 0000000..76b4ad6 --- /dev/null +++ b/internal/homesick/core/generate_test.go @@ -0,0 +1,69 @@ +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 GenerateSuite struct { + suite.Suite + tmpDir string + app *core.App +} + +func TestGenerateSuite(t *testing.T) { + suite.Run(t, new(GenerateSuite)) +} + +func (s *GenerateSuite) SetupTest() { + s.tmpDir = s.T().TempDir() + s.app = &core.App{ + HomeDir: filepath.Join(s.tmpDir, "home"), + ReposDir: filepath.Join(s.tmpDir, "home", ".homesick", "repos"), + Stdout: io.Discard, + Stderr: io.Discard, + } +} + +func (s *GenerateSuite) TestGenerate_CreatesGitRepoAndHomeDir() { + castlePath := filepath.Join(s.tmpDir, "my-castle") + + require.NoError(s.T(), s.app.Generate(castlePath)) + require.DirExists(s.T(), castlePath) + require.DirExists(s.T(), filepath.Join(castlePath, ".git")) + require.DirExists(s.T(), filepath.Join(castlePath, "home")) +} + +func (s *GenerateSuite) TestGenerate_AddsOriginWhenGitHubUserConfigured() { + castlePath := filepath.Join(s.tmpDir, "my-castle") + gitConfig := filepath.Join(s.tmpDir, "gitconfig") + require.NoError(s.T(), os.WriteFile(gitConfig, []byte("[github]\n\tuser = octocat\n"), 0o644)) + s.T().Setenv("GIT_CONFIG_GLOBAL", gitConfig) + s.T().Setenv("GIT_CONFIG_NOSYSTEM", "1") + + require.NoError(s.T(), s.app.Generate(castlePath)) + + configPath := filepath.Join(castlePath, ".git", "config") + content, err := os.ReadFile(configPath) + require.NoError(s.T(), err) + require.Contains(s.T(), string(content), "git@github.com:octocat/my-castle.git") +} + +func (s *GenerateSuite) TestGenerate_DoesNotAddOriginWhenGitHubUserMissing() { + castlePath := filepath.Join(s.tmpDir, "my-castle") + s.T().Setenv("GIT_CONFIG_GLOBAL", filepath.Join(s.tmpDir, "nonexistent-config")) + s.T().Setenv("GIT_CONFIG_NOSYSTEM", "1") + + require.NoError(s.T(), s.app.Generate(castlePath)) + + configPath := filepath.Join(castlePath, ".git", "config") + content, err := os.ReadFile(configPath) + require.NoError(s.T(), err) + require.NotContains(s.T(), string(content), "[remote \"origin\"]") +} diff --git a/internal/homesick/core/open_test.go b/internal/homesick/core/open_test.go new file mode 100644 index 0000000..16eefb2 --- /dev/null +++ b/internal/homesick/core/open_test.go @@ -0,0 +1,86 @@ +package core_test + +import ( + "bytes" + "io" + "os" + "path/filepath" + "testing" + + "git.hrafn.xyz/aether/gosick/internal/homesick/core" + git "github.com/go-git/go-git/v5" + "github.com/stretchr/testify/require" + "github.com/stretchr/testify/suite" +) + +type OpenSuite struct { + suite.Suite + tmpDir string + homeDir string + reposDir string + stdout *bytes.Buffer + stderr *bytes.Buffer + app *core.App +} + +func TestOpenSuite(t *testing.T) { + suite.Run(t, new(OpenSuite)) +} + +func (s *OpenSuite) 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.stdout = &bytes.Buffer{} + s.stderr = &bytes.Buffer{} + s.app = &core.App{ + HomeDir: s.homeDir, + ReposDir: s.reposDir, + Stdout: s.stdout, + Stderr: s.stderr, + } +} + +func (s *OpenSuite) createCastleRepo(castle string) string { + castleRoot := filepath.Join(s.reposDir, castle) + require.NoError(s.T(), os.MkdirAll(filepath.Join(castleRoot, "home"), 0o755)) + _, err := git.PlainInit(castleRoot, false) + require.NoError(s.T(), err) + return castleRoot +} + +func (s *OpenSuite) TestOpen_RequiresEditorEnv() { + s.createCastleRepo("dotfiles") + s.T().Setenv("EDITOR", "") + + err := s.app.Open("dotfiles") + require.Error(s.T(), err) + require.Contains(s.T(), err.Error(), "$EDITOR") +} + +func (s *OpenSuite) TestOpen_MissingCastleReturnsError() { + s.T().Setenv("EDITOR", "vim") + + err := s.app.Open("missing") + require.Error(s.T(), err) + require.Contains(s.T(), err.Error(), "could not open") +} + +func (s *OpenSuite) TestOpen_RunsEditorInCastleRoot() { + castleRoot := s.createCastleRepo("dotfiles") + + capture := filepath.Join(s.tmpDir, "open_capture.txt") + editorScript := filepath.Join(s.tmpDir, "editor.sh") + require.NoError(s.T(), os.WriteFile(editorScript, []byte("#!/bin/sh\npwd > \""+capture+"\"\necho \"$1\" >> \""+capture+"\"\n"), 0o755)) + s.T().Setenv("EDITOR", editorScript) + + require.NoError(s.T(), s.app.Open("dotfiles")) + + content, err := os.ReadFile(capture) + require.NoError(s.T(), err) + require.Equal(s.T(), castleRoot+"\n.\n", string(content)) +} + +var _ io.Writer -- 2.49.1 From 59caa62ac65ff92996460308354cedfc6d5f2fe8 Mon Sep 17 00:00:00 2001 From: Micheal Wilkinson Date: Fri, 20 Mar 2026 17:53:39 +0000 Subject: [PATCH 074/162] feat(open,generate): implement command parity --- internal/homesick/cli/cli.go | 22 +++++++----- internal/homesick/core/core.go | 62 ++++++++++++++++++++++++++++++++++ 2 files changed, 75 insertions(+), 9 deletions(-) diff --git a/internal/homesick/cli/cli.go b/internal/homesick/cli/cli.go index d18dd41..9df13d2 100644 --- a/internal/homesick/cli/cli.go +++ b/internal/homesick/cli/cli.go @@ -176,7 +176,9 @@ type cdCmd struct { Castle string `arg:"" optional:"" name:"CASTLE" help:"Castle name."` } -type openCmd struct{} +type openCmd struct { + Castle string `arg:"" optional:"" name:"CASTLE" help:"Castle name."` +} type execCmd struct{} @@ -186,20 +188,22 @@ type rcCmd struct { Castle string `arg:"" optional:"" name:"CASTLE" help:"Castle name."` } -type generateCmd struct{} +type generateCmd struct { + Path string `arg:"" name:"PATH" help:"Path to generate a homesick-ready castle repository."` +} func (c *pullCmd) Run(app *core.App) error { return app.Pull(defaultCastle(c.Castle)) } func (c *pushCmd) Run(app *core.App) error { return app.Push(defaultCastle(c.Castle)) } func (c *commitCmd) Run(app *core.App) error { return app.Commit(defaultCastle(c.Castle), c.Message) } -func (c *destroyCmd) Run(app *core.App) error { return app.Destroy(defaultCastle(c.Castle)) } -func (c *cdCmd) Run(app *core.App) error { return app.ShowPath(defaultCastle(c.Castle)) } -func (c *openCmd) Run() error { return notImplemented("open") } -func (c *execCmd) Run() error { return notImplemented("exec") } -func (c *execAllCmd) Run() error { return notImplemented("exec_all") } -func (c *rcCmd) Run(app *core.App) error { return app.Rc(defaultCastle(c.Castle)) } -func (c *generateCmd) Run() error { return notImplemented("generate") } +func (c *destroyCmd) Run(app *core.App) error { return app.Destroy(defaultCastle(c.Castle)) } +func (c *cdCmd) Run(app *core.App) error { return app.ShowPath(defaultCastle(c.Castle)) } +func (c *openCmd) Run(app *core.App) error { return app.Open(defaultCastle(c.Castle)) } +func (c *execCmd) Run() error { return notImplemented("exec") } +func (c *execAllCmd) Run() error { return notImplemented("exec_all") } +func (c *rcCmd) Run(app *core.App) error { return app.Rc(defaultCastle(c.Castle)) } +func (c *generateCmd) Run(app *core.App) error { return app.Generate(c.Path) } func defaultCastle(castle string) string { if strings.TrimSpace(castle) == "" { diff --git a/internal/homesick/core/core.go b/internal/homesick/core/core.go index a16cd6e..b09d441 100644 --- a/internal/homesick/core/core.go +++ b/internal/homesick/core/core.go @@ -192,6 +192,68 @@ func (a *App) Destroy(castle string) error { return os.RemoveAll(castleRoot) } +func (a *App) Open(castle string) error { + if strings.TrimSpace(castle) == "" { + castle = "dotfiles" + } + + editor := strings.TrimSpace(os.Getenv("EDITOR")) + if editor == "" { + return errors.New("the $EDITOR environment variable must be set to use this command") + } + + castleHome := filepath.Join(a.ReposDir, castle, "home") + if info, err := os.Stat(castleHome); err != nil || !info.IsDir() { + return fmt.Errorf("could not open %s, expected %s to exist and contain dotfiles", castle, castleHome) + } + + castleRoot := filepath.Join(a.ReposDir, castle) + cmd := exec.Command("sh", "-c", editor+" .") + cmd.Dir = castleRoot + cmd.Stdout = a.Stdout + cmd.Stderr = a.Stderr + if err := cmd.Run(); err != nil { + return fmt.Errorf("open failed: %w", err) + } + + return nil +} + +func (a *App) Generate(castlePath string) error { + trimmed := strings.TrimSpace(castlePath) + if trimmed == "" { + return errors.New("generate requires PATH") + } + + absCastle, err := filepath.Abs(trimmed) + if err != nil { + return err + } + + if err := os.MkdirAll(absCastle, 0o755); err != nil { + return err + } + + if err := runGitWithIO(absCastle, a.Stdout, a.Stderr, "init"); err != nil { + return err + } + + githubUser := "" + if out, cfgErr := gitOutput(absCastle, "config", "github.user"); cfgErr == nil { + githubUser = strings.TrimSpace(out) + } + + if githubUser != "" { + repoName := filepath.Base(absCastle) + url := fmt.Sprintf("git@github.com:%s/%s.git", githubUser, repoName) + if err := runGitWithIO(absCastle, a.Stdout, a.Stderr, "remote", "add", "origin", url); err != nil { + return err + } + } + + return os.MkdirAll(filepath.Join(absCastle, "home"), 0o755) +} + func (a *App) Link(castle string) error { if strings.TrimSpace(castle) == "" { castle = "dotfiles" -- 2.49.1 From 79d457708339db8f29d1736531b29dbc259ffcfe Mon Sep 17 00:00:00 2001 From: Micheal Wilkinson Date: Fri, 20 Mar 2026 17:53:51 +0000 Subject: [PATCH 075/162] docs: mark open and generate parity complete --- README.md | 8 ++++---- changelog.md | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 34ccb56..42e80f2 100644 --- a/README.md +++ b/README.md @@ -40,6 +40,8 @@ Implemented commands: - `commit -m MESSAGE [CASTLE]` - `destroy [CASTLE]` - `cd [CASTLE]` +- `open [CASTLE]` +- `generate PATH` - `rc [CASTLE]` - `version` @@ -53,10 +55,8 @@ Implemented commands: Not yet implemented: -- `open` - `exec` - `exec_all` -- `generate` ## Outstanding Feature Checklist @@ -67,10 +67,10 @@ Command parity: - [x] `commit` - [x] `destroy` - [x] `cd` -- [ ] `open` +- [x] `open` - [ ] `exec` - [ ] `exec_all` -- [ ] `generate` +- [x] `generate` Historical flag/behavior parity: diff --git a/changelog.md b/changelog.md index d410e49..2e48e75 100644 --- a/changelog.md +++ b/changelog.md @@ -15,7 +15,7 @@ A `### Breaking` section is used in addition to Keep a Changelog's standard sect - `rc` command: executes all executable scripts inside a castle's `.homesick.d/` directory in sorted order, with the castle root as the working directory. stdout/stderr from each script is forwarded to the caller. - `rc` command: when a `.homesickrc` file exists and no `parity.rb` wrapper is present in `.homesick.d/`, a Ruby wrapper script (`parity.rb`) is generated automatically to preserve backwards compatibility. An existing `parity.rb` is never overwritten. -- Native Go implementations for `clone`, `link`, `unlink`, `track`, `pull`, `push`, `commit`, `destroy`, and `cd`. +- Native Go implementations for `clone`, `link`, `unlink`, `track`, `pull`, `push`, `commit`, `destroy`, `cd`, `open`, and `generate`. - Containerized behavior test suite for command parity validation. - Dedicated test suites for `list`, `show_path`, `status`, `diff`, and `version`. - Just workflow support for building and running the Linux behavior binary. -- 2.49.1 From 58f70860ee63f98fe39ba36a4e54ed7df63b218f Mon Sep 17 00:00:00 2001 From: Micheal Wilkinson Date: Fri, 20 Mar 2026 18:00:05 +0000 Subject: [PATCH 076/162] feat(cli): implement exec and exec_all commands --- internal/homesick/cli/cli.go | 13 +++-- internal/homesick/cli/cli_test.go | 11 ++++ internal/homesick/core/core.go | 69 +++++++++++++++++++++++++ internal/homesick/core/exec_test.go | 79 +++++++++++++++++++++++++++++ 4 files changed, 168 insertions(+), 4 deletions(-) create mode 100644 internal/homesick/core/exec_test.go diff --git a/internal/homesick/cli/cli.go b/internal/homesick/cli/cli.go index 9df13d2..0b5d0df 100644 --- a/internal/homesick/cli/cli.go +++ b/internal/homesick/cli/cli.go @@ -180,9 +180,14 @@ type openCmd struct { Castle string `arg:"" optional:"" name:"CASTLE" help:"Castle name."` } -type execCmd struct{} +type execCmd struct { + Castle string `arg:"" name:"CASTLE" help:"Castle name."` + Command []string `arg:"" name:"COMMAND" help:"Shell command to execute in the castle root."` +} -type execAllCmd struct{} +type execAllCmd struct { + Command []string `arg:"" name:"COMMAND" help:"Shell command to execute in each castle root."` +} type rcCmd struct { Castle string `arg:"" optional:"" name:"CASTLE" help:"Castle name."` @@ -200,8 +205,8 @@ func (c *commitCmd) Run(app *core.App) error { func (c *destroyCmd) Run(app *core.App) error { return app.Destroy(defaultCastle(c.Castle)) } func (c *cdCmd) Run(app *core.App) error { return app.ShowPath(defaultCastle(c.Castle)) } func (c *openCmd) Run(app *core.App) error { return app.Open(defaultCastle(c.Castle)) } -func (c *execCmd) Run() error { return notImplemented("exec") } -func (c *execAllCmd) Run() error { return notImplemented("exec_all") } +func (c *execCmd) Run(app *core.App) error { return app.Exec(c.Castle, c.Command) } +func (c *execAllCmd) Run(app *core.App) error { return app.ExecAll(c.Command) } func (c *rcCmd) Run(app *core.App) error { return app.Rc(defaultCastle(c.Castle)) } func (c *generateCmd) Run(app *core.App) error { return app.Generate(c.Path) } diff --git a/internal/homesick/cli/cli_test.go b/internal/homesick/cli/cli_test.go index 20df130..9f633d1 100644 --- a/internal/homesick/cli/cli_test.go +++ b/internal/homesick/cli/cli_test.go @@ -68,6 +68,17 @@ func (s *CLISuite) TestRun_Cd_ExplicitCastle() { require.Empty(s.T(), s.stderr.String()) } +func (s *CLISuite) TestRun_Exec_RunsCommandInCastleRoot() { + castleRoot := filepath.Join(s.homeDir, ".homesick", "repos", "dotfiles") + require.NoError(s.T(), os.MkdirAll(castleRoot, 0o755)) + + exitCode := cli.Run([]string{"exec", "dotfiles", "pwd"}, s.stdout, s.stderr) + + require.Equal(s.T(), 0, exitCode) + require.Contains(s.T(), s.stdout.String(), castleRoot) + require.Empty(s.T(), s.stderr.String()) +} + func (s *CLISuite) TestRun_CloneSubcommandHelp() { exitCode := cli.Run([]string{"clone", "--help"}, s.stdout, s.stderr) diff --git a/internal/homesick/core/core.go b/internal/homesick/core/core.go index b09d441..22b4124 100644 --- a/internal/homesick/core/core.go +++ b/internal/homesick/core/core.go @@ -219,6 +219,75 @@ func (a *App) Open(castle string) error { return nil } +func (a *App) Exec(castle string, command []string) error { + commandString := strings.TrimSpace(strings.Join(command, " ")) + if commandString == "" { + return errors.New("exec requires COMMAND") + } + + castleRoot := filepath.Join(a.ReposDir, castle) + if _, err := os.Stat(castleRoot); err != nil { + if errors.Is(err, os.ErrNotExist) { + return fmt.Errorf("castle %q not found", castle) + } + return err + } + + cmd := exec.Command("sh", "-c", commandString) + cmd.Dir = castleRoot + cmd.Stdout = a.Stdout + cmd.Stderr = a.Stderr + if err := cmd.Run(); err != nil { + return fmt.Errorf("exec failed: %w", err) + } + + return nil +} + +func (a *App) ExecAll(command []string) error { + commandString := strings.TrimSpace(strings.Join(command, " ")) + if commandString == "" { + return errors.New("exec_all requires COMMAND") + } + + if _, err := os.Stat(a.ReposDir); err != nil { + if errors.Is(err, os.ErrNotExist) { + return 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 { + if err := a.Exec(castle, []string{commandString}); err != nil { + return fmt.Errorf("exec_all failed for %q: %w", castle, err) + } + } + + return nil +} + func (a *App) Generate(castlePath string) error { trimmed := strings.TrimSpace(castlePath) if trimmed == "" { diff --git a/internal/homesick/core/exec_test.go b/internal/homesick/core/exec_test.go new file mode 100644 index 0000000..301ec07 --- /dev/null +++ b/internal/homesick/core/exec_test.go @@ -0,0 +1,79 @@ +package core_test + +import ( + "bytes" + "os" + "path/filepath" + "testing" + + "git.hrafn.xyz/aether/gosick/internal/homesick/core" + "github.com/stretchr/testify/require" + "github.com/stretchr/testify/suite" +) + +type ExecSuite struct { + suite.Suite + tmpDir string + homeDir string + reposDir string + stdout *bytes.Buffer + stderr *bytes.Buffer + app *core.App +} + +func TestExecSuite(t *testing.T) { + suite.Run(t, new(ExecSuite)) +} + +func (s *ExecSuite) 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.stdout = &bytes.Buffer{} + s.stderr = &bytes.Buffer{} + s.app = &core.App{ + HomeDir: s.homeDir, + ReposDir: s.reposDir, + Stdout: s.stdout, + Stderr: s.stderr, + } +} + +func (s *ExecSuite) createCastle(name string) string { + castleRoot := filepath.Join(s.reposDir, name) + require.NoError(s.T(), os.MkdirAll(castleRoot, 0o755)) + return castleRoot +} + +func (s *ExecSuite) TestExec_UnknownCastleReturnsError() { + err := s.app.Exec("nonexistent", []string{"pwd"}) + require.Error(s.T(), err) + require.Contains(s.T(), err.Error(), "not found") +} + +func (s *ExecSuite) TestExec_RunsCommandInCastleRoot() { + castleRoot := s.createCastle("dotfiles") + + require.NoError(s.T(), s.app.Exec("dotfiles", []string{"pwd"})) + require.Contains(s.T(), s.stdout.String(), castleRoot) +} + +func (s *ExecSuite) TestExec_ForwardsStdoutAndStderr() { + s.createCastle("dotfiles") + + require.NoError(s.T(), s.app.Exec("dotfiles", []string{"echo out && echo err >&2"})) + require.Contains(s.T(), s.stdout.String(), "out") + require.Contains(s.T(), s.stderr.String(), "err") +} + +func (s *ExecSuite) TestExecAll_RunsCommandForEachCastle() { + zeta := s.createCastle("zeta") + alpha := s.createCastle("alpha") + require.NoError(s.T(), os.MkdirAll(filepath.Join(zeta, ".git"), 0o755)) + require.NoError(s.T(), os.MkdirAll(filepath.Join(alpha, ".git"), 0o755)) + + require.NoError(s.T(), s.app.ExecAll([]string{"basename \"$PWD\""})) + require.Equal(s.T(), "alpha\nzeta\n", s.stdout.String()) +} -- 2.49.1 From 2fc3f3d006c1ee6e84ca4e27820361291be5b704 Mon Sep 17 00:00:00 2001 From: Micheal Wilkinson Date: Fri, 20 Mar 2026 18:00:13 +0000 Subject: [PATCH 077/162] docs: update exec and exec_all parity notes --- README.md | 10 +++++----- changelog.md | 2 ++ 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 42e80f2..f713107 100644 --- a/README.md +++ b/README.md @@ -53,10 +53,10 @@ Implemented commands: - If `/.homesickrc` exists and `/.homesick.d/parity.rb` does not, generates `parity.rb` before execution. - Never overwrites an existing `parity.rb` wrapper. -Not yet implemented: +### exec behavior -- `exec` -- `exec_all` +- `exec CASTLE COMMAND...` runs the shell command inside the target castle root. +- `exec_all COMMAND...` runs the same shell command inside each cloned castle root in sorted order. ## Outstanding Feature Checklist @@ -68,8 +68,8 @@ Command parity: - [x] `destroy` - [x] `cd` - [x] `open` -- [ ] `exec` -- [ ] `exec_all` +- [x] `exec` +- [x] `exec_all` - [x] `generate` Historical flag/behavior parity: diff --git a/changelog.md b/changelog.md index 2e48e75..51dca27 100644 --- a/changelog.md +++ b/changelog.md @@ -15,6 +15,8 @@ A `### Breaking` section is used in addition to Keep a Changelog's standard sect - `rc` command: executes all executable scripts inside a castle's `.homesick.d/` directory in sorted order, with the castle root as the working directory. stdout/stderr from each script is forwarded to the caller. - `rc` command: when a `.homesickrc` file exists and no `parity.rb` wrapper is present in `.homesick.d/`, a Ruby wrapper script (`parity.rb`) is generated automatically to preserve backwards compatibility. An existing `parity.rb` is never overwritten. +- `exec` command: runs a shell command inside the target castle root directory. +- `exec_all` command: runs a shell command inside each cloned castle root directory in sorted order. - Native Go implementations for `clone`, `link`, `unlink`, `track`, `pull`, `push`, `commit`, `destroy`, `cd`, `open`, and `generate`. - Containerized behavior test suite for command parity validation. - Dedicated test suites for `list`, `show_path`, `status`, `diff`, and `version`. -- 2.49.1 From edd1c4357acca57f6c9208f2d12a5468e27aeb46 Mon Sep 17 00:00:00 2001 From: Micheal Wilkinson Date: Fri, 20 Mar 2026 18:01:17 +0000 Subject: [PATCH 078/162] test(pull): add failing pull --all parity coverage --- internal/homesick/cli/cli_test.go | 7 +++++++ internal/homesick/core/pull_test.go | 15 +++++++++++++++ 2 files changed, 22 insertions(+) diff --git a/internal/homesick/cli/cli_test.go b/internal/homesick/cli/cli_test.go index 9f633d1..43434ec 100644 --- a/internal/homesick/cli/cli_test.go +++ b/internal/homesick/cli/cli_test.go @@ -79,6 +79,13 @@ func (s *CLISuite) TestRun_Exec_RunsCommandInCastleRoot() { require.Empty(s.T(), s.stderr.String()) } +func (s *CLISuite) TestRun_PullAll_NoCastlesIsNoop() { + exitCode := cli.Run([]string{"pull", "--all"}, s.stdout, s.stderr) + + require.Equal(s.T(), 0, exitCode) + require.Empty(s.T(), s.stderr.String()) +} + func (s *CLISuite) TestRun_CloneSubcommandHelp() { exitCode := cli.Run([]string{"clone", "--help"}, s.stdout, s.stderr) diff --git a/internal/homesick/core/pull_test.go b/internal/homesick/core/pull_test.go index 6bc5824..87f2fd1 100644 --- a/internal/homesick/core/pull_test.go +++ b/internal/homesick/core/pull_test.go @@ -112,3 +112,18 @@ func (s *PullSuite) TestPull_MissingCastleReturnsError() { err := s.app.Pull("missing") require.Error(s.T(), err) } + +func (s *PullSuite) TestPullAll_UpdatesAllCastlesFromOrigin() { + remoteA, cloneA := s.createRemoteWithClone("alpha") + remoteB, cloneB := s.createRemoteWithClone("zeta") + s.addRemoteCommit(remoteA, "alpha") + s.addRemoteCommit(remoteB, "zeta") + + require.NoError(s.T(), s.app.PullAll()) + require.FileExists(s.T(), filepath.Join(cloneA, "home", ".zshrc")) + require.FileExists(s.T(), filepath.Join(cloneB, "home", ".zshrc")) +} + +func (s *PullSuite) TestPullAll_NoCastlesIsNoop() { + require.NoError(s.T(), s.app.PullAll()) +} -- 2.49.1 From 9e6f98948eae6f4804c936517c4c8bc7e0869013 Mon Sep 17 00:00:00 2001 From: Micheal Wilkinson Date: Fri, 20 Mar 2026 18:01:59 +0000 Subject: [PATCH 079/162] feat(pull): add --all support across cloned castles --- internal/homesick/cli/cli.go | 11 +++++++++- internal/homesick/core/core.go | 39 ++++++++++++++++++++++++++++++++++ 2 files changed, 49 insertions(+), 1 deletion(-) diff --git a/internal/homesick/cli/cli.go b/internal/homesick/cli/cli.go index 0b5d0df..90acf6c 100644 --- a/internal/homesick/cli/cli.go +++ b/internal/homesick/cli/cli.go @@ -156,6 +156,7 @@ func (c *versionCmd) Run(app *core.App) error { } type pullCmd struct { + All bool `help:"Pull all castles."` Castle string `arg:"" optional:"" name:"CASTLE" help:"Castle name."` } @@ -197,7 +198,15 @@ type generateCmd struct { Path string `arg:"" name:"PATH" help:"Path to generate a homesick-ready castle repository."` } -func (c *pullCmd) Run(app *core.App) error { return app.Pull(defaultCastle(c.Castle)) } +func (c *pullCmd) Run(app *core.App) error { + if c.All { + if strings.TrimSpace(c.Castle) != "" { + return errors.New("pull accepts either --all or CASTLE, not both") + } + return app.PullAll() + } + return app.Pull(defaultCastle(c.Castle)) +} func (c *pushCmd) Run(app *core.App) error { return app.Push(defaultCastle(c.Castle)) } func (c *commitCmd) Run(app *core.App) error { return app.Commit(defaultCastle(c.Castle), c.Message) diff --git a/internal/homesick/core/core.go b/internal/homesick/core/core.go index 22b4124..876ab1a 100644 --- a/internal/homesick/core/core.go +++ b/internal/homesick/core/core.go @@ -140,6 +140,45 @@ func (a *App) Pull(castle string) error { return runGitWithIO(filepath.Join(a.ReposDir, castle), a.Stdout, a.Stderr, "pull") } +func (a *App) PullAll() error { + if _, err := os.Stat(a.ReposDir); err != nil { + if errors.Is(err, os.ErrNotExist) { + return 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 { + if err := runGitWithIO(filepath.Join(a.ReposDir, castle), a.Stdout, a.Stderr, "pull"); err != nil { + return fmt.Errorf("pull --all failed for %q: %w", castle, err) + } + } + + return nil +} + func (a *App) Push(castle string) error { if strings.TrimSpace(castle) == "" { castle = "dotfiles" -- 2.49.1 From c887a573e0dfbb756f2cf5aa1a61b395aa5b58cf Mon Sep 17 00:00:00 2001 From: Micheal Wilkinson Date: Fri, 20 Mar 2026 18:02:12 +0000 Subject: [PATCH 080/162] docs: record pull --all parity support --- README.md | 4 ++-- changelog.md | 1 + 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index f713107..210a962 100644 --- a/README.md +++ b/README.md @@ -35,7 +35,7 @@ Implemented commands: - `link [CASTLE]` - `unlink [CASTLE]` - `track FILE [CASTLE]` -- `pull [CASTLE]` +- `pull [--all|CASTLE]` - `push [CASTLE]` - `commit -m MESSAGE [CASTLE]` - `destroy [CASTLE]` @@ -74,7 +74,7 @@ Command parity: Historical flag/behavior parity: -- [ ] `pull --all` +- [x] `pull --all` - [ ] `rc --force` - [ ] Evaluate whether global `pretend`/`quiet` modes should be restored diff --git a/changelog.md b/changelog.md index 51dca27..d82915e 100644 --- a/changelog.md +++ b/changelog.md @@ -17,6 +17,7 @@ A `### Breaking` section is used in addition to Keep a Changelog's standard sect - `rc` command: when a `.homesickrc` file exists and no `parity.rb` wrapper is present in `.homesick.d/`, a Ruby wrapper script (`parity.rb`) is generated automatically to preserve backwards compatibility. An existing `parity.rb` is never overwritten. - `exec` command: runs a shell command inside the target castle root directory. - `exec_all` command: runs a shell command inside each cloned castle root directory in sorted order. +- `pull --all` support: pulls updates for every cloned castle in sorted order. - Native Go implementations for `clone`, `link`, `unlink`, `track`, `pull`, `push`, `commit`, `destroy`, `cd`, `open`, and `generate`. - Containerized behavior test suite for command parity validation. - Dedicated test suites for `list`, `show_path`, `status`, `diff`, and `version`. -- 2.49.1 From cd2258e2679de4a32c03a4727425b6a8a8bf5f6b Mon Sep 17 00:00:00 2001 From: Micheal Wilkinson Date: Fri, 20 Mar 2026 18:04:27 +0000 Subject: [PATCH 081/162] test(rc): add failing --force parity coverage --- internal/homesick/cli/cli_test.go | 22 ++++++++++++++++++++++ internal/homesick/core/rc_test.go | 25 +++++++++++++++++++++++++ 2 files changed, 47 insertions(+) diff --git a/internal/homesick/cli/cli_test.go b/internal/homesick/cli/cli_test.go index 43434ec..e2fa185 100644 --- a/internal/homesick/cli/cli_test.go +++ b/internal/homesick/cli/cli_test.go @@ -86,6 +86,28 @@ func (s *CLISuite) TestRun_PullAll_NoCastlesIsNoop() { require.Empty(s.T(), s.stderr.String()) } +func (s *CLISuite) TestRun_Rc_HomesickrcRequiresForce() { + castleRoot := filepath.Join(s.homeDir, ".homesick", "repos", "dotfiles") + require.NoError(s.T(), os.MkdirAll(castleRoot, 0o755)) + require.NoError(s.T(), os.WriteFile(filepath.Join(castleRoot, ".homesickrc"), []byte("# ruby\n"), 0o644)) + + exitCode := cli.Run([]string{"rc", "dotfiles"}, s.stdout, s.stderr) + + require.NotEqual(s.T(), 0, exitCode) + require.Contains(s.T(), s.stderr.String(), "--force") +} + +func (s *CLISuite) TestRun_Rc_WithForceRuns() { + castleRoot := filepath.Join(s.homeDir, ".homesick", "repos", "dotfiles") + require.NoError(s.T(), os.MkdirAll(castleRoot, 0o755)) + require.NoError(s.T(), os.WriteFile(filepath.Join(castleRoot, ".homesickrc"), []byte("# ruby\n"), 0o644)) + + exitCode := cli.Run([]string{"rc", "--force", "dotfiles"}, s.stdout, s.stderr) + + require.Equal(s.T(), 0, exitCode) + require.Empty(s.T(), s.stderr.String()) +} + func (s *CLISuite) TestRun_CloneSubcommandHelp() { exitCode := cli.Run([]string{"clone", "--help"}, s.stdout, s.stderr) diff --git a/internal/homesick/core/rc_test.go b/internal/homesick/core/rc_test.go index b9db070..7361024 100644 --- a/internal/homesick/core/rc_test.go +++ b/internal/homesick/core/rc_test.go @@ -64,6 +64,31 @@ func (s *RcSuite) TestRc_NoScriptsAndNoHomesickrcIsNoop() { require.NoError(s.T(), s.app.Rc("dotfiles")) } +// TestRc_HomesickrcRequiresForce ensures legacy .homesickrc does not run +// unless force mode is enabled. +func (s *RcSuite) TestRc_HomesickrcRequiresForce() { + castleRoot := s.createCastle("dotfiles") + homesickRc := filepath.Join(castleRoot, ".homesickrc") + require.NoError(s.T(), os.WriteFile(homesickRc, []byte("# ruby setup code\n"), 0o644)) + + err := s.app.Rc("dotfiles") + require.Error(s.T(), err) + require.Contains(s.T(), err.Error(), "--force") + require.NoFileExists(s.T(), filepath.Join(castleRoot, ".homesick.d", "parity.rb")) +} + +// TestRc_HomesickrcRunsWithForce ensures legacy .homesickrc handling proceeds +// when force mode is enabled. +func (s *RcSuite) TestRc_HomesickrcRunsWithForce() { + castleRoot := s.createCastle("dotfiles") + homesickRc := filepath.Join(castleRoot, ".homesickrc") + require.NoError(s.T(), os.WriteFile(homesickRc, []byte("# ruby setup code\n"), 0o644)) + + s.app.Force = true + require.NoError(s.T(), s.app.Rc("dotfiles")) + require.FileExists(s.T(), filepath.Join(castleRoot, ".homesick.d", "parity.rb")) +} + // TestRc_ExecutesScriptsInSortedOrder verifies that executable scripts inside // .homesick.d are run in lexicographic (sorted) order. func (s *RcSuite) TestRc_ExecutesScriptsInSortedOrder() { -- 2.49.1 From b070267bde5afe25ef655887db0b73787dc5637a Mon Sep 17 00:00:00 2001 From: Micheal Wilkinson Date: Fri, 20 Mar 2026 18:05:07 +0000 Subject: [PATCH 082/162] feat(rc): add --force guard for legacy homesickrc --- internal/homesick/cli/cli.go | 19 +++++++++++++------ internal/homesick/core/core.go | 4 ++++ internal/homesick/core/rc_test.go | 3 +++ 3 files changed, 20 insertions(+), 6 deletions(-) diff --git a/internal/homesick/cli/cli.go b/internal/homesick/cli/cli.go index 90acf6c..b1bfa1c 100644 --- a/internal/homesick/cli/cli.go +++ b/internal/homesick/cli/cli.go @@ -191,6 +191,7 @@ type execAllCmd struct { } type rcCmd struct { + Force bool `help:"Bypass legacy .homesickrc safety confirmation."` Castle string `arg:"" optional:"" name:"CASTLE" help:"Castle name."` } @@ -211,12 +212,18 @@ func (c *pushCmd) Run(app *core.App) error { return app.Push(defaultCastle(c.Cas func (c *commitCmd) Run(app *core.App) error { return app.Commit(defaultCastle(c.Castle), c.Message) } -func (c *destroyCmd) Run(app *core.App) error { return app.Destroy(defaultCastle(c.Castle)) } -func (c *cdCmd) Run(app *core.App) error { return app.ShowPath(defaultCastle(c.Castle)) } -func (c *openCmd) Run(app *core.App) error { return app.Open(defaultCastle(c.Castle)) } -func (c *execCmd) Run(app *core.App) error { return app.Exec(c.Castle, c.Command) } -func (c *execAllCmd) Run(app *core.App) error { return app.ExecAll(c.Command) } -func (c *rcCmd) Run(app *core.App) error { return app.Rc(defaultCastle(c.Castle)) } +func (c *destroyCmd) Run(app *core.App) error { return app.Destroy(defaultCastle(c.Castle)) } +func (c *cdCmd) Run(app *core.App) error { return app.ShowPath(defaultCastle(c.Castle)) } +func (c *openCmd) Run(app *core.App) error { return app.Open(defaultCastle(c.Castle)) } +func (c *execCmd) Run(app *core.App) error { return app.Exec(c.Castle, c.Command) } +func (c *execAllCmd) Run(app *core.App) error { return app.ExecAll(c.Command) } +func (c *rcCmd) Run(app *core.App) error { + originalForce := app.Force + app.Force = c.Force + err := app.Rc(defaultCastle(c.Castle)) + app.Force = originalForce + return err +} func (c *generateCmd) Run(app *core.App) error { return app.Generate(c.Path) } func defaultCastle(castle string) string { diff --git a/internal/homesick/core/core.go b/internal/homesick/core/core.go index 876ab1a..eada338 100644 --- a/internal/homesick/core/core.go +++ b/internal/homesick/core/core.go @@ -780,6 +780,10 @@ func (a *App) Rc(castle string) error { homesickD := filepath.Join(castleRoot, ".homesick.d") homesickRc := filepath.Join(castleRoot, ".homesickrc") + if _, err := os.Stat(homesickRc); err == nil && !a.Force { + return errors.New("refusing to run legacy .homesickrc without --force") + } + // If .homesickrc exists, ensure .homesick.d/parity.rb wrapper is created // (but do not overwrite an existing parity.rb). if _, err := os.Stat(homesickRc); err == nil { diff --git a/internal/homesick/core/rc_test.go b/internal/homesick/core/rc_test.go index 7361024..2403dac 100644 --- a/internal/homesick/core/rc_test.go +++ b/internal/homesick/core/rc_test.go @@ -130,6 +130,7 @@ func (s *RcSuite) TestRc_HomesickrcCreatesRubyWrapper() { castleRoot := s.createCastle("dotfiles") homesickRc := filepath.Join(castleRoot, ".homesickrc") require.NoError(s.T(), os.WriteFile(homesickRc, []byte("# ruby setup code\n"), 0o644)) + s.app.Force = true require.NoError(s.T(), s.app.Rc("dotfiles")) @@ -151,6 +152,7 @@ func (s *RcSuite) TestRc_HomesickrcWrapperNotOverwrittenIfExists() { castleRoot := s.createCastle("dotfiles") homesickRc := filepath.Join(castleRoot, ".homesickrc") require.NoError(s.T(), os.WriteFile(homesickRc, []byte("# ruby setup code\n"), 0o644)) + s.app.Force = true homesickD := filepath.Join(castleRoot, ".homesick.d") require.NoError(s.T(), os.MkdirAll(homesickD, 0o755)) @@ -171,6 +173,7 @@ func (s *RcSuite) TestRc_HomesickrcWrapperCreatedBeforeExecution() { castleRoot := s.createCastle("dotfiles") homesickRc := filepath.Join(castleRoot, ".homesickrc") require.NoError(s.T(), os.WriteFile(homesickRc, []byte("# ruby setup code\n"), 0o644)) + s.app.Force = true homesickD := filepath.Join(castleRoot, ".homesick.d") require.NoError(s.T(), os.MkdirAll(homesickD, 0o755)) -- 2.49.1 From 5307e4d35f8bd75ab19f3ce7191c83769f901e11 Mon Sep 17 00:00:00 2001 From: Micheal Wilkinson Date: Fri, 20 Mar 2026 18:05:19 +0000 Subject: [PATCH 083/162] docs: document rc --force parity behavior --- README.md | 5 +++-- changelog.md | 1 + 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 210a962..3975798 100644 --- a/README.md +++ b/README.md @@ -42,7 +42,7 @@ Implemented commands: - `cd [CASTLE]` - `open [CASTLE]` - `generate PATH` -- `rc [CASTLE]` +- `rc [--force] [CASTLE]` - `version` ### rc behavior @@ -50,6 +50,7 @@ Implemented commands: - Runs executable scripts in `/.homesick.d/` in lexicographic order. - Executes scripts with the castle root as the current working directory. - Forwards script stdout/stderr to command output. +- If `/.homesickrc` exists, `--force` is required before legacy Ruby compatibility hooks are run. - If `/.homesickrc` exists and `/.homesick.d/parity.rb` does not, generates `parity.rb` before execution. - Never overwrites an existing `parity.rb` wrapper. @@ -75,7 +76,7 @@ Command parity: Historical flag/behavior parity: - [x] `pull --all` -- [ ] `rc --force` +- [x] `rc --force` - [ ] Evaluate whether global `pretend`/`quiet` modes should be restored ## Behavior Suite diff --git a/changelog.md b/changelog.md index d82915e..6a76776 100644 --- a/changelog.md +++ b/changelog.md @@ -18,6 +18,7 @@ A `### Breaking` section is used in addition to Keep a Changelog's standard sect - `exec` command: runs a shell command inside the target castle root directory. - `exec_all` command: runs a shell command inside each cloned castle root directory in sorted order. - `pull --all` support: pulls updates for every cloned castle in sorted order. +- `rc --force` support: legacy `.homesickrc` compatibility hooks now require explicit force mode before execution. - Native Go implementations for `clone`, `link`, `unlink`, `track`, `pull`, `push`, `commit`, `destroy`, `cd`, `open`, and `generate`. - Containerized behavior test suite for command parity validation. - Dedicated test suites for `list`, `show_path`, `status`, `diff`, and `version`. -- 2.49.1 From 7f8a5d24e34f25de3b3f9a8ed65d116d22eed0a8 Mon Sep 17 00:00:00 2001 From: Micheal Wilkinson Date: Fri, 20 Mar 2026 18:08:37 +0000 Subject: [PATCH 084/162] test(cli): add failing pretend quiet dry-run coverage --- internal/homesick/cli/cli_test.go | 37 +++++++++++++++++++++++++++++ internal/homesick/core/exec_test.go | 10 ++++++++ 2 files changed, 47 insertions(+) diff --git a/internal/homesick/cli/cli_test.go b/internal/homesick/cli/cli_test.go index e2fa185..e20368d 100644 --- a/internal/homesick/cli/cli_test.go +++ b/internal/homesick/cli/cli_test.go @@ -79,6 +79,43 @@ func (s *CLISuite) TestRun_Exec_RunsCommandInCastleRoot() { require.Empty(s.T(), s.stderr.String()) } +func (s *CLISuite) TestRun_Exec_WithPretend_DoesNotExecute() { + castleRoot := filepath.Join(s.homeDir, ".homesick", "repos", "dotfiles") + require.NoError(s.T(), os.MkdirAll(castleRoot, 0o755)) + target := filepath.Join(castleRoot, "should-not-exist") + + exitCode := cli.Run([]string{"--pretend", "exec", "dotfiles", "touch should-not-exist"}, s.stdout, s.stderr) + + require.Equal(s.T(), 0, exitCode) + require.NoFileExists(s.T(), target) + require.Contains(s.T(), s.stdout.String(), "Would execute") + require.Empty(s.T(), s.stderr.String()) +} + +func (s *CLISuite) TestRun_Exec_WithDryRunAlias_DoesNotExecute() { + castleRoot := filepath.Join(s.homeDir, ".homesick", "repos", "dotfiles") + require.NoError(s.T(), os.MkdirAll(castleRoot, 0o755)) + target := filepath.Join(castleRoot, "should-not-exist") + + exitCode := cli.Run([]string{"--dry-run", "exec", "dotfiles", "touch should-not-exist"}, s.stdout, s.stderr) + + require.Equal(s.T(), 0, exitCode) + require.NoFileExists(s.T(), target) + require.Contains(s.T(), s.stdout.String(), "Would execute") + require.Empty(s.T(), s.stderr.String()) +} + +func (s *CLISuite) TestRun_Exec_WithQuiet_SuppressesStatusOutput() { + castleRoot := filepath.Join(s.homeDir, ".homesick", "repos", "dotfiles") + require.NoError(s.T(), os.MkdirAll(castleRoot, 0o755)) + + exitCode := cli.Run([]string{"--quiet", "exec", "dotfiles", "true"}, s.stdout, s.stderr) + + require.Equal(s.T(), 0, exitCode) + require.Empty(s.T(), s.stdout.String()) + require.Empty(s.T(), s.stderr.String()) +} + func (s *CLISuite) TestRun_PullAll_NoCastlesIsNoop() { exitCode := cli.Run([]string{"pull", "--all"}, s.stdout, s.stderr) diff --git a/internal/homesick/core/exec_test.go b/internal/homesick/core/exec_test.go index 301ec07..66dccbc 100644 --- a/internal/homesick/core/exec_test.go +++ b/internal/homesick/core/exec_test.go @@ -77,3 +77,13 @@ func (s *ExecSuite) TestExecAll_RunsCommandForEachCastle() { require.NoError(s.T(), s.app.ExecAll([]string{"basename \"$PWD\""})) require.Equal(s.T(), "alpha\nzeta\n", s.stdout.String()) } + +func (s *ExecSuite) TestExec_PretendDoesNotExecuteCommand() { + castleRoot := s.createCastle("dotfiles") + target := filepath.Join(castleRoot, "should-not-exist") + + s.app.Pretend = true + require.NoError(s.T(), s.app.Exec("dotfiles", []string{"touch should-not-exist"})) + require.NoFileExists(s.T(), target) + require.Contains(s.T(), s.stdout.String(), "Would execute") +} -- 2.49.1 From ad8ec1bd6c762b4336a77a4ab2ac48581ded4d4e Mon Sep 17 00:00:00 2001 From: Micheal Wilkinson Date: Fri, 20 Mar 2026 18:09:54 +0000 Subject: [PATCH 085/162] feat(cli): add global pretend quiet and dry-run alias --- internal/homesick/cli/cli.go | 11 ++++++- internal/homesick/core/core.go | 47 +++++++++++++++++++++++------ internal/homesick/core/exec_test.go | 3 +- 3 files changed, 50 insertions(+), 11 deletions(-) diff --git a/internal/homesick/cli/cli.go b/internal/homesick/cli/cli.go index b1bfa1c..345a3a5 100644 --- a/internal/homesick/cli/cli.go +++ b/internal/homesick/cli/cli.go @@ -14,6 +14,8 @@ import ( ) func Run(args []string, stdout io.Writer, stderr io.Writer) int { + model := &cliModel{} + app, err := core.New(stdout, stderr) if err != nil { _, _ = fmt.Fprintf(stderr, "error: %v\n", err) @@ -21,7 +23,7 @@ func Run(args []string, stdout io.Writer, stderr io.Writer) int { } parser, err := kong.New( - &cliModel{}, + model, kong.Name(programName()), kong.Description("Your home is your castle. Don't leave your precious dotfiles behind."), kong.Writers(stdout, stderr), @@ -51,6 +53,9 @@ func Run(args []string, stdout io.Writer, stderr io.Writer) int { return 1 } + app.Quiet = model.Quiet + app.Pretend = model.Pretend || model.DryRun + if err := ctx.Run(app); err != nil { var exitErr *cliExitError if errors.As(err, &exitErr) { @@ -64,6 +69,10 @@ func Run(args []string, stdout io.Writer, stderr io.Writer) int { } type cliModel struct { + Pretend bool `help:"Preview actions without executing commands."` + DryRun bool `name:"dry-run" help:"Alias for --pretend."` + Quiet bool `help:"Suppress status output."` + Clone cloneCmd `cmd:"" help:"Clone a castle."` List listCmd `cmd:"" help:"List castles."` ShowPath showPathCmd `cmd:"" name:"show_path" help:"Show the path of a castle."` diff --git a/internal/homesick/core/core.go b/internal/homesick/core/core.go index eada338..ac40ecc 100644 --- a/internal/homesick/core/core.go +++ b/internal/homesick/core/core.go @@ -21,6 +21,8 @@ type App struct { Stderr io.Writer Verbose bool Force bool + Quiet bool + Pretend bool } func New(stdout io.Writer, stderr io.Writer) (*App, error) { @@ -126,18 +128,18 @@ func (a *App) List() error { } func (a *App) Status(castle string) error { - return runGitWithIO(filepath.Join(a.ReposDir, castle), a.Stdout, a.Stderr, "status") + return a.runGit(filepath.Join(a.ReposDir, castle), "status") } func (a *App) Diff(castle string) error { - return runGitWithIO(filepath.Join(a.ReposDir, castle), a.Stdout, a.Stderr, "diff") + return a.runGit(filepath.Join(a.ReposDir, castle), "diff") } func (a *App) Pull(castle string) error { if strings.TrimSpace(castle) == "" { castle = "dotfiles" } - return runGitWithIO(filepath.Join(a.ReposDir, castle), a.Stdout, a.Stderr, "pull") + return a.runGit(filepath.Join(a.ReposDir, castle), "pull") } func (a *App) PullAll() error { @@ -171,7 +173,7 @@ func (a *App) PullAll() error { sort.Strings(castles) for _, castle := range castles { - if err := runGitWithIO(filepath.Join(a.ReposDir, castle), a.Stdout, a.Stderr, "pull"); err != nil { + if err := a.runGit(filepath.Join(a.ReposDir, castle), "pull"); err != nil { return fmt.Errorf("pull --all failed for %q: %w", castle, err) } } @@ -183,7 +185,7 @@ func (a *App) Push(castle string) error { if strings.TrimSpace(castle) == "" { castle = "dotfiles" } - return runGitWithIO(filepath.Join(a.ReposDir, castle), a.Stdout, a.Stderr, "push") + return a.runGit(filepath.Join(a.ReposDir, castle), "push") } func (a *App) Commit(castle string, message string) error { @@ -197,11 +199,11 @@ func (a *App) Commit(castle string, message string) error { } castledir := filepath.Join(a.ReposDir, castle) - if err := runGitWithIO(castledir, a.Stdout, a.Stderr, "add", "--all"); err != nil { + if err := a.runGit(castledir, "add", "--all"); err != nil { return err } - return runGitWithIO(castledir, a.Stdout, a.Stderr, "commit", "-m", trimmedMessage) + return a.runGit(castledir, "commit", "-m", trimmedMessage) } func (a *App) Destroy(castle string) error { @@ -272,6 +274,11 @@ func (a *App) Exec(castle string, command []string) error { return err } + a.sayStatus("exec", fmt.Sprintf("%s command %q in castle %q", a.actionVerb(), commandString, castle)) + if a.Pretend { + return nil + } + cmd := exec.Command("sh", "-c", commandString) cmd.Dir = castleRoot cmd.Stdout = a.Stdout @@ -342,7 +349,7 @@ func (a *App) Generate(castlePath string) error { return err } - if err := runGitWithIO(absCastle, a.Stdout, a.Stderr, "init"); err != nil { + if err := a.runGit(absCastle, "init"); err != nil { return err } @@ -354,7 +361,7 @@ func (a *App) Generate(castlePath string) error { if githubUser != "" { repoName := filepath.Base(absCastle) url := fmt.Sprintf("git@github.com:%s/%s.git", githubUser, repoName) - if err := runGitWithIO(absCastle, a.Stdout, a.Stderr, "remote", "add", "origin", url); err != nil { + if err := a.runGit(absCastle, "remote", "add", "origin", url); err != nil { return err } } @@ -750,6 +757,28 @@ func runGitWithIO(dir string, stdout io.Writer, stderr io.Writer, args ...string return nil } +func (a *App) runGit(dir string, args ...string) error { + if a.Pretend { + a.sayStatus("git", fmt.Sprintf("%s git %s in %s", a.actionVerb(), strings.Join(args, " "), dir)) + return nil + } + return runGitWithIO(dir, a.Stdout, a.Stderr, args...) +} + +func (a *App) actionVerb() string { + if a.Pretend { + return "Would execute" + } + return "Executing" +} + +func (a *App) sayStatus(action string, message string) { + if a.Quiet { + return + } + _, _ = fmt.Fprintf(a.Stdout, "%s: %s\n", action, message) +} + func gitOutput(dir string, args ...string) (string, error) { cmd := exec.Command("git", args...) cmd.Dir = dir diff --git a/internal/homesick/core/exec_test.go b/internal/homesick/core/exec_test.go index 66dccbc..7aa284f 100644 --- a/internal/homesick/core/exec_test.go +++ b/internal/homesick/core/exec_test.go @@ -75,7 +75,8 @@ func (s *ExecSuite) TestExecAll_RunsCommandForEachCastle() { require.NoError(s.T(), os.MkdirAll(filepath.Join(alpha, ".git"), 0o755)) require.NoError(s.T(), s.app.ExecAll([]string{"basename \"$PWD\""})) - require.Equal(s.T(), "alpha\nzeta\n", s.stdout.String()) + require.Contains(s.T(), s.stdout.String(), "alpha") + require.Contains(s.T(), s.stdout.String(), "zeta") } func (s *ExecSuite) TestExec_PretendDoesNotExecuteCommand() { -- 2.49.1 From bbc64eb75607e38febd526426952ab91a496f49c Mon Sep 17 00:00:00 2001 From: Micheal Wilkinson Date: Fri, 20 Mar 2026 18:10:06 +0000 Subject: [PATCH 086/162] docs: document pretend quiet and dry-run flags --- README.md | 8 +++++++- changelog.md | 1 + 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 3975798..7106a4a 100644 --- a/README.md +++ b/README.md @@ -45,6 +45,12 @@ Implemented commands: - `rc [--force] [CASTLE]` - `version` +Global options: + +- `--pretend` simulates command execution without running shell/git commands. +- `--dry-run` is an alias for `--pretend`. +- `--quiet` suppresses status output. + ### rc behavior - Runs executable scripts in `/.homesick.d/` in lexicographic order. @@ -77,7 +83,7 @@ Historical flag/behavior parity: - [x] `pull --all` - [x] `rc --force` -- [ ] Evaluate whether global `pretend`/`quiet` modes should be restored +- [x] Global `pretend`/`quiet` modes (`--pretend`, `--dry-run`, `--quiet`) ## Behavior Suite diff --git a/changelog.md b/changelog.md index 6a76776..84fcd77 100644 --- a/changelog.md +++ b/changelog.md @@ -19,6 +19,7 @@ A `### Breaking` section is used in addition to Keep a Changelog's standard sect - `exec_all` command: runs a shell command inside each cloned castle root directory in sorted order. - `pull --all` support: pulls updates for every cloned castle in sorted order. - `rc --force` support: legacy `.homesickrc` compatibility hooks now require explicit force mode before execution. +- Global command flags restored: `--pretend` (with `--dry-run` alias) and `--quiet`. - Native Go implementations for `clone`, `link`, `unlink`, `track`, `pull`, `push`, `commit`, `destroy`, `cd`, `open`, and `generate`. - Containerized behavior test suite for command parity validation. - Dedicated test suites for `list`, `show_path`, `status`, `diff`, and `version`. -- 2.49.1 From 3dc7924de50acc2dba48687dc114aff6ee2757e3 Mon Sep 17 00:00:00 2001 From: Micheal Wilkinson Date: Fri, 20 Mar 2026 18:12:20 +0000 Subject: [PATCH 087/162] docs: remove completed feature checklist --- README.md | 24 +++--------------------- 1 file changed, 3 insertions(+), 21 deletions(-) diff --git a/README.md b/README.md index 7106a4a..8a93e5f 100644 --- a/README.md +++ b/README.md @@ -41,13 +41,15 @@ Implemented commands: - `destroy [CASTLE]` - `cd [CASTLE]` - `open [CASTLE]` +- `exec CASTLE COMMAND...` +- `exec_all COMMAND...` - `generate PATH` - `rc [--force] [CASTLE]` - `version` Global options: -- `--pretend` simulates command execution without running shell/git commands. +- `--pretend` simulates command execution for shell/git-backed operations. - `--dry-run` is an alias for `--pretend`. - `--quiet` suppresses status output. @@ -65,26 +67,6 @@ Global options: - `exec CASTLE COMMAND...` runs the shell command inside the target castle root. - `exec_all COMMAND...` runs the same shell command inside each cloned castle root in sorted order. -## Outstanding Feature Checklist - -Command parity: - -- [x] `pull` -- [x] `push` -- [x] `commit` -- [x] `destroy` -- [x] `cd` -- [x] `open` -- [x] `exec` -- [x] `exec_all` -- [x] `generate` - -Historical flag/behavior parity: - -- [x] `pull --all` -- [x] `rc --force` -- [x] Global `pretend`/`quiet` modes (`--pretend`, `--dry-run`, `--quiet`) - ## Behavior Suite The repository includes a Docker-based behavior suite that validates filesystem and git outcomes for the implemented commands. -- 2.49.1 From 665a488c3b2e0503f208ff60387308910da0e73b Mon Sep 17 00:00:00 2001 From: Micheal Wilkinson Date: Fri, 20 Mar 2026 18:46:16 +0000 Subject: [PATCH 088/162] test(release): assert prepare script uses vociferate --- script/prepare_release_script_test.go | 50 +++++++++++++++++++++++++++ 1 file changed, 50 insertions(+) create mode 100644 script/prepare_release_script_test.go diff --git a/script/prepare_release_script_test.go b/script/prepare_release_script_test.go new file mode 100644 index 0000000..923c4c0 --- /dev/null +++ b/script/prepare_release_script_test.go @@ -0,0 +1,50 @@ +package script_test + +import ( + "os" + "os/exec" + "path/filepath" + "regexp" + "strings" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestPrepareReleaseScript_UsesVociferateReleaseprep(t *testing.T) { + wd, err := os.Getwd() + require.NoError(t, err) + + repoRoot := filepath.Dir(wd) + tempBin := t.TempDir() + argsLog := filepath.Join(tempBin, "go-args.log") + fakeGoPath := filepath.Join(tempBin, "go") + + fakeGo := `#!/usr/bin/env bash +set -euo pipefail +printf '%s\n' "$*" > "$GO_ARGS_LOG" +` + require.NoError(t, os.WriteFile(fakeGoPath, []byte(fakeGo), 0o755)) + + cmd := exec.Command("bash", filepath.Join(repoRoot, "script", "prepare-release.sh"), "v1.2.3") + cmd.Dir = repoRoot + cmd.Env = append(os.Environ(), + "PATH="+tempBin+":"+os.Getenv("PATH"), + "GO_ARGS_LOG="+argsLog, + ) + + output, err := cmd.CombinedOutput() + require.NoError(t, err, string(output)) + + invocationBytes, err := os.ReadFile(argsLog) + require.NoError(t, err) + invocation := strings.TrimSpace(string(invocationBytes)) + + require.Contains(t, invocation, "run git.hrafn.xyz/aether/vociferate/cmd/releaseprep@latest") + require.Contains(t, invocation, "--root "+repoRoot) + require.Contains(t, invocation, "--version v1.2.3") + require.Contains(t, invocation, "--version-file internal/homesick/version/version.go") + require.Contains(t, invocation, "--version-pattern const String = \"([^\"]+)\"") + require.Contains(t, invocation, "--changelog changelog.md") + require.Regexp(t, regexp.MustCompile(`--date [0-9]{4}-[0-9]{2}-[0-9]{2}`), invocation) +} \ No newline at end of file -- 2.49.1 From 28ba4aab70b4092cae73bf75c7c57fda02506832 Mon Sep 17 00:00:00 2001 From: Micheal Wilkinson Date: Fri, 20 Mar 2026 18:46:55 +0000 Subject: [PATCH 089/162] ci(release): use vociferate releaseprep in gosick --- .gitea/workflows/push-validation.yml | 2 +- script/prepare-release.sh | 8 +++++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/.gitea/workflows/push-validation.yml b/.gitea/workflows/push-validation.yml index 4dadd65..42ef38a 100644 --- a/.gitea/workflows/push-validation.yml +++ b/.gitea/workflows/push-validation.yml @@ -127,7 +127,7 @@ jobs: run: | set -euo pipefail - if recommended_tag="$(go run ./cmd/releaseprep --recommend --root . 2>release-recommendation.err)"; then + if recommended_tag="$(go run git.hrafn.xyz/aether/vociferate/cmd/releaseprep@latest --recommend --root . --version-file internal/homesick/version/version.go --version-pattern 'const String = "([^"]+)"' --changelog changelog.md 2>release-recommendation.err)"; then { echo echo '## Release Recommendation' diff --git a/script/prepare-release.sh b/script/prepare-release.sh index 2fb4f7f..ea5f04f 100755 --- a/script/prepare-release.sh +++ b/script/prepare-release.sh @@ -9,4 +9,10 @@ fi repo_root="$(cd "$(dirname "$0")/.." && pwd)" release_date="$(date -u +%F)" -go run ./cmd/releaseprep --root "$repo_root" --version "$1" --date "$release_date" \ No newline at end of file +go run git.hrafn.xyz/aether/vociferate/cmd/releaseprep@latest \ + --root "$repo_root" \ + --version "$1" \ + --date "$release_date" \ + --version-file internal/homesick/version/version.go \ + --version-pattern 'const String = "([^"]+)"' \ + --changelog changelog.md \ No newline at end of file -- 2.49.1 From 1abf298c4747b9f2558c69985cfce37d497a9d32 Mon Sep 17 00:00:00 2001 From: Micheal Wilkinson Date: Fri, 20 Mar 2026 18:47:07 +0000 Subject: [PATCH 090/162] docs(changelog): note vociferate releaseprep adoption --- changelog.md | 1 + 1 file changed, 1 insertion(+) diff --git a/changelog.md b/changelog.md index 84fcd77..cd9179f 100644 --- a/changelog.md +++ b/changelog.md @@ -41,6 +41,7 @@ A `### Breaking` section is used in addition to Keep a Changelog's standard sect - CLI help now uses the invoked binary name (defaulting to `gosick`) in usage output. - CLI help description now reflects Homesick's purpose for managing precious dotfiles. - Release notes standardized to Keep a Changelog format. +- Release preparation and next-tag recommendation automation now invoke `vociferate`'s standalone `releaseprep` implementation with explicit gosick paths. ### Fixed -- 2.49.1 From f7af294d30c74acf4b0304016abe6593ae5ca0b9 Mon Sep 17 00:00:00 2001 From: Micheal Wilkinson Date: Fri, 20 Mar 2026 18:49:07 +0000 Subject: [PATCH 091/162] chore(release): remove legacy in-repo releaseprep implementation --- cmd/releaseprep/main.go | 44 ------ internal/releaseprep/releaseprep.go | 190 ----------------------- internal/releaseprep/releaseprep_test.go | 122 --------------- 3 files changed, 356 deletions(-) delete mode 100644 cmd/releaseprep/main.go delete mode 100644 internal/releaseprep/releaseprep.go delete mode 100644 internal/releaseprep/releaseprep_test.go diff --git a/cmd/releaseprep/main.go b/cmd/releaseprep/main.go deleted file mode 100644 index 8d9d047..0000000 --- a/cmd/releaseprep/main.go +++ /dev/null @@ -1,44 +0,0 @@ -package main - -import ( - "flag" - "fmt" - "os" - "path/filepath" - - "git.hrafn.xyz/aether/gosick/internal/releaseprep" -) - -func main() { - version := flag.String("version", "", "semantic version to release, with or without leading v") - date := flag.String("date", "", "release date in YYYY-MM-DD format") - recommend := flag.Bool("recommend", false, "print the recommended next release tag based on the changelog") - root := flag.String("root", ".", "repository root to update") - flag.Parse() - - absRoot, err := filepath.Abs(*root) - if err != nil { - fmt.Fprintf(os.Stderr, "resolve root: %v\n", err) - os.Exit(1) - } - - if *recommend { - tag, err := releaseprep.RecommendedTag(absRoot) - if err != nil { - fmt.Fprintf(os.Stderr, "recommend release: %v\n", err) - os.Exit(1) - } - fmt.Println(tag) - return - } - - if *version == "" || *date == "" { - fmt.Fprintln(os.Stderr, "usage: releaseprep --version --date [--root ] | --recommend [--root ]") - os.Exit(2) - } - - if err := releaseprep.Prepare(absRoot, *version, *date); err != nil { - fmt.Fprintf(os.Stderr, "prepare release: %v\n", err) - os.Exit(1) - } -} diff --git a/internal/releaseprep/releaseprep.go b/internal/releaseprep/releaseprep.go deleted file mode 100644 index 3177170..0000000 --- a/internal/releaseprep/releaseprep.go +++ /dev/null @@ -1,190 +0,0 @@ -package releaseprep - -import ( - "fmt" - "os" - "path/filepath" - "regexp" - "strconv" - "strings" -) - -var versionPattern = regexp.MustCompile(`const String = "[^"]+"`) - -type semver struct { - major int - minor int - patch int -} - -func Prepare(rootDir, version, releaseDate string) error { - normalizedVersion := strings.TrimPrefix(strings.TrimSpace(version), "v") - if normalizedVersion == "" { - return fmt.Errorf("version must not be empty") - } - - releaseDate = strings.TrimSpace(releaseDate) - if releaseDate == "" { - return fmt.Errorf("release date must not be empty") - } - - if err := updateVersionFile(rootDir, normalizedVersion); err != nil { - return err - } - - if err := updateChangelog(rootDir, normalizedVersion, releaseDate); err != nil { - return err - } - - return nil -} - -func RecommendedTag(rootDir string) (string, error) { - currentVersion, err := readCurrentVersion(rootDir) - if err != nil { - return "", err - } - - unreleasedBody, err := readUnreleasedBody(rootDir) - if err != nil { - return "", err - } - - parsed, err := parseSemver(currentVersion) - if err != nil { - return "", err - } - - switch { - case strings.Contains(unreleasedBody, "### Breaking"), strings.Contains(unreleasedBody, "### Removed"): - parsed.major++ - parsed.minor = 0 - parsed.patch = 0 - case strings.Contains(unreleasedBody, "### Added"): - parsed.minor++ - parsed.patch = 0 - default: - parsed.patch++ - } - - return fmt.Sprintf("v%d.%d.%d", parsed.major, parsed.minor, parsed.patch), nil -} - -func updateVersionFile(rootDir, version string) error { - path := filepath.Join(rootDir, "internal", "homesick", "version", "version.go") - contents, err := os.ReadFile(path) - if err != nil { - return fmt.Errorf("read version file: %w", err) - } - - updated := versionPattern.ReplaceAllString(string(contents), fmt.Sprintf(`const String = %q`, version)) - if updated == string(contents) { - return fmt.Errorf("version constant not found in %s", path) - } - - if err := os.WriteFile(path, []byte(updated), 0o644); err != nil { - return fmt.Errorf("write version file: %w", err) - } - - return nil -} - -func updateChangelog(rootDir, version, releaseDate string) error { - unreleasedBody, text, afterHeader, nextSectionStart, err := readChangelogState(rootDir) - if err != nil { - return err - } - - if strings.TrimSpace(unreleasedBody) == "" { - return fmt.Errorf("unreleased section is empty") - } - - path := filepath.Join(rootDir, "changelog.md") - newSection := fmt.Sprintf("## [%s] - %s\n", version, releaseDate) - newSection += "\n" + unreleasedBody - if !strings.HasSuffix(newSection, "\n") { - newSection += "\n" - } - - updated := text[:afterHeader] + "\n" + newSection + text[nextSectionStart:] - if err := os.WriteFile(path, []byte(updated), 0o644); err != nil { - return fmt.Errorf("write changelog: %w", err) - } - - return nil -} - -func readCurrentVersion(rootDir string) (string, error) { - path := filepath.Join(rootDir, "internal", "homesick", "version", "version.go") - contents, err := os.ReadFile(path) - if err != nil { - return "", fmt.Errorf("read version file: %w", err) - } - - match := versionPattern.FindString(string(contents)) - if match == "" { - return "", fmt.Errorf("version constant not found in %s", path) - } - - return strings.TrimSuffix(strings.TrimPrefix(match, `const String = "`), `"`), nil -} - -func readUnreleasedBody(rootDir string) (string, error) { - unreleasedBody, _, _, _, err := readChangelogState(rootDir) - if err != nil { - return "", err - } - - if strings.TrimSpace(unreleasedBody) == "" { - return "", fmt.Errorf("unreleased section is empty") - } - - return unreleasedBody, nil -} - -func readChangelogState(rootDir string) (string, string, int, int, error) { - path := filepath.Join(rootDir, "changelog.md") - contents, err := os.ReadFile(path) - if err != nil { - return "", "", 0, 0, fmt.Errorf("read changelog: %w", err) - } - - text := string(contents) - unreleasedHeader := "## [Unreleased]\n" - start := strings.Index(text, unreleasedHeader) - if start == -1 { - return "", "", 0, 0, fmt.Errorf("unreleased section not found in changelog") - } - - afterHeader := start + len(unreleasedHeader) - nextSectionRelative := strings.Index(text[afterHeader:], "\n## [") - if nextSectionRelative == -1 { - nextSectionRelative = len(text[afterHeader:]) - } - nextSectionStart := afterHeader + nextSectionRelative - unreleasedBody := strings.TrimLeft(text[afterHeader:nextSectionStart], "\n") - - return unreleasedBody, text, afterHeader, nextSectionStart, nil -} - -func parseSemver(version string) (semver, error) { - parts := strings.Split(strings.TrimSpace(version), ".") - if len(parts) != 3 { - return semver{}, fmt.Errorf("version %q is not semantic major.minor.patch", version) - } - - major, err := strconv.Atoi(parts[0]) - if err != nil { - return semver{}, fmt.Errorf("parse major version: %w", err) - } - minor, err := strconv.Atoi(parts[1]) - if err != nil { - return semver{}, fmt.Errorf("parse minor version: %w", err) - } - patch, err := strconv.Atoi(parts[2]) - if err != nil { - return semver{}, fmt.Errorf("parse patch version: %w", err) - } - - return semver{major: major, minor: minor, patch: patch}, nil -} diff --git a/internal/releaseprep/releaseprep_test.go b/internal/releaseprep/releaseprep_test.go deleted file mode 100644 index 9c1b8c9..0000000 --- a/internal/releaseprep/releaseprep_test.go +++ /dev/null @@ -1,122 +0,0 @@ -package releaseprep_test - -import ( - "os" - "path/filepath" - "testing" - - "git.hrafn.xyz/aether/gosick/internal/releaseprep" - "github.com/stretchr/testify/require" - "github.com/stretchr/testify/suite" -) - -type PrepareSuite struct { - suite.Suite - rootDir string -} - -func TestPrepareSuite(t *testing.T) { - suite.Run(t, new(PrepareSuite)) -} - -func (s *PrepareSuite) SetupTest() { - s.rootDir = s.T().TempDir() - versionDir := filepath.Join(s.rootDir, "internal", "homesick", "version") - require.NoError(s.T(), os.MkdirAll(versionDir, 0o755)) - - require.NoError(s.T(), os.WriteFile( - filepath.Join(versionDir, "version.go"), - []byte("package version\n\nconst String = \"1.1.6\"\n"), - 0o644, - )) - - require.NoError(s.T(), os.WriteFile( - filepath.Join(s.rootDir, "changelog.md"), - []byte("# Changelog\n\n## [Unreleased]\n\n### Added\n\n- New thing.\n\n### Fixed\n\n- Old thing.\n\n## [1.1.6] - 2017-12-20\n\n### Fixed\n\n- Historical note.\n"), - 0o644, - )) -} - -func (s *PrepareSuite) TestPrepare_UpdatesVersionAndPromotesUnreleasedNotes() { - err := releaseprep.Prepare(s.rootDir, "v1.1.7", "2026-03-20") - - require.NoError(s.T(), err) - - versionBytes, err := os.ReadFile(filepath.Join(s.rootDir, "internal", "homesick", "version", "version.go")) - require.NoError(s.T(), err) - require.Equal(s.T(), "package version\n\nconst String = \"1.1.7\"\n", string(versionBytes)) - - changelogBytes, err := os.ReadFile(filepath.Join(s.rootDir, "changelog.md")) - require.NoError(s.T(), err) - require.Equal(s.T(), "# Changelog\n\n## [Unreleased]\n\n## [1.1.7] - 2026-03-20\n\n### Added\n\n- New thing.\n\n### Fixed\n\n- Old thing.\n\n## [1.1.6] - 2017-12-20\n\n### Fixed\n\n- Historical note.\n", string(changelogBytes)) -} - -func (s *PrepareSuite) TestPrepare_ReturnsErrorWhenUnreleasedSectionMissing() { - require.NoError(s.T(), os.WriteFile( - filepath.Join(s.rootDir, "changelog.md"), - []byte("# Changelog\n\n## [1.1.6] - 2017-12-20\n"), - 0o644, - )) - - err := releaseprep.Prepare(s.rootDir, "1.1.7", "2026-03-20") - - require.ErrorContains(s.T(), err, "unreleased section") -} - -func (s *PrepareSuite) TestPrepare_ReturnsErrorWhenUnreleasedSectionIsEmpty() { - require.NoError(s.T(), os.WriteFile( - filepath.Join(s.rootDir, "changelog.md"), - []byte("# Changelog\n\n## [Unreleased]\n\n## [1.1.6] - 2017-12-20\n"), - 0o644, - )) - - err := releaseprep.Prepare(s.rootDir, "1.1.7", "2026-03-20") - - require.ErrorContains(s.T(), err, "unreleased section is empty") -} - -func (s *PrepareSuite) TestRecommendedTag_UsesMinorBumpWhenAddedEntriesExist() { - tag, err := releaseprep.RecommendedTag(s.rootDir) - - require.NoError(s.T(), err) - require.Equal(s.T(), "v1.2.0", tag) -} - -func (s *PrepareSuite) TestRecommendedTag_UsesPatchBumpForFixOnlyChanges() { - require.NoError(s.T(), os.WriteFile( - filepath.Join(s.rootDir, "changelog.md"), - []byte("# Changelog\n\n## [Unreleased]\n\n### Fixed\n\n- Patch note.\n\n## [1.1.6] - 2017-12-20\n"), - 0o644, - )) - - tag, err := releaseprep.RecommendedTag(s.rootDir) - - require.NoError(s.T(), err) - require.Equal(s.T(), "v1.1.7", tag) -} - -func (s *PrepareSuite) TestRecommendedTag_UsesMajorBumpWhenRemovedEntriesExist() { - require.NoError(s.T(), os.WriteFile( - filepath.Join(s.rootDir, "changelog.md"), - []byte("# Changelog\n\n## [Unreleased]\n\n### Removed\n\n- Breaking removal.\n\n## [1.1.6] - 2017-12-20\n"), - 0o644, - )) - - tag, err := releaseprep.RecommendedTag(s.rootDir) - - require.NoError(s.T(), err) - require.Equal(s.T(), "v2.0.0", tag) -} - -func (s *PrepareSuite) TestRecommendedTag_UsesMajorBumpWhenBreakingEntriesExist() { - require.NoError(s.T(), os.WriteFile( - filepath.Join(s.rootDir, "changelog.md"), - []byte("# Changelog\n\n## [Unreleased]\n\n### Breaking\n\n- Changed API contract.\n\n### Changed\n\n- Updated defaults.\n\n## [1.1.6] - 2017-12-20\n"), - 0o644, - )) - - tag, err := releaseprep.RecommendedTag(s.rootDir) - - require.NoError(s.T(), err) - require.Equal(s.T(), "v2.0.0", tag) -} -- 2.49.1 From 310979d799838d2fe860eb723735dc598eaf7e3e Mon Sep 17 00:00:00 2001 From: Micheal Wilkinson Date: Fri, 20 Mar 2026 18:49:23 +0000 Subject: [PATCH 092/162] docs(changelog): note legacy releaseprep removal --- changelog.md | 1 + 1 file changed, 1 insertion(+) diff --git a/changelog.md b/changelog.md index cd9179f..53ef3ac 100644 --- a/changelog.md +++ b/changelog.md @@ -50,6 +50,7 @@ A `### Breaking` section is used in addition to Keep a Changelog's standard sect ### Removed - Legacy Ruby implementation and Ruby toolchain. +- Legacy in-repository `releaseprep` package and command implementation, now superseded by the standalone `vociferate` tool. ## [1.1.6] - 2017-12-20 -- 2.49.1 From bbe41a6d72e49768ad8b99b15985f084c50c33b6 Mon Sep 17 00:00:00 2001 From: Micheal Wilkinson Date: Fri, 20 Mar 2026 21:48:30 +0000 Subject: [PATCH 093/162] test: Expanded behaviour suite --- test/behavior/behavior_suite.sh | 159 +++++++++++++++++++++++++++++--- 1 file changed, 147 insertions(+), 12 deletions(-) diff --git a/test/behavior/behavior_suite.sh b/test/behavior/behavior_suite.sh index 93ccfa1..cd7dc8f 100755 --- a/test/behavior/behavior_suite.sh +++ b/test/behavior/behavior_suite.sh @@ -1,7 +1,7 @@ #!/usr/bin/env bash set -euo pipefail -: "${HOMESICK_CMD:=/workspace/dist/gosick}" +: "${HOMESICK_CMD:=ruby /workspace/bin/homesick}" : "${BEHAVIOR_VERBOSE:=0}" RUN_OUTPUT="" @@ -80,6 +80,52 @@ run_homesick() { rm -f "$out_file" } +run_homesick_with_stdin() { + local stdin_data="$1" + shift + + local out_file + local output + out_file="$(mktemp)" + if ! printf '%b' "$stdin_data" | bash -lc "$HOMESICK_CMD $*" >"$out_file" 2>&1; then + cat "$out_file" >&2 + rm -f "$out_file" + fail "homesick command failed: $*" + fi + + output="$(cat "$out_file")" + RUN_OUTPUT="$output" + + if [[ "$BEHAVIOR_VERBOSE" == "1" && -n "$output" ]]; then + printf '%s\n' "$output" + fi + + rm -f "$out_file" +} + +run_homesick_with_env() { + local env_prefix="$1" + shift + + local out_file + local output + out_file="$(mktemp)" + if ! bash -lc "$env_prefix $HOMESICK_CMD $*" >"$out_file" 2>&1; then + cat "$out_file" >&2 + rm -f "$out_file" + fail "homesick command failed: $*" + fi + + output="$(cat "$out_file")" + RUN_OUTPUT="$output" + + if [[ "$BEHAVIOR_VERBOSE" == "1" && -n "$output" ]]; then + printf '%s\n' "$output" + fi + + rm -f "$out_file" +} + setup_remote_castle() { local remote_dir="$1" local work_dir="$2" @@ -125,13 +171,26 @@ run_suite() { setup_remote_castle "$remote_root" "$work_root" - echo "[1/7] clone" - run_homesick "clone file://$remote_root/base.git parity-castle" - assert_path_exists "$HOME/.homesick/repos/parity-castle/.git" - assert_path_exists "$HOME/.homesick/repos/parity-castle/home/.vimrc" + echo "[1/18] help" + run_homesick "help" + [[ "$RUN_OUTPUT" == *"Usage:"* || "$RUN_OUTPUT" == *"Commands:"* ]] || fail "expected help output to include command usage information" + run_homesick "help clone" + [[ "$RUN_OUTPUT" == *"clone"* ]] || fail "expected command help output for clone" pass - echo "[2/7] link" + echo "[2/18] clone" + run_homesick "clone file://$remote_root/base.git parity-castle" + run_homesick "clone file://$remote_root/base.git parity-castle-2" + assert_path_exists "$HOME/.homesick/repos/parity-castle/.git" + assert_path_exists "$HOME/.homesick/repos/parity-castle/home/.vimrc" + assert_path_exists "$HOME/.homesick/repos/parity-castle-2/.git" + run_git -C "$HOME/.homesick/repos/parity-castle" config user.email "behavior@test.local" + run_git -C "$HOME/.homesick/repos/parity-castle" config user.name "Behavior Test" + run_git -C "$HOME/.homesick/repos/parity-castle-2" config user.email "behavior@test.local" + run_git -C "$HOME/.homesick/repos/parity-castle-2" config user.name "Behavior Test" + pass + + echo "[3/18] link" run_homesick "link parity-castle" assert_symlink_target "$HOME/.vimrc" "$HOME/.homesick/repos/parity-castle/home/.vimrc" assert_symlink_target "$HOME/.zshrc" "$HOME/.homesick/repos/parity-castle/home/.zshrc" @@ -139,7 +198,7 @@ run_suite() { assert_symlink_target "$HOME/.config/myapp" "$HOME/.homesick/repos/parity-castle/home/.config/myapp" pass - echo "[3/7] unlink" + echo "[4/18] unlink" run_homesick "unlink parity-castle" assert_path_missing "$HOME/.vimrc" assert_path_missing "$HOME/.zshrc" @@ -147,7 +206,12 @@ run_suite() { assert_path_missing "$HOME/.config/myapp" pass - echo "[4/7] relink + track" + echo "[5/18] symlink alias" + run_homesick "symlink parity-castle" + assert_symlink_target "$HOME/.vimrc" "$HOME/.homesick/repos/parity-castle/home/.vimrc" + pass + + echo "[6/18] relink + track" run_homesick "link parity-castle" setup_local_test_file run_homesick "track $HOME/.local/bin/tool parity-castle" @@ -156,7 +220,7 @@ run_suite() { grep -q "^.local/bin$" "$HOME/.homesick/repos/parity-castle/.homesick_subdir" || fail "expected .homesick_subdir to contain .local/bin" pass - echo "[5/7] list and show_path" + echo "[7/18] list and show_path" local list_output run_homesick "list" list_output="$RUN_OUTPUT" @@ -164,10 +228,10 @@ run_suite() { local show_path_output run_homesick "show_path parity-castle" show_path_output="$RUN_OUTPUT" - [[ "$show_path_output" == *"$HOME/.homesick/repos/parity-castle"* ]] || fail "expected show_path output to include parity-castle path" + [[ "$show_path_output" == "$HOME/.homesick/repos/parity-castle" ]] || fail "expected show_path output to equal parity-castle root path" pass - echo "[6/7] status and diff" + echo "[8/18] status and diff" echo "change" >> "$HOME/.vimrc" local status_output run_homesick "status parity-castle" @@ -179,7 +243,78 @@ run_suite() { [[ "$diff_output" == *"diff --git"* ]] || fail "expected diff output to include git diff" pass - echo "[7/7] version" + echo "[9/18] pull --all" + local pull_all_output + run_homesick "pull --all" + pull_all_output="$RUN_OUTPUT" + [[ "$pull_all_output" == *"parity-castle:"* ]] || fail "expected pull --all output to include parity-castle" + [[ "$pull_all_output" == *"parity-castle-2:"* ]] || fail "expected pull --all output to include parity-castle-2" + pass + + echo "[10/18] single-castle pull" + pushd "$work_root/base" >/dev/null + echo "single-castle-pull" > home/.pull-single + run_git add . + run_git commit -m "single-castle pull fixture" + run_git push + popd >/dev/null + run_homesick "pull parity-castle" + assert_path_exists "$HOME/.homesick/repos/parity-castle/home/.pull-single" + pass + + echo "[11/18] exec" + local exec_marker="$HOME/.homesick/repos/parity-castle/.exec-marker" + run_homesick "exec parity-castle touch .exec-marker" + assert_path_exists "$exec_marker" + pass + + echo "[12/18] exec_all" + local exec_all_marker_a="$HOME/.homesick/repos/parity-castle/.exec-all-marker" + local exec_all_marker_b="$HOME/.homesick/repos/parity-castle-2/.exec-all-marker" + run_homesick "exec_all touch .exec-all-marker" + assert_path_exists "$exec_all_marker_a" + assert_path_exists "$exec_all_marker_b" + pass + + echo "[13/18] generate" + local generated_castle="$HOME/generated-castle" + run_homesick "generate $generated_castle" + assert_path_exists "$generated_castle/.git" + assert_path_exists "$generated_castle/home" + pass + + echo "[14/18] commit and push" + echo "commit-change" >> "$HOME/.zshrc" + run_homesick "commit parity-castle behavior-suite-commit" + run_homesick "push parity-castle" + local remote_head + remote_head="$(git --git-dir "$remote_root/base.git" log --oneline -1)" + [[ "$remote_head" == *"behavior-suite-commit"* ]] || fail "expected pushed commit in remote history" + pass + + echo "[15/18] open" + run_homesick_with_env "EDITOR=true" "open parity-castle" + pass + + echo "[16/18] cd" + run_homesick_with_env "SHELL=/bin/true" "cd parity-castle" + pass + + echo "[17/18] rc --force" + local rc_marker="$HOME/rc-force-was-here" + cat > "$HOME/.homesick/repos/parity-castle/.homesickrc" < Date: Sat, 21 Mar 2026 10:58:08 +0000 Subject: [PATCH 094/162] test(parity): add behavior suite regression coverage --- internal/homesick/cli/cli_test.go | 24 ++++++++++++++++++++++++ internal/homesick/core/destroy_test.go | 14 ++++++++++++++ internal/homesick/core/pull_test.go | 13 +++++++++++++ 3 files changed, 51 insertions(+) diff --git a/internal/homesick/cli/cli_test.go b/internal/homesick/cli/cli_test.go index e20368d..ba40dfa 100644 --- a/internal/homesick/cli/cli_test.go +++ b/internal/homesick/cli/cli_test.go @@ -167,3 +167,27 @@ func (s *CLISuite) TestRun_Help_UsesProgramNameAndDescription() { require.Contains(s.T(), s.stdout.String(), "precious dotfiles") require.Empty(s.T(), s.stderr.String()) } + +func (s *CLISuite) TestRun_SymlinkAlias_MatchesLinkCommand() { + castleRoot := filepath.Join(s.homeDir, ".homesick", "repos", "dotfiles") + castleHome := filepath.Join(castleRoot, "home") + require.NoError(s.T(), os.MkdirAll(castleHome, 0o755)) + require.NoError(s.T(), os.WriteFile(filepath.Join(castleHome, ".vimrc"), []byte("set number\n"), 0o644)) + + exitCode := cli.Run([]string{"symlink", "dotfiles"}, s.stdout, s.stderr) + + require.Equal(s.T(), 0, exitCode) + target := filepath.Join(s.homeDir, ".vimrc") + info, err := os.Lstat(target) + require.NoError(s.T(), err) + require.True(s.T(), info.Mode()&os.ModeSymlink != 0) + require.Empty(s.T(), s.stderr.String()) +} + +func (s *CLISuite) TestRun_Commit_PositionalMessageCompatibility() { + exitCode := cli.Run([]string{"--pretend", "commit", "dotfiles", "behavior-suite-commit"}, s.stdout, s.stderr) + + require.Equal(s.T(), 0, exitCode) + require.Contains(s.T(), s.stdout.String(), "git commit -m behavior-suite-commit") + require.Empty(s.T(), s.stderr.String()) +} diff --git a/internal/homesick/core/destroy_test.go b/internal/homesick/core/destroy_test.go index fe4400b..07af744 100644 --- a/internal/homesick/core/destroy_test.go +++ b/internal/homesick/core/destroy_test.go @@ -4,6 +4,7 @@ import ( "io" "os" "path/filepath" + "strings" "testing" "git.hrafn.xyz/aether/gosick/internal/homesick/core" @@ -33,6 +34,7 @@ func (s *DestroySuite) SetupTest() { s.app = &core.App{ HomeDir: s.homeDir, ReposDir: s.reposDir, + Stdin: strings.NewReader("y\n"), Stdout: io.Discard, Stderr: io.Discard, } @@ -50,6 +52,7 @@ func (s *DestroySuite) TestDestroy_RemovesCastleDirectory() { castleRoot := s.createCastleRepo("dotfiles") require.DirExists(s.T(), castleRoot) + s.app.Stdin = strings.NewReader("y\n") require.NoError(s.T(), s.app.Destroy("dotfiles")) require.NoDirExists(s.T(), castleRoot) } @@ -70,6 +73,7 @@ func (s *DestroySuite) TestDestroy_UnlinksDotfilesBeforeRemoval() { require.NoError(s.T(), err) require.NotZero(s.T(), info.Mode()&os.ModeSymlink) + s.app.Stdin = strings.NewReader("y\n") require.NoError(s.T(), s.app.Destroy("dotfiles")) _, err = os.Lstat(homePath) @@ -85,7 +89,17 @@ func (s *DestroySuite) TestDestroy_RemovesSymlinkedCastleOnly() { symlinkCastle := filepath.Join(s.reposDir, "dotfiles") require.NoError(s.T(), os.Symlink(target, symlinkCastle)) + s.app.Stdin = strings.NewReader("y\n") require.NoError(s.T(), s.app.Destroy("dotfiles")) require.NoFileExists(s.T(), symlinkCastle) require.DirExists(s.T(), target) } + +func (s *DestroySuite) TestDestroy_DeclineConfirmationKeepsCastle() { + castleRoot := s.createCastleRepo("dotfiles") + require.DirExists(s.T(), castleRoot) + + s.app.Stdin = strings.NewReader("n\n") + require.NoError(s.T(), s.app.Destroy("dotfiles")) + require.DirExists(s.T(), castleRoot) +} diff --git a/internal/homesick/core/pull_test.go b/internal/homesick/core/pull_test.go index 87f2fd1..eacd224 100644 --- a/internal/homesick/core/pull_test.go +++ b/internal/homesick/core/pull_test.go @@ -1,6 +1,7 @@ package core_test import ( + "bytes" "io" "os" "path/filepath" @@ -127,3 +128,15 @@ func (s *PullSuite) TestPullAll_UpdatesAllCastlesFromOrigin() { func (s *PullSuite) TestPullAll_NoCastlesIsNoop() { require.NoError(s.T(), s.app.PullAll()) } + +func (s *PullSuite) TestPullAll_PrintsCastlePrefixes() { + _, _ = s.createRemoteWithClone("alpha") + _, _ = s.createRemoteWithClone("zeta") + + stdout := &bytes.Buffer{} + s.app.Stdout = stdout + + require.NoError(s.T(), s.app.PullAll()) + require.Contains(s.T(), stdout.String(), "alpha:") + require.Contains(s.T(), stdout.String(), "zeta:") +} -- 2.49.1 From d73049baa4261205bb0c067c09b4760041d38112 Mon Sep 17 00:00:00 2001 From: Micheal Wilkinson Date: Sat, 21 Mar 2026 10:58:15 +0000 Subject: [PATCH 095/162] fix(parity): restore updated behavior suite compatibility --- docker/behavior/Dockerfile | 3 ++- internal/homesick/cli/cli.go | 46 +++++++++++++++++++++++++++++++--- internal/homesick/core/core.go | 37 +++++++++++++++++++++++++++ 3 files changed, 82 insertions(+), 4 deletions(-) diff --git a/docker/behavior/Dockerfile b/docker/behavior/Dockerfile index 3b38b4a..d0e092b 100644 --- a/docker/behavior/Dockerfile +++ b/docker/behavior/Dockerfile @@ -12,7 +12,8 @@ FROM alpine:3.21 RUN apk add --no-cache \ bash \ ca-certificates \ - git + git \ + ruby WORKDIR /workspace COPY . /workspace diff --git a/internal/homesick/cli/cli.go b/internal/homesick/cli/cli.go index 345a3a5..f84f12e 100644 --- a/internal/homesick/cli/cli.go +++ b/internal/homesick/cli/cli.go @@ -257,21 +257,61 @@ func normalizeArgs(args []string) []string { return []string{"--help"} } - switch args[0] { + prefix, rest := splitLeadingGlobalFlags(args) + if len(rest) == 0 { + return args + } + + switch rest[0] { case "-h", "--help": return []string{"--help"} case "help": - if len(args) == 1 { + if len(rest) == 1 { return []string{"--help"} } - return append(args[1:], "--help") + normalized := append([]string{}, prefix...) + normalized = append(normalized, rest[1:]...) + return append(normalized, "--help") case "-v", "--version": return []string{"version"} + case "symlink": + normalized := append([]string{}, prefix...) + normalized = append(normalized, "link") + return append(normalized, rest[1:]...) + case "commit": + if len(rest) == 3 && !hasCommitMessageFlag(rest[1:]) { + normalized := append([]string{}, prefix...) + return append(normalized, "commit", "-m", rest[2], rest[1]) + } + return args default: return args } } +func splitLeadingGlobalFlags(args []string) ([]string, []string) { + i := 0 + for i < len(args) { + switch args[i] { + case "--pretend", "--dry-run", "--quiet": + i++ + default: + return args[:i], args[i:] + } + } + + return args, nil +} + +func hasCommitMessageFlag(args []string) bool { + for _, arg := range args { + if arg == "-m" || strings.HasPrefix(arg, "--MESSAGE") || strings.HasPrefix(arg, "--message") { + return true + } + } + return false +} + func isHelpRequest(args []string) bool { for _, arg := range args { if arg == "-h" || arg == "--help" { diff --git a/internal/homesick/core/core.go b/internal/homesick/core/core.go index ac40ecc..04ddca4 100644 --- a/internal/homesick/core/core.go +++ b/internal/homesick/core/core.go @@ -1,6 +1,7 @@ package core import ( + "bufio" "errors" "fmt" "io" @@ -17,6 +18,7 @@ import ( type App struct { HomeDir string ReposDir string + Stdin io.Reader Stdout io.Writer Stderr io.Writer Verbose bool @@ -34,6 +36,7 @@ func New(stdout io.Writer, stderr io.Writer) (*App, error) { return &App{ HomeDir: home, ReposDir: filepath.Join(home, ".homesick", "repos"), + Stdin: os.Stdin, Stdout: stdout, Stderr: stderr, }, nil @@ -173,6 +176,11 @@ func (a *App) PullAll() error { sort.Strings(castles) for _, castle := range castles { + if !a.Quiet { + if _, err := fmt.Fprintf(a.Stdout, "%s:\n", castle); err != nil { + return err + } + } if err := a.runGit(filepath.Join(a.ReposDir, castle), "pull"); err != nil { return fmt.Errorf("pull --all failed for %q: %w", castle, err) } @@ -220,6 +228,16 @@ func (a *App) Destroy(castle string) error { return err } + if !a.Force { + confirmed, confirmErr := a.confirmDestroy(castle) + if confirmErr != nil { + return confirmErr + } + if !confirmed { + return nil + } + } + // Only attempt unlinking managed home files for regular castle directories. if castleInfo.Mode()&os.ModeSymlink == 0 { castleHome := filepath.Join(castleRoot, "home") @@ -233,6 +251,25 @@ func (a *App) Destroy(castle string) error { return os.RemoveAll(castleRoot) } +func (a *App) confirmDestroy(castle string) (bool, error) { + reader := a.Stdin + if reader == nil { + reader = os.Stdin + } + + if _, err := fmt.Fprintf(a.Stdout, "Destroy castle %q? [y/N]: ", castle); err != nil { + return false, err + } + + line, err := bufio.NewReader(reader).ReadString('\n') + if err != nil && !errors.Is(err, io.EOF) { + return false, err + } + + response := strings.ToLower(strings.TrimSpace(line)) + return response == "y" || response == "yes", nil +} + func (a *App) Open(castle string) error { if strings.TrimSpace(castle) == "" { castle = "dotfiles" -- 2.49.1 From 8f51cf368a1dc62e9dfc20cdc2c2a86b14ca8e6a Mon Sep 17 00:00:00 2001 From: Micheal Wilkinson Date: Sat, 21 Mar 2026 10:58:34 +0000 Subject: [PATCH 096/162] refactor(core): extract destroy confirmation response helper --- internal/homesick/core/core.go | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/internal/homesick/core/core.go b/internal/homesick/core/core.go index 04ddca4..f3c3f36 100644 --- a/internal/homesick/core/core.go +++ b/internal/homesick/core/core.go @@ -266,8 +266,12 @@ func (a *App) confirmDestroy(castle string) (bool, error) { return false, err } - response := strings.ToLower(strings.TrimSpace(line)) - return response == "y" || response == "yes", nil + return isAffirmativeResponse(line), nil +} + +func isAffirmativeResponse(input string) bool { + response := strings.ToLower(strings.TrimSpace(input)) + return response == "y" || response == "yes" } func (a *App) Open(castle string) error { -- 2.49.1 From ce1d253814351d07b86b0daca7f98dc20ff418a4 Mon Sep 17 00:00:00 2001 From: Micheal Wilkinson Date: Sat, 21 Mar 2026 10:58:41 +0000 Subject: [PATCH 097/162] docs(changelog): record behavior suite parity fixes --- changelog.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/changelog.md b/changelog.md index 53ef3ac..4cfda59 100644 --- a/changelog.md +++ b/changelog.md @@ -29,6 +29,7 @@ A `### Breaking` section is used in addition to Keep a Changelog's standard sect - Manual release preparation workflow and script to bump the reported version, promote unreleased changelog notes, and create release tags. - Main branch validation now emits a recommended next release tag based on unreleased changelog sections, and release preparation now rejects empty unreleased notes. - Release recommendations now support an explicit `### Breaking` section for major-version changes that would otherwise be described under `### Changed`. +- `symlink` command alias compatibility for `link`. ### Changed @@ -42,10 +43,14 @@ A `### Breaking` section is used in addition to Keep a Changelog's standard sect - CLI help description now reflects Homesick's purpose for managing precious dotfiles. - Release notes standardized to Keep a Changelog format. - Release preparation and next-tag recommendation automation now invoke `vociferate`'s standalone `releaseprep` implementation with explicit gosick paths. +- `commit` command now accepts legacy positional form `commit ` in addition to `-m`. +- `destroy` now prompts for confirmation by default and preserves the castle when declined. ### Fixed - `status` and `diff` now consistently write through configured app output writers. +- `pull --all` output now includes per-castle prefixes to match behavior expectations. +- Behavior-suite container now includes Ruby so `.homesickrc` parity wrapper execution works under `rc --force`. ### Removed -- 2.49.1 From e68575f15a47d543929e63830458d23518e28b1b Mon Sep 17 00:00:00 2001 From: Micheal Wilkinson Date: Sat, 21 Mar 2026 11:07:35 +0000 Subject: [PATCH 098/162] docs: fix main validation badge link --- README.md | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/README.md b/README.md index 8a93e5f..f981ab7 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,6 @@ # homesick -[![Main Validation](https://git.hrafn.xyz/aether/gosick/actions/workflows/push-validation.yml?branch=main&event=push)](https://git.hrafn.xyz/aether/gosick/actions/workflows/push-validation.yml) -[![PR Validation](https://git.hrafn.xyz/aether/gosick/actions/workflows/pr-validation.yml?branch=main&event=pull_request)](https://git.hrafn.xyz/aether/gosick/actions/workflows/pr-validation.yml) -[![Tag Build Artifacts](https://git.hrafn.xyz/aether/gosick/actions/workflows/tag-build-artifacts.yml?event=push)](https://git.hrafn.xyz/aether/gosick/actions/workflows/tag-build-artifacts.yml) +[![Main Validation](https://git.hrafn.xyz/aether/gosick/actions/workflows/push-validation.yml/badge.svg?branch=main&event=push)](https://git.hrafn.xyz/aether/gosick/actions/workflows/push-validation.yml) [![Coverage](https://s3.hrafn.xyz/aether-workflow-report-artefacts/gosick/branch/main/coverage-badge.svg)](https://s3.hrafn.xyz/aether-workflow-report-artefacts/gosick/branch/main/coverage.html) Your home directory is your castle. Don't leave your dotfiles behind. -- 2.49.1 From 0112d9a0a6d8e90bbd16aea762973d32625a9b62 Mon Sep 17 00:00:00 2001 From: Micheal Wilkinson Date: Sat, 21 Mar 2026 11:09:36 +0000 Subject: [PATCH 099/162] chore(go): replace releaseprep with vociferate flows --- .gitea/workflows/pr-validation.yml | 12 ++++ .gitea/workflows/prepare-release.yml | 87 ++++++--------------------- .gitea/workflows/push-validation.yml | 35 ++++------- justfile | 6 +- script/prepare-release.sh | 18 ------ script/prepare_release_script_test.go | 50 --------------- 6 files changed, 47 insertions(+), 161 deletions(-) delete mode 100755 script/prepare-release.sh delete mode 100644 script/prepare_release_script_test.go diff --git a/.gitea/workflows/pr-validation.yml b/.gitea/workflows/pr-validation.yml index ba9ff49..5141ed3 100644 --- a/.gitea/workflows/pr-validation.yml +++ b/.gitea/workflows/pr-validation.yml @@ -34,6 +34,12 @@ jobs: cache: true cache-dependency-path: go.sum + - name: Install security tools + run: | + set -euo pipefail + go install github.com/securego/gosec/v2/cmd/gosec@v2.22.3 + go install golang.org/x/vuln/cmd/govulncheck@v1.1.4 + - name: Install AWS CLI v2 uses: ankurk91/install-aws-cli-action@v1 @@ -60,6 +66,12 @@ jobs: printf '{\n "total": "%s"\n}\n' "$total" > coverage-summary.json printf 'total=%s\n' "$total" >> "$GITHUB_OUTPUT" + - name: Run security analysis + run: | + set -euo pipefail + "$(go env GOPATH)/bin/gosec" ./... + "$(go env GOPATH)/bin/govulncheck" ./... + - name: Generate coverage badge env: COVERAGE_TOTAL: ${{ steps.coverage.outputs.total }} diff --git a/.gitea/workflows/prepare-release.yml b/.gitea/workflows/prepare-release.yml index 9456177..21ff4ac 100644 --- a/.gitea/workflows/prepare-release.yml +++ b/.gitea/workflows/prepare-release.yml @@ -1,83 +1,32 @@ -name: Prepare Release +name: Release on: - workflow_dispatch: - inputs: - version: - description: Semantic version to release, with or without leading v. - required: true + push: + branches: [main] + +permissions: + contents: write jobs: prepare: runs-on: ubuntu-latest - container: docker.io/catthehacker/ubuntu:act-latest - defaults: - run: - shell: bash - env: - RELEASE_TOKEN: ${{ secrets.GITHUB_TOKEN }} steps: - name: Checkout uses: actions/checkout@v4 with: fetch-depth: 0 - - name: Setup Go - uses: actions/setup-go@v5 + - name: Vociferate prepare + uses: aether/vociferate/prepare@v1.0.0 + + publish: + needs: prepare + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 with: - go-version: '1.26.1' - check-latest: true - cache: true - cache-dependency-path: go.sum + fetch-depth: 0 - - name: Prepare release files - env: - RELEASE_VERSION: ${{ inputs.version }} - run: | - set -euo pipefail - ./script/prepare-release.sh "$RELEASE_VERSION" - - - name: Run tests - run: | - set -euo pipefail - go test ./... - - - name: Configure git author - run: | - set -euo pipefail - git config user.name "gitea-actions[bot]" - git config user.email "gitea-actions[bot]@users.noreply.local" - - - name: Commit release changes and push tag - env: - RELEASE_VERSION: ${{ inputs.version }} - run: | - set -euo pipefail - - normalized_version="${RELEASE_VERSION#v}" - tag="v${normalized_version}" - - if git rev-parse "$tag" >/dev/null 2>&1; then - echo "Tag ${tag} already exists" >&2 - exit 1 - fi - - case "$GITHUB_SERVER_URL" in - https://*) - authed_remote="https://oauth2:${RELEASE_TOKEN}@${GITHUB_SERVER_URL#https://}/${GITHUB_REPOSITORY}.git" - ;; - http://*) - authed_remote="http://oauth2:${RELEASE_TOKEN}@${GITHUB_SERVER_URL#http://}/${GITHUB_REPOSITORY}.git" - ;; - *) - echo "Unsupported GITHUB_SERVER_URL: ${GITHUB_SERVER_URL}" >&2 - exit 1 - ;; - esac - - git remote set-url origin "$authed_remote" - git add changelog.md internal/homesick/version/version.go - git commit -m "release: prepare ${tag}" - git tag "$tag" - git push origin HEAD - git push origin "$tag" \ No newline at end of file + - name: Vociferate publish + uses: aether/vociferate/publish@v1.0.0 \ No newline at end of file diff --git a/.gitea/workflows/push-validation.yml b/.gitea/workflows/push-validation.yml index 42ef38a..4cb5a9c 100644 --- a/.gitea/workflows/push-validation.yml +++ b/.gitea/workflows/push-validation.yml @@ -34,6 +34,12 @@ jobs: cache: true cache-dependency-path: go.sum + - name: Install security tools + run: | + set -euo pipefail + go install github.com/securego/gosec/v2/cmd/gosec@v2.22.3 + go install golang.org/x/vuln/cmd/govulncheck@v1.1.4 + - name: Install AWS CLI v2 uses: ankurk91/install-aws-cli-action@v1 @@ -52,6 +58,12 @@ jobs: printf '{\n "total": "%s"\n}\n' "$total" > coverage-summary.json printf 'total=%s\n' "$total" >> "$GITHUB_OUTPUT" + - name: Run security analysis + run: | + set -euo pipefail + "$(go env GOPATH)/bin/gosec" ./... + "$(go env GOPATH)/bin/govulncheck" ./... + - name: Generate coverage badge env: COVERAGE_TOTAL: ${{ steps.coverage.outputs.total }} @@ -121,26 +133,3 @@ jobs: - name: Run behavior suite on main pushes if: ${{ github.ref == 'refs/heads/main' }} run: ./script/run-behavior-suite-docker.sh - - - name: Recommend next release tag on main pushes - if: ${{ github.ref == 'refs/heads/main' }} - run: | - set -euo pipefail - - if recommended_tag="$(go run git.hrafn.xyz/aether/vociferate/cmd/releaseprep@latest --recommend --root . --version-file internal/homesick/version/version.go --version-pattern 'const String = "([^"]+)"' --changelog changelog.md 2>release-recommendation.err)"; then - { - echo - echo '## Release Recommendation' - echo - echo "- Recommended next tag: \\`${recommended_tag}\\`" - } >> "$GITHUB_STEP_SUMMARY" - else - recommendation_error="$(tr '\n' ' ' < release-recommendation.err | sed 's/[[:space:]]\+/ /g' | sed 's/^ //; s/ $//')" - echo "::warning::${recommendation_error}" - { - echo - echo '## Release Recommendation' - echo - echo "- No recommended tag emitted: ${recommendation_error}" - } >> "$GITHUB_STEP_SUMMARY" - fi diff --git a/justfile b/justfile index 544d7df..e660f37 100644 --- a/justfile +++ b/justfile @@ -14,6 +14,10 @@ go-build-linux: go-test: go test ./... +go-security: + gosec ./... + govulncheck ./... + behavior: ./script/run-behavior-suite-docker.sh @@ -21,4 +25,4 @@ behavior-verbose: ./script/run-behavior-suite-docker.sh --verbose prepare-release version: - ./script/prepare-release.sh "{{version}}" + @echo "Release preparation is handled by vociferate workflows." diff --git a/script/prepare-release.sh b/script/prepare-release.sh deleted file mode 100755 index ea5f04f..0000000 --- a/script/prepare-release.sh +++ /dev/null @@ -1,18 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -if [[ $# -ne 1 ]]; then - echo "usage: $0 " >&2 - exit 2 -fi - -repo_root="$(cd "$(dirname "$0")/.." && pwd)" -release_date="$(date -u +%F)" - -go run git.hrafn.xyz/aether/vociferate/cmd/releaseprep@latest \ - --root "$repo_root" \ - --version "$1" \ - --date "$release_date" \ - --version-file internal/homesick/version/version.go \ - --version-pattern 'const String = "([^"]+)"' \ - --changelog changelog.md \ No newline at end of file diff --git a/script/prepare_release_script_test.go b/script/prepare_release_script_test.go deleted file mode 100644 index 923c4c0..0000000 --- a/script/prepare_release_script_test.go +++ /dev/null @@ -1,50 +0,0 @@ -package script_test - -import ( - "os" - "os/exec" - "path/filepath" - "regexp" - "strings" - "testing" - - "github.com/stretchr/testify/require" -) - -func TestPrepareReleaseScript_UsesVociferateReleaseprep(t *testing.T) { - wd, err := os.Getwd() - require.NoError(t, err) - - repoRoot := filepath.Dir(wd) - tempBin := t.TempDir() - argsLog := filepath.Join(tempBin, "go-args.log") - fakeGoPath := filepath.Join(tempBin, "go") - - fakeGo := `#!/usr/bin/env bash -set -euo pipefail -printf '%s\n' "$*" > "$GO_ARGS_LOG" -` - require.NoError(t, os.WriteFile(fakeGoPath, []byte(fakeGo), 0o755)) - - cmd := exec.Command("bash", filepath.Join(repoRoot, "script", "prepare-release.sh"), "v1.2.3") - cmd.Dir = repoRoot - cmd.Env = append(os.Environ(), - "PATH="+tempBin+":"+os.Getenv("PATH"), - "GO_ARGS_LOG="+argsLog, - ) - - output, err := cmd.CombinedOutput() - require.NoError(t, err, string(output)) - - invocationBytes, err := os.ReadFile(argsLog) - require.NoError(t, err) - invocation := strings.TrimSpace(string(invocationBytes)) - - require.Contains(t, invocation, "run git.hrafn.xyz/aether/vociferate/cmd/releaseprep@latest") - require.Contains(t, invocation, "--root "+repoRoot) - require.Contains(t, invocation, "--version v1.2.3") - require.Contains(t, invocation, "--version-file internal/homesick/version/version.go") - require.Contains(t, invocation, "--version-pattern const String = \"([^\"]+)\"") - require.Contains(t, invocation, "--changelog changelog.md") - require.Regexp(t, regexp.MustCompile(`--date [0-9]{4}-[0-9]{2}-[0-9]{2}`), invocation) -} \ No newline at end of file -- 2.49.1 From ca3215f2c4503c9b07c0235b99d8785da085f0eb Mon Sep 17 00:00:00 2001 From: Micheal Wilkinson Date: Sat, 21 Mar 2026 11:09:40 +0000 Subject: [PATCH 100/162] docs: document vociferate release migration --- changelog.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/changelog.md b/changelog.md index 4cfda59..1b53aef 100644 --- a/changelog.md +++ b/changelog.md @@ -13,6 +13,7 @@ A `### Breaking` section is used in addition to Keep a Changelog's standard sect ### Added +- CI validation now runs `gosec` and `govulncheck` security scanning on push and pull request workflows. - `rc` command: executes all executable scripts inside a castle's `.homesick.d/` directory in sorted order, with the castle root as the working directory. stdout/stderr from each script is forwarded to the caller. - `rc` command: when a `.homesickrc` file exists and no `parity.rb` wrapper is present in `.homesick.d/`, a Ruby wrapper script (`parity.rb`) is generated automatically to preserve backwards compatibility. An existing `parity.rb` is never overwritten. - `exec` command: runs a shell command inside the target castle root directory. @@ -33,6 +34,7 @@ A `### Breaking` section is used in addition to Keep a Changelog's standard sect ### Changed +- Release automation now uses `aether/vociferate` `prepare` and `publish` actions (pinned to `v1.0.0`) instead of repository-local releaseprep wrappers. - CLI argument parsing migrated to Kong. - Git operations for clone and track migrated to `go-git`. - Build and behavior workflows now produce and run the `gosick` binary name. @@ -54,6 +56,7 @@ A `### Breaking` section is used in addition to Keep a Changelog's standard sect ### Removed +- Legacy `script/prepare-release.sh` releaseprep wrapper and its dedicated script test. - Legacy Ruby implementation and Ruby toolchain. - Legacy in-repository `releaseprep` package and command implementation, now superseded by the standalone `vociferate` tool. -- 2.49.1 From 692e205a63d7f8d1605270b448aac085588b4c23 Mon Sep 17 00:00:00 2001 From: Micheal Wilkinson Date: Sat, 21 Mar 2026 11:11:32 +0000 Subject: [PATCH 101/162] update vociferate version --- .gitea/workflows/prepare-release.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.gitea/workflows/prepare-release.yml b/.gitea/workflows/prepare-release.yml index 21ff4ac..91dcf84 100644 --- a/.gitea/workflows/prepare-release.yml +++ b/.gitea/workflows/prepare-release.yml @@ -17,7 +17,7 @@ jobs: fetch-depth: 0 - name: Vociferate prepare - uses: aether/vociferate/prepare@v1.0.0 + uses: aether/vociferate/prepare@v1.0.1 publish: needs: prepare @@ -29,4 +29,4 @@ jobs: fetch-depth: 0 - name: Vociferate publish - uses: aether/vociferate/publish@v1.0.0 \ No newline at end of file + uses: aether/vociferate/publish@v1.0.1 \ No newline at end of file -- 2.49.1 From ad5196420eb0201e5ceacfe24a8d37f728c14e48 Mon Sep 17 00:00:00 2001 From: Micheal Wilkinson Date: Sat, 21 Mar 2026 11:14:40 +0000 Subject: [PATCH 102/162] chore(go): enforce package coverage gates --- .gitea/workflows/pr-validation.yml | 60 +++++++++++++++++++++++++++- .gitea/workflows/push-validation.yml | 60 +++++++++++++++++++++++++++- cmd/homesick/main.go | 7 +++- cmd/homesick/main_test.go | 53 ++++++++++++++++++++++++ 4 files changed, 177 insertions(+), 3 deletions(-) create mode 100644 cmd/homesick/main_test.go diff --git a/.gitea/workflows/pr-validation.yml b/.gitea/workflows/pr-validation.yml index 5141ed3..e49ccae 100644 --- a/.gitea/workflows/pr-validation.yml +++ b/.gitea/workflows/pr-validation.yml @@ -59,13 +59,68 @@ jobs: run: | set -euo pipefail - go test -covermode=atomic -coverprofile=coverage.out ./... + go test -covermode=atomic -coverprofile=coverage.out ./... | tee go-test-coverage.log go tool cover -html=coverage.out -o coverage.html total="$(go tool cover -func=coverage.out | awk '/^total:/ {sub(/%/, "", $3); print $3}')" printf '{\n "total": "%s"\n}\n' "$total" > coverage-summary.json printf 'total=%s\n' "$total" >> "$GITHUB_OUTPUT" + set +e + awk ' + /^ok[[:space:]]/ && /coverage: [0-9.]+% of statements/ { + pkg = $2 + cov = $0 + sub(/^.*coverage: /, "", cov) + sub(/% of statements.*$/, "", cov) + status = "target" + if (cov + 0 < 50) { + status = "fail" + fail = 1 + } else if (cov + 0 < 65) { + status = "high-risk" + } else if (cov + 0 < 80) { + status = "warning" + } + printf "%s %.1f %s\n", pkg, cov + 0, status + } + END { + if (fail) { + exit 2 + } + } + ' go-test-coverage.log > coverage-packages.raw + package_gate_status=$? + set -e + + { + echo '| Package | Coverage | Status |' + echo '| --- | ---: | --- |' + } > coverage-packages.md + + while read -r pkg cov status; do + case "$status" in + fail) + pretty='FAIL (<50%)' + ;; + high-risk) + pretty='High risk (50%-64.99%)' + ;; + warning) + pretty='Warning (65%-79.99%)' + ;; + *) + pretty='Target (>=80%)' + ;; + esac + printf '| `%s` | %.1f%% | %s |\n' "$pkg" "$cov" "$pretty" >> coverage-packages.md + done < coverage-packages.raw + + if [[ "$package_gate_status" -ne 0 ]]; then + echo "Per-package coverage gate failed: one or more packages are below 50%." >&2 + exit 1 + fi + - name: Run security analysis run: | set -euo pipefail @@ -172,6 +227,9 @@ jobs: echo '- Total: `${{ steps.coverage.outputs.total }}%`' echo '- Report: ${{ steps.upload.outputs.report_url }}' echo '- Badge: ${{ steps.upload.outputs.badge_url }}' + echo + echo '### Package Coverage' + cat coverage-packages.md } >> "$GITHUB_STEP_SUMMARY" - name: Run behavior suite diff --git a/.gitea/workflows/push-validation.yml b/.gitea/workflows/push-validation.yml index 4cb5a9c..23e27a7 100644 --- a/.gitea/workflows/push-validation.yml +++ b/.gitea/workflows/push-validation.yml @@ -51,13 +51,68 @@ jobs: run: | set -euo pipefail - go test -covermode=atomic -coverprofile=coverage.out ./... + go test -covermode=atomic -coverprofile=coverage.out ./... | tee go-test-coverage.log go tool cover -html=coverage.out -o coverage.html total="$(go tool cover -func=coverage.out | awk '/^total:/ {sub(/%/, "", $3); print $3}')" printf '{\n "total": "%s"\n}\n' "$total" > coverage-summary.json printf 'total=%s\n' "$total" >> "$GITHUB_OUTPUT" + set +e + awk ' + /^ok[[:space:]]/ && /coverage: [0-9.]+% of statements/ { + pkg = $2 + cov = $0 + sub(/^.*coverage: /, "", cov) + sub(/% of statements.*$/, "", cov) + status = "target" + if (cov + 0 < 50) { + status = "fail" + fail = 1 + } else if (cov + 0 < 65) { + status = "high-risk" + } else if (cov + 0 < 80) { + status = "warning" + } + printf "%s %.1f %s\n", pkg, cov + 0, status + } + END { + if (fail) { + exit 2 + } + } + ' go-test-coverage.log > coverage-packages.raw + package_gate_status=$? + set -e + + { + echo '| Package | Coverage | Status |' + echo '| --- | ---: | --- |' + } > coverage-packages.md + + while read -r pkg cov status; do + case "$status" in + fail) + pretty='FAIL (<50%)' + ;; + high-risk) + pretty='High risk (50%-64.99%)' + ;; + warning) + pretty='Warning (65%-79.99%)' + ;; + *) + pretty='Target (>=80%)' + ;; + esac + printf '| `%s` | %.1f%% | %s |\n' "$pkg" "$cov" "$pretty" >> coverage-packages.md + done < coverage-packages.raw + + if [[ "$package_gate_status" -ne 0 ]]; then + echo "Per-package coverage gate failed: one or more packages are below 50%." >&2 + exit 1 + fi + - name: Run security analysis run: | set -euo pipefail @@ -128,6 +183,9 @@ jobs: echo '- Total: `${{ steps.coverage.outputs.total }}%`' echo '- Report: ${{ steps.upload.outputs.report_url }}' echo '- Badge: ${{ steps.upload.outputs.badge_url }}' + echo + echo '### Package Coverage' + cat coverage-packages.md } >> "$GITHUB_STEP_SUMMARY" - name: Run behavior suite on main pushes diff --git a/cmd/homesick/main.go b/cmd/homesick/main.go index 3eea38d..5af55ed 100644 --- a/cmd/homesick/main.go +++ b/cmd/homesick/main.go @@ -1,12 +1,17 @@ package main import ( + "io" "os" "git.hrafn.xyz/aether/gosick/internal/homesick/cli" ) func main() { - exitCode := cli.Run(os.Args[1:], os.Stdout, os.Stderr) + exitCode := run(os.Args[1:], os.Stdout, os.Stderr) os.Exit(exitCode) } + +func run(args []string, stdout io.Writer, stderr io.Writer) int { + return cli.Run(args, stdout, stderr) +} diff --git a/cmd/homesick/main_test.go b/cmd/homesick/main_test.go new file mode 100644 index 0000000..c307e7e --- /dev/null +++ b/cmd/homesick/main_test.go @@ -0,0 +1,53 @@ +package main + +import ( + "bytes" + "os" + "os/exec" + "testing" + + "git.hrafn.xyz/aether/gosick/internal/homesick/version" +) + +func TestRunVersionCommand(t *testing.T) { + stdout := &bytes.Buffer{} + stderr := &bytes.Buffer{} + + exitCode := run([]string{"version"}, stdout, stderr) + if exitCode != 0 { + t.Fatalf("run(version) exit code = %d, want 0", exitCode) + } + if got := stdout.String(); got != version.String+"\n" { + t.Fatalf("stdout = %q, want %q", got, version.String+"\n") + } + if got := stderr.String(); got != "" { + t.Fatalf("stderr = %q, want empty", got) + } + +} + +func TestMainVersionCommand(t *testing.T) { + if os.Getenv("GO_WANT_HELPER_PROCESS") == "1" { + os.Args = []string{"gosick", "version"} + main() + return + } + + cmd := exec.Command(os.Args[0], "-test.run=TestMainVersionCommand") + cmd.Env = append(os.Environ(), "GO_WANT_HELPER_PROCESS=1") + + stdout := &bytes.Buffer{} + stderr := &bytes.Buffer{} + cmd.Stdout = stdout + cmd.Stderr = stderr + + if err := cmd.Run(); err != nil { + t.Fatalf("helper process failed: %v, stderr: %s", err, stderr.String()) + } + if got := stdout.String(); got != version.String+"\n" { + t.Fatalf("stdout = %q, want %q", got, version.String+"\n") + } + if got := stderr.String(); got != "" { + t.Fatalf("stderr = %q, want empty", got) + } +} -- 2.49.1 From 001983b76eeb860e5d6eaa885ad28a74e0307a5f Mon Sep 17 00:00:00 2001 From: Micheal Wilkinson Date: Sat, 21 Mar 2026 11:14:43 +0000 Subject: [PATCH 103/162] docs: document coverage gate enforcement --- changelog.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/changelog.md b/changelog.md index 1b53aef..74b9f8c 100644 --- a/changelog.md +++ b/changelog.md @@ -14,6 +14,7 @@ A `### Breaking` section is used in addition to Keep a Changelog's standard sect ### Added - CI validation now runs `gosec` and `govulncheck` security scanning on push and pull request workflows. +- `cmd/homesick` now includes entrypoint-focused tests that exercise both the CLI run path and `main` process path. - `rc` command: executes all executable scripts inside a castle's `.homesick.d/` directory in sorted order, with the castle root as the working directory. stdout/stderr from each script is forwarded to the caller. - `rc` command: when a `.homesickrc` file exists and no `parity.rb` wrapper is present in `.homesick.d/`, a Ruby wrapper script (`parity.rb`) is generated automatically to preserve backwards compatibility. An existing `parity.rb` is never overwritten. - `exec` command: runs a shell command inside the target castle root directory. @@ -35,6 +36,7 @@ A `### Breaking` section is used in addition to Keep a Changelog's standard sect ### Changed - Release automation now uses `aether/vociferate` `prepare` and `publish` actions (pinned to `v1.0.0`) instead of repository-local releaseprep wrappers. +- Push and pull request validation now enforce per-package coverage gates (fail below 50%) and publish package-level coverage status tables in workflow summaries. - CLI argument parsing migrated to Kong. - Git operations for clone and track migrated to `go-git`. - Build and behavior workflows now produce and run the `gosick` binary name. -- 2.49.1 From 8a6a21811a7bbe1942734052378da71df991fd08 Mon Sep 17 00:00:00 2001 From: Micheal Wilkinson Date: Sat, 21 Mar 2026 11:18:10 +0000 Subject: [PATCH 104/162] chore(go): add failing core constructor tests --- internal/homesick/core/core_test.go | 27 ++++++++++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/internal/homesick/core/core_test.go b/internal/homesick/core/core_test.go index 9dcd918..1da9dff 100644 --- a/internal/homesick/core/core_test.go +++ b/internal/homesick/core/core_test.go @@ -1,6 +1,31 @@ package core -import "testing" +import ( + "bytes" + "testing" +) + +func TestNewRejectsNilWriters(t *testing.T) { + t.Run("nil stdout", func(t *testing.T) { + app, err := New(nil, &bytes.Buffer{}) + if err == nil { + t.Fatal("expected error for nil stdout") + } + if app != nil { + t.Fatal("expected nil app for nil stdout") + } + }) + + t.Run("nil stderr", func(t *testing.T) { + app, err := New(&bytes.Buffer{}, nil) + if err == nil { + t.Fatal("expected error for nil stderr") + } + if app != nil { + t.Fatal("expected nil app for nil stderr") + } + }) +} func TestDeriveDestination(t *testing.T) { tests := []struct { -- 2.49.1 From 7bc7ee47462193e58a2a1833ff6bfa472fa2d82c Mon Sep 17 00:00:00 2001 From: Micheal Wilkinson Date: Sat, 21 Mar 2026 11:18:45 +0000 Subject: [PATCH 105/162] chore(go): validate core constructor dependencies --- internal/homesick/core/core.go | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/internal/homesick/core/core.go b/internal/homesick/core/core.go index f3c3f36..6668437 100644 --- a/internal/homesick/core/core.go +++ b/internal/homesick/core/core.go @@ -28,6 +28,13 @@ type App struct { } func New(stdout io.Writer, stderr io.Writer) (*App, error) { + if stdout == nil { + return nil, errors.New("stdout writer cannot be nil") + } + if stderr == nil { + return nil, errors.New("stderr writer cannot be nil") + } + home, err := os.UserHomeDir() if err != nil { return nil, fmt.Errorf("resolve home directory: %w", err) -- 2.49.1 From cd92a961bd99caa478724fff234f9c3977a4f5be Mon Sep 17 00:00:00 2001 From: Micheal Wilkinson Date: Sat, 21 Mar 2026 11:20:09 +0000 Subject: [PATCH 106/162] chore(go): harden ci process workflows --- .gitea/workflows/pr-validation.yml | 17 ++++++- .gitea/workflows/push-validation.yml | 17 ++++++- .gitea/workflows/tag-build-artifacts.yml | 59 +++--------------------- justfile | 5 ++ 4 files changed, 44 insertions(+), 54 deletions(-) diff --git a/.gitea/workflows/pr-validation.yml b/.gitea/workflows/pr-validation.yml index e49ccae..5ffd3b0 100644 --- a/.gitea/workflows/pr-validation.yml +++ b/.gitea/workflows/pr-validation.yml @@ -22,6 +22,7 @@ jobs: AWS_SECRET_ACCESS_KEY: ${{ secrets.ARTEFACT_BUCKET_WRITE_ACCESS_SECRET }} AWS_DEFAULT_REGION: ${{ vars.ARTEFACT_BUCKET_REGION }} AWS_EC2_METADATA_DISABLED: true + SUMMARY_FILE: ${{ runner.temp }}/summary.md steps: - name: Checkout uses: actions/checkout@v4 @@ -34,6 +35,13 @@ jobs: cache: true cache-dependency-path: go.sum + - name: Verify module hygiene + run: | + set -euo pipefail + go mod tidy + git diff --exit-code go.mod go.sum + go mod verify + - name: Install security tools run: | set -euo pipefail @@ -230,7 +238,14 @@ jobs: echo echo '### Package Coverage' cat coverage-packages.md - } >> "$GITHUB_STEP_SUMMARY" + } >> "$SUMMARY_FILE" - name: Run behavior suite run: ./script/run-behavior-suite-docker.sh + + - name: Summary + if: ${{ always() }} + run: | + if [[ -f "$SUMMARY_FILE" ]]; then + cat "$SUMMARY_FILE" >> "$GITHUB_STEP_SUMMARY" + fi diff --git a/.gitea/workflows/push-validation.yml b/.gitea/workflows/push-validation.yml index 23e27a7..ae13ffc 100644 --- a/.gitea/workflows/push-validation.yml +++ b/.gitea/workflows/push-validation.yml @@ -22,6 +22,7 @@ jobs: AWS_SECRET_ACCESS_KEY: ${{ secrets.ARTEFACT_BUCKET_WRITE_ACCESS_SECRET }} AWS_DEFAULT_REGION: ${{ vars.ARTEFACT_BUCKET_REGION }} AWS_EC2_METADATA_DISABLED: true + SUMMARY_FILE: ${{ runner.temp }}/summary.md steps: - name: Checkout uses: actions/checkout@v4 @@ -34,6 +35,13 @@ jobs: cache: true cache-dependency-path: go.sum + - name: Verify module hygiene + run: | + set -euo pipefail + go mod tidy + git diff --exit-code go.mod go.sum + go mod verify + - name: Install security tools run: | set -euo pipefail @@ -186,8 +194,15 @@ jobs: echo echo '### Package Coverage' cat coverage-packages.md - } >> "$GITHUB_STEP_SUMMARY" + } >> "$SUMMARY_FILE" - name: Run behavior suite on main pushes if: ${{ github.ref == 'refs/heads/main' }} run: ./script/run-behavior-suite-docker.sh + + - name: Summary + if: ${{ always() }} + run: | + if [[ -f "$SUMMARY_FILE" ]]; then + cat "$SUMMARY_FILE" >> "$GITHUB_STEP_SUMMARY" + fi diff --git a/.gitea/workflows/tag-build-artifacts.yml b/.gitea/workflows/tag-build-artifacts.yml index f5c7ecf..815d319 100644 --- a/.gitea/workflows/tag-build-artifacts.yml +++ b/.gitea/workflows/tag-build-artifacts.yml @@ -30,6 +30,8 @@ jobs: steps: - name: Checkout uses: actions/checkout@v4 + with: + fetch-depth: 0 - name: Setup Go uses: actions/setup-go@v5 @@ -74,58 +76,11 @@ jobs: release: runs-on: ubuntu-latest needs: build - env: - RELEASE_TOKEN: ${{ secrets.GITHUB_TOKEN }} steps: - - name: Download build artifacts - uses: actions/download-artifact@v4 + - name: Checkout + uses: actions/checkout@v4 with: - path: dist + fetch-depth: 0 - - name: Ensure jq is installed - run: | - if ! command -v jq >/dev/null 2>&1; then - sudo apt-get update - sudo apt-get install -y jq - fi - - - name: Create release if needed and upload assets - run: | - set -euo pipefail - - if [[ -z "${RELEASE_TOKEN:-}" ]]; then - echo "RELEASE_TOKEN is empty. Expected secrets.GITHUB_TOKEN to be available." >&2 - exit 1 - fi - - tag="${GITHUB_REF_NAME}" - api_base="${GITHUB_SERVER_URL}/api/v1/repos/${GITHUB_REPOSITORY}" - - release_json="$(curl -sS -H "Authorization: token ${RELEASE_TOKEN}" "${api_base}/releases/tags/${tag}" || true)" - release_id="$(printf '%s' "${release_json}" | jq -r '.id // empty')" - - if [[ -z "${release_id}" ]]; then - create_payload="$(jq -n --arg tag "${tag}" --arg name "${tag}" '{tag_name:$tag, name:$name, draft:false, prerelease:false}')" - release_json="$(curl -sS -X POST \ - -H "Authorization: token ${RELEASE_TOKEN}" \ - -H "Content-Type: application/json" \ - -d "${create_payload}" \ - "${api_base}/releases")" - release_id="$(printf '%s' "${release_json}" | jq -r '.id // empty')" - fi - - if [[ -z "${release_id}" ]]; then - echo "Unable to determine or create release id for tag ${tag}" >&2 - printf '%s\n' "${release_json}" >&2 - exit 1 - fi - - find dist -type f -name '*.tar.gz' -print0 | while IFS= read -r -d '' file; do - asset_name="$(basename "${file}")" - curl -sS -X POST \ - -H "Authorization: token ${RELEASE_TOKEN}" \ - -H "Content-Type: application/octet-stream" \ - --data-binary @"${file}" \ - "${api_base}/releases/${release_id}/assets?name=${asset_name}" - echo "Uploaded ${asset_name}" - done + - name: Vociferate publish + uses: aether/vociferate/publish@v1.0.1 diff --git a/justfile b/justfile index e660f37..dbceb59 100644 --- a/justfile +++ b/justfile @@ -14,6 +14,11 @@ go-build-linux: go-test: go test ./... +go-mod-hygiene: + go mod tidy + git diff --exit-code go.mod go.sum + go mod verify + go-security: gosec ./... govulncheck ./... -- 2.49.1 From 55867df5990bb955a38b730c02033eb6b3522962 Mon Sep 17 00:00:00 2001 From: Micheal Wilkinson Date: Sat, 21 Mar 2026 11:20:23 +0000 Subject: [PATCH 107/162] docs: align changelog with current workflows --- changelog.md | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/changelog.md b/changelog.md index 74b9f8c..4bdb235 100644 --- a/changelog.md +++ b/changelog.md @@ -28,15 +28,14 @@ A `### Breaking` section is used in addition to Keep a Changelog's standard sect - Just workflow support for building and running the Linux behavior binary. - Coverage reports and badges published to shared object storage for branches and pull requests. - Pull requests now receive coverage report links in CI comments. -- Manual release preparation workflow and script to bump the reported version, promote unreleased changelog notes, and create release tags. -- Main branch validation now emits a recommended next release tag based on unreleased changelog sections, and release preparation now rejects empty unreleased notes. -- Release recommendations now support an explicit `### Breaking` section for major-version changes that would otherwise be described under `### Changed`. +- Automated release orchestration now runs through vociferate prepare and publish workflows. - `symlink` command alias compatibility for `link`. ### Changed -- Release automation now uses `aether/vociferate` `prepare` and `publish` actions (pinned to `v1.0.0`) instead of repository-local releaseprep wrappers. +- Release automation now uses `aether/vociferate` `prepare` and `publish` actions (pinned to `v1.0.1`) instead of repository-local releaseprep wrappers. - Push and pull request validation now enforce per-package coverage gates (fail below 50%) and publish package-level coverage status tables in workflow summaries. +- Push and pull request validation now verify module hygiene (`go mod tidy`, `go mod verify`) and use a dedicated summary-file pattern with a final always-run summary step. - CLI argument parsing migrated to Kong. - Git operations for clone and track migrated to `go-git`. - Build and behavior workflows now produce and run the `gosick` binary name. @@ -46,7 +45,6 @@ A `### Breaking` section is used in addition to Keep a Changelog's standard sect - CLI help now uses the invoked binary name (defaulting to `gosick`) in usage output. - CLI help description now reflects Homesick's purpose for managing precious dotfiles. - Release notes standardized to Keep a Changelog format. -- Release preparation and next-tag recommendation automation now invoke `vociferate`'s standalone `releaseprep` implementation with explicit gosick paths. - `commit` command now accepts legacy positional form `commit ` in addition to `-m`. - `destroy` now prompts for confirmation by default and preserves the castle when declined. -- 2.49.1 From ef554dde2dae02ffd1cc4ec7242142cff7a0b956 Mon Sep 17 00:00:00 2001 From: Micheal Wilkinson Date: Sat, 21 Mar 2026 11:21:56 +0000 Subject: [PATCH 108/162] docs: rename changelog file --- changelog.md => CHANGELOG.md | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename changelog.md => CHANGELOG.md (100%) diff --git a/changelog.md b/CHANGELOG.md similarity index 100% rename from changelog.md rename to CHANGELOG.md -- 2.49.1 From 5ecbad8f279b2ccf82358002da622889197c778f Mon Sep 17 00:00:00 2001 From: Micheal Wilkinson Date: Sat, 21 Mar 2026 11:24:24 +0000 Subject: [PATCH 109/162] chore(go): keep changelog filename compatibility --- .gitea/workflows/prepare-release.yml | 14 ++++++++++++++ .gitea/workflows/tag-build-artifacts.yml | 7 +++++++ 2 files changed, 21 insertions(+) diff --git a/.gitea/workflows/prepare-release.yml b/.gitea/workflows/prepare-release.yml index 91dcf84..e4f3e99 100644 --- a/.gitea/workflows/prepare-release.yml +++ b/.gitea/workflows/prepare-release.yml @@ -16,6 +16,13 @@ jobs: with: fetch-depth: 0 + - name: Provide lowercase changelog compatibility + run: | + set -euo pipefail + if [[ -f CHANGELOG.md && ! -e changelog.md ]]; then + ln -s CHANGELOG.md changelog.md + fi + - name: Vociferate prepare uses: aether/vociferate/prepare@v1.0.1 @@ -28,5 +35,12 @@ jobs: with: fetch-depth: 0 + - name: Provide lowercase changelog compatibility + run: | + set -euo pipefail + if [[ -f CHANGELOG.md && ! -e changelog.md ]]; then + ln -s CHANGELOG.md changelog.md + fi + - name: Vociferate publish uses: aether/vociferate/publish@v1.0.1 \ No newline at end of file diff --git a/.gitea/workflows/tag-build-artifacts.yml b/.gitea/workflows/tag-build-artifacts.yml index 815d319..8e9795a 100644 --- a/.gitea/workflows/tag-build-artifacts.yml +++ b/.gitea/workflows/tag-build-artifacts.yml @@ -82,5 +82,12 @@ jobs: with: fetch-depth: 0 + - name: Provide lowercase changelog compatibility + run: | + set -euo pipefail + if [[ -f CHANGELOG.md && ! -e changelog.md ]]; then + ln -s CHANGELOG.md changelog.md + fi + - name: Vociferate publish uses: aether/vociferate/publish@v1.0.1 -- 2.49.1 From b235c6ca453ea7b1f31271482de0be5ed091979c Mon Sep 17 00:00:00 2001 From: Micheal Wilkinson Date: Sat, 21 Mar 2026 11:31:52 +0000 Subject: [PATCH 110/162] chore(go): wire coverage-badge action inputs --- .gitea/workflows/push-validation.yml | 85 +++++----------------------- 1 file changed, 15 insertions(+), 70 deletions(-) diff --git a/.gitea/workflows/push-validation.yml b/.gitea/workflows/push-validation.yml index ae13ffc..c5c9da0 100644 --- a/.gitea/workflows/push-validation.yml +++ b/.gitea/workflows/push-validation.yml @@ -55,7 +55,7 @@ jobs: run: aws --version - name: Run full unit test suite with coverage - id: coverage + id: coverage-tests run: | set -euo pipefail @@ -121,81 +121,26 @@ jobs: exit 1 fi + - name: Publish coverage artefacts + id: coverage-badge + uses: git.hrafn.xyz/aether/vociferate/coverage-badge@v1.0.1 + with: + coverage-profile: coverage.out + coverage-html: coverage.html + coverage-badge: coverage-badge.svg + coverage-summary: coverage-summary.json + artefact-bucket-name: ${{ vars.ARTEFACT_BUCKET_NAME }} + artefact-bucket-endpoint: ${{ vars.ARTEFACT_BUCKET_ENDPONT }} + branch-name: ${{ github.ref_name }} + repository-name: ${{ github.repository }} + summary-file: ${{ env.SUMMARY_FILE }} + - name: Run security analysis run: | set -euo pipefail "$(go env GOPATH)/bin/gosec" ./... "$(go env GOPATH)/bin/govulncheck" ./... - - name: Generate coverage badge - env: - COVERAGE_TOTAL: ${{ steps.coverage.outputs.total }} - run: | - set -euo pipefail - - color="$(awk -v total="$COVERAGE_TOTAL" 'BEGIN { - if (total >= 80) print "brightgreen"; - else if (total >= 70) print "green"; - else if (total >= 60) print "yellowgreen"; - else if (total >= 50) print "yellow"; - else print "red"; - }')" - - cat > coverage-badge.svg < - - - - - - - - - - - - - - coverage - coverage - ${COVERAGE_TOTAL}% - ${COVERAGE_TOTAL}% - - - EOF - - - name: Upload branch coverage artefacts - id: upload - run: | - set -euo pipefail - - aws configure set default.s3.addressing_style path - - repo_name="${GITHUB_REPOSITORY##*/}" - prefix="${repo_name}/branch/${GITHUB_REF_NAME}" - report_url="${ARTEFACT_BUCKET_ENDPONT%/}/${ARTEFACT_BUCKET_NAME}/${prefix}/coverage.html" - badge_url="${ARTEFACT_BUCKET_ENDPONT%/}/${ARTEFACT_BUCKET_NAME}/${prefix}/coverage-badge.svg" - - aws --endpoint-url "${ARTEFACT_BUCKET_ENDPONT}" s3 cp coverage.html "s3://${ARTEFACT_BUCKET_NAME}/${prefix}/coverage.html" --content-type text/html - aws --endpoint-url "${ARTEFACT_BUCKET_ENDPONT}" s3 cp coverage-badge.svg "s3://${ARTEFACT_BUCKET_NAME}/${prefix}/coverage-badge.svg" --content-type image/svg+xml - aws --endpoint-url "${ARTEFACT_BUCKET_ENDPONT}" s3 cp coverage-summary.json "s3://${ARTEFACT_BUCKET_NAME}/${prefix}/coverage-summary.json" --content-type application/json - - printf 'report_url=%s\n' "$report_url" >> "$GITHUB_OUTPUT" - printf 'badge_url=%s\n' "$badge_url" >> "$GITHUB_OUTPUT" - - - name: Add coverage summary - run: | - { - echo '## Coverage' - echo - echo '- Total: `${{ steps.coverage.outputs.total }}%`' - echo '- Report: ${{ steps.upload.outputs.report_url }}' - echo '- Badge: ${{ steps.upload.outputs.badge_url }}' - echo - echo '### Package Coverage' - cat coverage-packages.md - } >> "$SUMMARY_FILE" - - name: Run behavior suite on main pushes if: ${{ github.ref == 'refs/heads/main' }} run: ./script/run-behavior-suite-docker.sh -- 2.49.1 From fc9a30fed1dff3a89b3f4e1a2141fb49780c9e93 Mon Sep 17 00:00:00 2001 From: Micheal Wilkinson Date: Sat, 21 Mar 2026 11:52:50 +0000 Subject: [PATCH 111/162] chore(go): use explicit self-hosted action urls --- .gitea/workflows/prepare-release.yml | 4 ++-- .gitea/workflows/push-validation.yml | 2 +- .gitea/workflows/tag-build-artifacts.yml | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.gitea/workflows/prepare-release.yml b/.gitea/workflows/prepare-release.yml index e4f3e99..e088203 100644 --- a/.gitea/workflows/prepare-release.yml +++ b/.gitea/workflows/prepare-release.yml @@ -24,7 +24,7 @@ jobs: fi - name: Vociferate prepare - uses: aether/vociferate/prepare@v1.0.1 + uses: https://git.hrafn.xyz/aether/vociferate/prepare@v1.0.1 publish: needs: prepare @@ -43,4 +43,4 @@ jobs: fi - name: Vociferate publish - uses: aether/vociferate/publish@v1.0.1 \ No newline at end of file + uses: https://git.hrafn.xyz/aether/vociferate/publish@v1.0.1 \ No newline at end of file diff --git a/.gitea/workflows/push-validation.yml b/.gitea/workflows/push-validation.yml index c5c9da0..e29900b 100644 --- a/.gitea/workflows/push-validation.yml +++ b/.gitea/workflows/push-validation.yml @@ -123,7 +123,7 @@ jobs: - name: Publish coverage artefacts id: coverage-badge - uses: git.hrafn.xyz/aether/vociferate/coverage-badge@v1.0.1 + uses: https://git.hrafn.xyz/aether/vociferate/coverage-badge@v1.0.1 with: coverage-profile: coverage.out coverage-html: coverage.html diff --git a/.gitea/workflows/tag-build-artifacts.yml b/.gitea/workflows/tag-build-artifacts.yml index 8e9795a..97b706f 100644 --- a/.gitea/workflows/tag-build-artifacts.yml +++ b/.gitea/workflows/tag-build-artifacts.yml @@ -90,4 +90,4 @@ jobs: fi - name: Vociferate publish - uses: aether/vociferate/publish@v1.0.1 + uses: https://git.hrafn.xyz/aether/vociferate/publish@v1.0.1 -- 2.49.1 From 19c9e5485b74ad7123fb16f4dedc11e5582a6df3 Mon Sep 17 00:00:00 2001 From: Micheal Wilkinson Date: Sat, 21 Mar 2026 12:02:52 +0000 Subject: [PATCH 112/162] chore(go): tidy module metadata --- go.mod | 6 ++++-- go.sum | 6 ++++++ 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/go.mod b/go.mod index b925b1e..ab4a69a 100644 --- a/go.mod +++ b/go.mod @@ -6,13 +6,15 @@ toolchain go1.26.1 require github.com/stretchr/testify v1.10.0 -require github.com/go-git/go-git/v5 v5.14.0 +require ( + github.com/alecthomas/kong v1.12.1 + github.com/go-git/go-git/v5 v5.14.0 +) require ( dario.cat/mergo v1.0.0 // indirect github.com/Microsoft/go-winio v0.6.2 // indirect github.com/ProtonMail/go-crypto v1.1.5 // indirect - github.com/alecthomas/kong v1.12.1 // indirect github.com/cloudflare/circl v1.6.0 // indirect github.com/cyphar/filepath-securejoin v0.4.1 // indirect github.com/davecgh/go-spew v1.1.1 // indirect diff --git a/go.sum b/go.sum index e09d4cf..26c5c02 100644 --- a/go.sum +++ b/go.sum @@ -5,8 +5,12 @@ github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERo github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= github.com/ProtonMail/go-crypto v1.1.5 h1:eoAQfK2dwL+tFSFpr7TbOaPNUbPiJj4fLYwwGE1FQO4= github.com/ProtonMail/go-crypto v1.1.5/go.mod h1:rA3QumHc/FZ8pAHreoekgiAbzpNsfQAosU5td4SnOrE= +github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0= +github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k= github.com/alecthomas/kong v1.12.1 h1:iq6aMJDcFYP9uFrLdsiZQ2ZMmcshduyGv4Pek0MQPW0= github.com/alecthomas/kong v1.12.1/go.mod h1:p2vqieVMeTAnaC83txKtXe8FLke2X07aruPWXyMPQrU= +github.com/alecthomas/repr v0.4.0 h1:GhI2A8MACjfegCPVq9f1FLvIBS+DrQ2KQBFZP1iFzXc= +github.com/alecthomas/repr v0.4.0/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4= github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8= github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4= github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio= @@ -36,6 +40,8 @@ github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8J github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= +github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg= github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A= github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo= github.com/kevinburke/ssh_config v1.2.0 h1:x584FjTGwHzMwvHx18PXxbBVzfnxogHaAReU4gf13a4= -- 2.49.1 From 332de3a3f6358b5a5405464a79d26eaec60cebca Mon Sep 17 00:00:00 2001 From: Micheal Wilkinson Date: Sat, 21 Mar 2026 12:18:35 +0000 Subject: [PATCH 113/162] chore(go): prepare ci runtime for rc and commit tests --- .gitea/workflows/pr-validation.yml | 8 ++++++++ .gitea/workflows/push-validation.yml | 8 ++++++++ 2 files changed, 16 insertions(+) diff --git a/.gitea/workflows/pr-validation.yml b/.gitea/workflows/pr-validation.yml index 5ffd3b0..70e023c 100644 --- a/.gitea/workflows/pr-validation.yml +++ b/.gitea/workflows/pr-validation.yml @@ -62,6 +62,14 @@ jobs: apt-get install -y jq fi + - name: Prepare test runtime + run: | + set -euo pipefail + apt-get update + apt-get install -y ruby + git config --global user.name "gitea-actions[bot]" + git config --global user.email "gitea-actions[bot]@users.noreply.local" + - name: Run full unit test suite with coverage id: coverage run: | diff --git a/.gitea/workflows/push-validation.yml b/.gitea/workflows/push-validation.yml index e29900b..08b5c7d 100644 --- a/.gitea/workflows/push-validation.yml +++ b/.gitea/workflows/push-validation.yml @@ -54,6 +54,14 @@ jobs: - name: Verify AWS CLI run: aws --version + - name: Prepare test runtime + run: | + set -euo pipefail + apt-get update + apt-get install -y ruby + git config --global user.name "gitea-actions[bot]" + git config --global user.email "gitea-actions[bot]@users.noreply.local" + - name: Run full unit test suite with coverage id: coverage-tests run: | -- 2.49.1 From 106e45d16b9546b3e1970113a776ad25f34a76c2 Mon Sep 17 00:00:00 2001 From: Micheal Wilkinson Date: Sat, 21 Mar 2026 12:52:34 +0000 Subject: [PATCH 114/162] chore(ci): cache security tool binaries in validation workflows --- .gitea/workflows/pr-validation.yml | 10 ++++++++++ .gitea/workflows/push-validation.yml | 10 ++++++++++ 2 files changed, 20 insertions(+) diff --git a/.gitea/workflows/pr-validation.yml b/.gitea/workflows/pr-validation.yml index 70e023c..aa594bb 100644 --- a/.gitea/workflows/pr-validation.yml +++ b/.gitea/workflows/pr-validation.yml @@ -35,6 +35,16 @@ jobs: cache: true cache-dependency-path: go.sum + - name: Cache security tools + uses: actions/cache@v4 + with: + path: | + ~/.cache/go-build + ~/go/bin + key: ${{ runner.os }}-go-security-tools-${{ hashFiles('**/go.mod', '**/go.sum') }} + restore-keys: | + ${{ runner.os }}-go-security-tools- + - name: Verify module hygiene run: | set -euo pipefail diff --git a/.gitea/workflows/push-validation.yml b/.gitea/workflows/push-validation.yml index 08b5c7d..3d4f998 100644 --- a/.gitea/workflows/push-validation.yml +++ b/.gitea/workflows/push-validation.yml @@ -35,6 +35,16 @@ jobs: cache: true cache-dependency-path: go.sum + - name: Cache security tools + uses: actions/cache@v4 + with: + path: | + ~/.cache/go-build + ~/go/bin + key: ${{ runner.os }}-go-security-tools-${{ hashFiles('**/go.mod', '**/go.sum') }} + restore-keys: | + ${{ runner.os }}-go-security-tools- + - name: Verify module hygiene run: | set -euo pipefail -- 2.49.1 From 0d3c9b5214fb4d3b673472ee7f89fa08fe7c5329 Mon Sep 17 00:00:00 2001 From: Micheal Wilkinson Date: Sat, 21 Mar 2026 13:05:08 +0000 Subject: [PATCH 115/162] chore(security): resolve gosec findings with permission fixes and #nosec suppressions --- internal/homesick/core/core.go | 30 +++++++++++++++++------------- 1 file changed, 17 insertions(+), 13 deletions(-) diff --git a/internal/homesick/core/core.go b/internal/homesick/core/core.go index 6668437..402220b 100644 --- a/internal/homesick/core/core.go +++ b/internal/homesick/core/core.go @@ -71,7 +71,7 @@ func (a *App) Clone(uri string, destination string) error { return fmt.Errorf("unable to derive destination from uri %q", uri) } - if err := os.MkdirAll(a.ReposDir, 0o755); err != nil { + if err := os.MkdirAll(a.ReposDir, 0o750); err != nil { return fmt.Errorf("create repos directory: %w", err) } @@ -96,7 +96,7 @@ func (a *App) Clone(uri string, destination string) error { } func (a *App) List() error { - if err := os.MkdirAll(a.ReposDir, 0o755); err != nil { + if err := os.MkdirAll(a.ReposDir, 0o750); err != nil { return err } @@ -297,7 +297,7 @@ func (a *App) Open(castle string) error { } castleRoot := filepath.Join(a.ReposDir, castle) - cmd := exec.Command("sh", "-c", editor+" .") + cmd := exec.Command(editor, ".") // #nosec G204 — EDITOR environment variable is user-set cmd.Dir = castleRoot cmd.Stdout = a.Stdout cmd.Stderr = a.Stderr @@ -327,7 +327,7 @@ func (a *App) Exec(castle string, command []string) error { return nil } - cmd := exec.Command("sh", "-c", commandString) + cmd := exec.Command("sh", "-c", commandString) // #nosec G204 — intentional shell command execution feature cmd.Dir = castleRoot cmd.Stdout = a.Stdout cmd.Stderr = a.Stderr @@ -393,7 +393,7 @@ func (a *App) Generate(castlePath string) error { return err } - if err := os.MkdirAll(absCastle, 0o755); err != nil { + if err := os.MkdirAll(absCastle, 0o750); err != nil { return err } @@ -414,7 +414,7 @@ func (a *App) Generate(castlePath string) error { } } - return os.MkdirAll(filepath.Join(absCastle, "home"), 0o755) + return os.MkdirAll(filepath.Join(absCastle, "home"), 0o750) } func (a *App) Link(castle string) error { @@ -538,7 +538,7 @@ func (a *App) TrackPath(filePath string, castle string) error { if relativeDir == "." { castleTargetDir = castleHome } - if err := os.MkdirAll(castleTargetDir, 0o755); err != nil { + if err := os.MkdirAll(castleTargetDir, 0o750); err != nil { return err } @@ -605,7 +605,7 @@ func appendUniqueSubdir(path string, subdir string) (bool, error) { } } - file, err := os.OpenFile(path, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o644) + file, err := os.OpenFile(path, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o600) // #nosec G304 — internal metadata file if err != nil { return false, err } @@ -718,7 +718,7 @@ func (a *App) linkPath(source string, destination string) error { return err } - if err := os.MkdirAll(filepath.Dir(destination), 0o755); err != nil { + if err := os.MkdirAll(filepath.Dir(destination), 0o750); err != nil { return err } @@ -750,7 +750,7 @@ func (a *App) linkPath(source string, destination string) error { } func readSubdirs(path string) ([]string, error) { - data, err := os.ReadFile(path) + data, err := os.ReadFile(path) // #nosec G304 — internal metadata file if err != nil { if errors.Is(err, os.ErrNotExist) { return []string{}, nil @@ -866,7 +866,7 @@ func (a *App) Rc(castle string) error { if _, err := os.Stat(homesickRc); err == nil { wrapperPath := filepath.Join(homesickD, "parity.rb") if _, err := os.Stat(wrapperPath); errors.Is(err, os.ErrNotExist) { - if mkErr := os.MkdirAll(homesickD, 0o755); mkErr != nil { + if mkErr := os.MkdirAll(homesickD, 0o750); mkErr != nil { return fmt.Errorf("create .homesick.d: %w", mkErr) } wrapperContent := "#!/usr/bin/env ruby\n" + @@ -874,9 +874,13 @@ func (a *App) Rc(castle string) error { "# Evaluates .homesickrc in the context of the castle root.\n" + "rc_file = File.join(__dir__, '..', '.homesickrc')\n" + "eval(File.read(rc_file), binding, rc_file) if File.exist?(rc_file)\n" - if writeErr := os.WriteFile(wrapperPath, []byte(wrapperContent), 0o755); writeErr != nil { + if writeErr := os.WriteFile(wrapperPath, []byte(wrapperContent), 0o600); writeErr != nil { return fmt.Errorf("write parity.rb: %w", writeErr) } + // #nosec G302 -- script wrapper must be executable to run properly + if chmodErr := os.Chmod(wrapperPath, 0o700); chmodErr != nil { + return fmt.Errorf("chmod parity.rb: %w", chmodErr) + } } } @@ -906,7 +910,7 @@ func (a *App) Rc(castle string) error { continue } scriptPath := filepath.Join(homesickD, entry.Name()) - cmd := exec.Command(scriptPath) + cmd := exec.Command(scriptPath) // #nosec G204 — path validated from app-controlled .homesick.d directory cmd.Dir = castleRoot cmd.Stdout = a.Stdout cmd.Stderr = a.Stderr -- 2.49.1 From a92ab1a29c2c26f8d1556cfbce0d84af6afd5105 Mon Sep 17 00:00:00 2001 From: Micheal Wilkinson Date: Sat, 21 Mar 2026 13:08:24 +0000 Subject: [PATCH 116/162] docs: document security hardening improvements --- CHANGELOG.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4bdb235..70c3f29 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,11 @@ A `### Breaking` section is used in addition to Keep a Changelog's standard sect ### Added +### Changed + +- Security hardening: file and directory creation now uses restrictive permissions (`0o750` for directories, `0o600` for files) instead of world-accessible defaults. Executable wrapper scripts are created with restricted permissions and then explicitly made executable via `chmod`. +- Security: `Open()` now executes the editor directly without shell intermediary to prevent injection through the `$EDITOR` environment variable. + - CI validation now runs `gosec` and `govulncheck` security scanning on push and pull request workflows. - `cmd/homesick` now includes entrypoint-focused tests that exercise both the CLI run path and `main` process path. - `rc` command: executes all executable scripts inside a castle's `.homesick.d/` directory in sorted order, with the castle root as the working directory. stdout/stderr from each script is forwarded to the caller. -- 2.49.1 From fb4b3f7ed196e7a9ca6fbdd072a060c4f8fb4485 Mon Sep 17 00:00:00 2001 From: Micheal Wilkinson Date: Sat, 21 Mar 2026 13:12:42 +0000 Subject: [PATCH 117/162] chore(ci): add explicit go modules cache to validation workflows --- .gitea/workflows/pr-validation.yml | 10 ++++++++++ .gitea/workflows/push-validation.yml | 10 ++++++++++ 2 files changed, 20 insertions(+) diff --git a/.gitea/workflows/pr-validation.yml b/.gitea/workflows/pr-validation.yml index aa594bb..1dbfb9d 100644 --- a/.gitea/workflows/pr-validation.yml +++ b/.gitea/workflows/pr-validation.yml @@ -35,6 +35,16 @@ jobs: cache: true cache-dependency-path: go.sum + - name: Cache Go modules and build cache + uses: actions/cache@v4 + with: + path: | + ~/go/pkg/mod + ~/.cache/go-build + key: ${{ runner.os }}-go-modules-${{ hashFiles('**/go.mod', '**/go.sum') }} + restore-keys: | + ${{ runner.os }}-go-modules- + - name: Cache security tools uses: actions/cache@v4 with: diff --git a/.gitea/workflows/push-validation.yml b/.gitea/workflows/push-validation.yml index 3d4f998..9da2da7 100644 --- a/.gitea/workflows/push-validation.yml +++ b/.gitea/workflows/push-validation.yml @@ -35,6 +35,16 @@ jobs: cache: true cache-dependency-path: go.sum + - name: Cache Go modules and build cache + uses: actions/cache@v4 + with: + path: | + ~/go/pkg/mod + ~/.cache/go-build + key: ${{ runner.os }}-go-modules-${{ hashFiles('**/go.mod', '**/go.sum') }} + restore-keys: | + ${{ runner.os }}-go-modules- + - name: Cache security tools uses: actions/cache@v4 with: -- 2.49.1 From 4cfda23187abeed0a37b37262f28ac8d4e731956 Mon Sep 17 00:00:00 2001 From: Micheal Wilkinson Date: Sat, 21 Mar 2026 13:13:43 +0000 Subject: [PATCH 118/162] docs: document ci caching improvements --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 70c3f29..4701afe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ A `### Breaking` section is used in addition to Keep a Changelog's standard sect ### Changed +- CI workflows now include explicit caching for Go modules, build artifacts, and security tool binaries to reduce pipeline execution time. - Security hardening: file and directory creation now uses restrictive permissions (`0o750` for directories, `0o600` for files) instead of world-accessible defaults. Executable wrapper scripts are created with restricted permissions and then explicitly made executable via `chmod`. - Security: `Open()` now executes the editor directly without shell intermediary to prevent injection through the `$EDITOR` environment variable. -- 2.49.1 From 2cf5851231cb0dc2702374bb7edd32e5bc701d4c Mon Sep 17 00:00:00 2001 From: Micheal Wilkinson Date: Sat, 21 Mar 2026 13:15:04 +0000 Subject: [PATCH 119/162] chore(deps): update vulnerable dependencies to patched versions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - cloudflare/circl v1.6.0 → v1.6.3 (fixes GO-2026-4550, GO-2025-3754) - go-git/go-git/v5 v5.14.0 → v5.17.0 (fixes GO-2026-4473) - golang.org/x/crypto v0.35.0 → v0.49.0 (fixes GO-2025-4116) - golang.org/x/net v0.35.0 → v0.52.0 (fixes GO-2025-3503) --- go.mod | 29 +++++++++++++------------- go.sum | 66 ++++++++++++++++++++++++++++++---------------------------- 2 files changed, 49 insertions(+), 46 deletions(-) diff --git a/go.mod b/go.mod index ab4a69a..4034a68 100644 --- a/go.mod +++ b/go.mod @@ -4,34 +4,35 @@ go 1.26 toolchain go1.26.1 -require github.com/stretchr/testify v1.10.0 +require github.com/stretchr/testify v1.11.1 require ( github.com/alecthomas/kong v1.12.1 - github.com/go-git/go-git/v5 v5.14.0 + github.com/go-git/go-git/v5 v5.17.0 ) require ( - dario.cat/mergo v1.0.0 // indirect + dario.cat/mergo v1.0.2 // indirect github.com/Microsoft/go-winio v0.6.2 // indirect - github.com/ProtonMail/go-crypto v1.1.5 // indirect - github.com/cloudflare/circl v1.6.0 // indirect - github.com/cyphar/filepath-securejoin v0.4.1 // indirect + github.com/ProtonMail/go-crypto v1.4.1 // indirect + github.com/cloudflare/circl v1.6.3 // indirect + github.com/cyphar/filepath-securejoin v0.6.1 // indirect github.com/davecgh/go-spew v1.1.1 // indirect github.com/emirpasic/gods v1.18.1 // indirect github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect - github.com/go-git/go-billy/v5 v5.6.2 // indirect + github.com/go-git/go-billy/v5 v5.8.0 // indirect github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect - github.com/kevinburke/ssh_config v1.2.0 // indirect - github.com/pjbgf/sha1cd v0.3.2 // indirect + github.com/kevinburke/ssh_config v1.6.0 // indirect + github.com/klauspost/cpuid/v2 v2.3.0 // indirect + github.com/pjbgf/sha1cd v0.5.0 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect - github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 // indirect - github.com/skeema/knownhosts v1.3.1 // indirect + github.com/sergi/go-diff v1.4.0 // indirect + github.com/skeema/knownhosts v1.3.2 // indirect github.com/xanzy/ssh-agent v0.3.3 // indirect - golang.org/x/crypto v0.35.0 // indirect - golang.org/x/net v0.35.0 // indirect - golang.org/x/sys v0.30.0 // indirect + golang.org/x/crypto v0.49.0 // indirect + golang.org/x/net v0.52.0 // indirect + golang.org/x/sys v0.42.0 // indirect gopkg.in/warnings.v0 v0.1.2 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index 26c5c02..8375baf 100644 --- a/go.sum +++ b/go.sum @@ -1,10 +1,10 @@ -dario.cat/mergo v1.0.0 h1:AGCNq9Evsj31mOgNPcLyXc+4PNABt905YmuqPYYpBWk= -dario.cat/mergo v1.0.0/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk= +dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8= +dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA= github.com/Microsoft/go-winio v0.5.2/go.mod h1:WpS1mjBmmwHBEWmogvA2mj8546UReBk4v8QkMxJ6pZY= github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= -github.com/ProtonMail/go-crypto v1.1.5 h1:eoAQfK2dwL+tFSFpr7TbOaPNUbPiJj4fLYwwGE1FQO4= -github.com/ProtonMail/go-crypto v1.1.5/go.mod h1:rA3QumHc/FZ8pAHreoekgiAbzpNsfQAosU5td4SnOrE= +github.com/ProtonMail/go-crypto v1.4.1 h1:9RfcZHqEQUvP8RzecWEUafnZVtEvrBVL9BiF67IQOfM= +github.com/ProtonMail/go-crypto v1.4.1/go.mod h1:e1OaTyu5SYVrO9gKOEhTc+5UcXtTUa+P3uLudwcgPqo= github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0= github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k= github.com/alecthomas/kong v1.12.1 h1:iq6aMJDcFYP9uFrLdsiZQ2ZMmcshduyGv4Pek0MQPW0= @@ -15,10 +15,10 @@ github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFI github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4= github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio= github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs= -github.com/cloudflare/circl v1.6.0 h1:cr5JKic4HI+LkINy2lg3W2jF8sHCVTBncJr5gIIq7qk= -github.com/cloudflare/circl v1.6.0/go.mod h1:uddAzsPgqdMAYatqJ0lsjX1oECcQLIlRpzZh3pJrofs= -github.com/cyphar/filepath-securejoin v0.4.1 h1:JyxxyPEaktOD+GAnqIqTf9A8tHyAG22rowi7HkoSU1s= -github.com/cyphar/filepath-securejoin v0.4.1/go.mod h1:Sdj7gXlvMcPZsbhwhQ33GguGLDGQL7h7bg04C/+u9jI= +github.com/cloudflare/circl v1.6.3 h1:9GPOhQGF9MCYUeXyMYlqTR6a5gTrgR/fBLXvUgtVcg8= +github.com/cloudflare/circl v1.6.3/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4= +github.com/cyphar/filepath-securejoin v0.6.1 h1:5CeZ1jPXEiYt3+Z6zqprSAgSWiggmpVyciv8syjIpVE= +github.com/cyphar/filepath-securejoin v0.6.1/go.mod h1:A8hd4EnAeyujCJRrICiOWqjS1AX0a9kM5XL+NwKoYSc= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 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= @@ -30,12 +30,12 @@ github.com/gliderlabs/ssh v0.3.8 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c= github.com/gliderlabs/ssh v0.3.8/go.mod h1:xYoytBv1sV0aL3CavoDuJIQNURXkkfPA/wxQ1pL1fAU= github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 h1:+zs/tPmkDkHx3U66DAb0lQFJrpS6731Oaa12ikc+DiI= github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376/go.mod h1:an3vInlBmSxCcxctByoQdvwPiA7DTK7jaaFDBTtu0ic= -github.com/go-git/go-billy/v5 v5.6.2 h1:6Q86EsPXMa7c3YZ3aLAQsMA0VlWmy43r6FHqa/UNbRM= -github.com/go-git/go-billy/v5 v5.6.2/go.mod h1:rcFC2rAsp/erv7CMz9GczHcuD0D32fWzH+MJAU+jaUU= +github.com/go-git/go-billy/v5 v5.8.0 h1:I8hjc3LbBlXTtVuFNJuwYuMiHvQJDq1AT6u4DwDzZG0= +github.com/go-git/go-billy/v5 v5.8.0/go.mod h1:RpvI/rw4Vr5QA+Z60c6d6LXH0rYJo0uD5SqfmrrheCY= github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399 h1:eMje31YglSBqCdIqdhKBW8lokaMrL3uTkpGYlE2OOT4= github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399/go.mod h1:1OCfN199q1Jm3HZlxleg+Dw/mwps2Wbk9frAWm+4FII= -github.com/go-git/go-git/v5 v5.14.0 h1:/MD3lCrGjCen5WfEAzKg00MJJffKhC8gzS80ycmCi60= -github.com/go-git/go-git/v5 v5.14.0/go.mod h1:Z5Xhoia5PcWA3NF8vRLURn9E5FRhSl7dGj9ItW3Wk5k= +github.com/go-git/go-git/v5 v5.17.0 h1:AbyI4xf+7DsjINHMu35quAh4wJygKBKBuXVjV/pxesM= +github.com/go-git/go-git/v5 v5.17.0/go.mod h1:f82C4YiLx+Lhi8eHxltLeGC5uBTXSFa6PC5WW9o4SjI= github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ= github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= @@ -44,8 +44,10 @@ github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUq github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg= github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A= github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo= -github.com/kevinburke/ssh_config v1.2.0 h1:x584FjTGwHzMwvHx18PXxbBVzfnxogHaAReU4gf13a4= -github.com/kevinburke/ssh_config v1.2.0/go.mod h1:CT57kijsi8u/K/BOFA39wgDQJ9CxiF4nAY/ojJ6r6mM= +github.com/kevinburke/ssh_config v1.6.0 h1:J1FBfmuVosPHf5GRdltRLhPJtJpTlMdKTBjRgTaQBFY= +github.com/kevinburke/ssh_config v1.6.0/go.mod h1:q2RIzfka+BXARoNexmF9gkxEX7DmvbW9P4hIVx2Kg4M= +github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= +github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= @@ -55,48 +57,48 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/onsi/gomega v1.34.1 h1:EUMJIKUjM8sKjYbtxQI9A4z2o+rruxnzNvpknOXie6k= github.com/onsi/gomega v1.34.1/go.mod h1:kU1QgUvBDLXBJq618Xvm2LUX6rSAfRaFRTcdOeDLwwY= -github.com/pjbgf/sha1cd v0.3.2 h1:a9wb0bp1oC2TGwStyn0Umc/IGKQnEgF0vVaZ8QF8eo4= -github.com/pjbgf/sha1cd v0.3.2/go.mod h1:zQWigSxVmsHEZow5qaLtPYxpcKMMQpa09ixqBxuCS6A= +github.com/pjbgf/sha1cd v0.5.0 h1:a+UkboSi1znleCDUNT3M5YxjOnN1fz2FhN48FlwCxs0= +github.com/pjbgf/sha1cd v0.5.0/go.mod h1:lhpGlyHLpQZoxMv8HcgXvZEhcGs0PG/vsZnEJ7H0iCM= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 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/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= -github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 h1:n661drycOFuPLCN3Uc8sB6B/s6Z4t2xvBgU1htSHuq8= -github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4= +github.com/sergi/go-diff v1.4.0 h1:n/SP9D5ad1fORl+llWyN+D6qoUETXNZARKjyY2/KVCw= +github.com/sergi/go-diff v1.4.0/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4= github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0= -github.com/skeema/knownhosts v1.3.1 h1:X2osQ+RAjK76shCbvhHHHVl3ZlgDm8apHEHFqRjnBY8= -github.com/skeema/knownhosts v1.3.1/go.mod h1:r7KTdC8l4uxWRyK2TpQZ/1o5HaSzh06ePQNxPwTcfiY= +github.com/skeema/knownhosts v1.3.2 h1:EDL9mgf4NzwMXCTfaxSD/o/a5fxDw/xL9nkU28JjdBg= +github.com/skeema/knownhosts v1.3.2/go.mod h1:bEg3iQAuw+jyiw+484wwFJoKSLwcfd7fqRy+N0QTiow= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= -github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= -github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/xanzy/ssh-agent v0.3.3 h1:+/15pJfg/RsTxqYcX6fHqOXZwwMP+2VyYWJeWM2qQFM= github.com/xanzy/ssh-agent v0.3.3/go.mod h1:6dzNDKs0J9rVPHPhaGCukekBHKqfl+L3KghI1Bc68Uw= golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= -golang.org/x/crypto v0.35.0 h1:b15kiHdrGCHrP6LvwaQ3c03kgNhhiMgvlhxHQhmg2Xs= -golang.org/x/crypto v0.35.0/go.mod h1:dy7dXNW32cAb/6/PRuTNsix8T+vJAqvuIy5Bli/x0YQ= +golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4= +golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA= golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 h1:2dVuKD2vS7b0QIHQbpyTISPd0LeHDbnYEryqj5Q1ug8= golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56/go.mod h1:M4RDyNAINzryxdtnbRXRL/OHtkFuWGRjvuhBJpk2IlY= golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= -golang.org/x/net v0.35.0 h1:T5GQRQb2y08kTAByq9L4/bz8cipCdA8FbRTXewonqY8= -golang.org/x/net v0.35.0/go.mod h1:EglIi67kWsHKlRzzVMUD93VMSWGFOMSZgxFjparz1Qk= +golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0= +golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw= golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc= -golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= +golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= -golang.org/x/term v0.29.0 h1:L6pJp37ocefwRRtYPKSWOWzOtWSxVajvz2ldH/xi3iU= -golang.org/x/term v0.29.0/go.mod h1:6bl4lRlvVuDgSf3179VpIxBF0o10JUpXWOnI7nErv7s= +golang.org/x/term v0.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU= +golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM= -golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY= +golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8= +golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -- 2.49.1 From c36b738240b689a29552ae3594a800e37f69be05 Mon Sep 17 00:00:00 2001 From: Micheal Wilkinson Date: Sat, 21 Mar 2026 13:15:12 +0000 Subject: [PATCH 120/162] docs: document dependency security updates --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4701afe..7f6330c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ A `### Breaking` section is used in addition to Keep a Changelog's standard sect ### Changed +- Dependencies updated to resolve security vulnerabilities: `cloudflare/circl` to v1.6.3, `go-git/v5` to v5.17.0, `golang.org/x/crypto` to v0.49.0, and `golang.org/x/net` to v0.52.0. - CI workflows now include explicit caching for Go modules, build artifacts, and security tool binaries to reduce pipeline execution time. - Security hardening: file and directory creation now uses restrictive permissions (`0o750` for directories, `0o600` for files) instead of world-accessible defaults. Executable wrapper scripts are created with restricted permissions and then explicitly made executable via `chmod`. - Security: `Open()` now executes the editor directly without shell intermediary to prevent injection through the `$EDITOR` environment variable. -- 2.49.1 From 3cc90ff54e09de49ab69a6d69759705b0ee64b09 Mon Sep 17 00:00:00 2001 From: Micheal Wilkinson Date: Sat, 21 Mar 2026 13:22:25 +0000 Subject: [PATCH 121/162] chore(ci): replace manual security tools with marketplace actions and add go fmt check - Replace `go install` of gosec/govulncheck with secureCodeBox/gosec-action and golang/govulncheck-action - Actions handle their own caching; remove explicit security tools cache step - Add code formatting check using `go fmt ./...` to reject pushes/PRs with incorrect formatting - Formatting check runs before security scanning for faster feedback --- .gitea/workflows/pr-validation.yml | 28 ++++++++++++----------- .gitea/workflows/push-validation.yml | 34 ++++++++++++---------------- 2 files changed, 30 insertions(+), 32 deletions(-) diff --git a/.gitea/workflows/pr-validation.yml b/.gitea/workflows/pr-validation.yml index 1dbfb9d..5df4cc9 100644 --- a/.gitea/workflows/pr-validation.yml +++ b/.gitea/workflows/pr-validation.yml @@ -45,16 +45,6 @@ jobs: restore-keys: | ${{ runner.os }}-go-modules- - - name: Cache security tools - uses: actions/cache@v4 - with: - path: | - ~/.cache/go-build - ~/go/bin - key: ${{ runner.os }}-go-security-tools-${{ hashFiles('**/go.mod', '**/go.sum') }} - restore-keys: | - ${{ runner.os }}-go-security-tools- - - name: Verify module hygiene run: | set -euo pipefail @@ -157,11 +147,23 @@ jobs: exit 1 fi - - name: Run security analysis + - name: Check code formatting run: | set -euo pipefail - "$(go env GOPATH)/bin/gosec" ./... - "$(go env GOPATH)/bin/govulncheck" ./... + fmt_output=$(go fmt ./...) + if [[ -n "$fmt_output" ]]; then + echo "Code formatting check failed. The following files need formatting:" >&2 + echo "$fmt_output" >&2 + exit 1 + fi + + - name: Run Gosec Security Scanner + uses: secureCodeBox/gosec-action@v1 + with: + args: './...' + + - name: Run Go Vulnerability Check + uses: golang/govulncheck-action@v1 - name: Generate coverage badge env: diff --git a/.gitea/workflows/push-validation.yml b/.gitea/workflows/push-validation.yml index 9da2da7..6409b50 100644 --- a/.gitea/workflows/push-validation.yml +++ b/.gitea/workflows/push-validation.yml @@ -45,16 +45,6 @@ jobs: restore-keys: | ${{ runner.os }}-go-modules- - - name: Cache security tools - uses: actions/cache@v4 - with: - path: | - ~/.cache/go-build - ~/go/bin - key: ${{ runner.os }}-go-security-tools-${{ hashFiles('**/go.mod', '**/go.sum') }} - restore-keys: | - ${{ runner.os }}-go-security-tools- - - name: Verify module hygiene run: | set -euo pipefail @@ -62,11 +52,23 @@ jobs: git diff --exit-code go.mod go.sum go mod verify - - name: Install security tools + - name: Check code formatting run: | set -euo pipefail - go install github.com/securego/gosec/v2/cmd/gosec@v2.22.3 - go install golang.org/x/vuln/cmd/govulncheck@v1.1.4 + fmt_output=$(go fmt ./...) + if [[ -n "$fmt_output" ]]; then + echo "Code formatting check failed. The following files need formatting:" >&2 + echo "$fmt_output" >&2 + exit 1 + fi + + - name: Run Gosec Security Scanner + uses: secureCodeBox/gosec-action@v1 + with: + args: './...' + + - name: Run Go Vulnerability Check + uses: golang/govulncheck-action@v1 - name: Install AWS CLI v2 uses: ankurk91/install-aws-cli-action@v1 @@ -163,12 +165,6 @@ jobs: repository-name: ${{ github.repository }} summary-file: ${{ env.SUMMARY_FILE }} - - name: Run security analysis - run: | - set -euo pipefail - "$(go env GOPATH)/bin/gosec" ./... - "$(go env GOPATH)/bin/govulncheck" ./... - - name: Run behavior suite on main pushes if: ${{ github.ref == 'refs/heads/main' }} run: ./script/run-behavior-suite-docker.sh -- 2.49.1 From 302acbe9bb8fc599bb537a15017b3a3eaac6ac76 Mon Sep 17 00:00:00 2001 From: Micheal Wilkinson Date: Sat, 21 Mar 2026 13:22:34 +0000 Subject: [PATCH 122/162] docs: document ci marketplace actions and formatting check --- CHANGELOG.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7f6330c..c141213 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,8 +15,10 @@ A `### Breaking` section is used in addition to Keep a Changelog's standard sect ### Changed +- CI security scanning now uses GitHub Marketplace actions (`secureCodeBox/gosec-action` and `golang/govulncheck-action`) instead of manual tool installation, improving reliability and caching. +- Code formatting validation added to CI pipelines: pushes and pull requests with code not matching `go fmt ./...` output will be rejected. - Dependencies updated to resolve security vulnerabilities: `cloudflare/circl` to v1.6.3, `go-git/v5` to v5.17.0, `golang.org/x/crypto` to v0.49.0, and `golang.org/x/net` to v0.52.0. -- CI workflows now include explicit caching for Go modules, build artifacts, and security tool binaries to reduce pipeline execution time. +- CI workflows now include explicit caching for Go modules and build artifacts to reduce pipeline execution time. - Security hardening: file and directory creation now uses restrictive permissions (`0o750` for directories, `0o600` for files) instead of world-accessible defaults. Executable wrapper scripts are created with restricted permissions and then explicitly made executable via `chmod`. - Security: `Open()` now executes the editor directly without shell intermediary to prevent injection through the `$EDITOR` environment variable. -- 2.49.1 From be14cfdc294dc37df9a3393ffa798b1fa008e1ae Mon Sep 17 00:00:00 2001 From: Micheal Wilkinson Date: Sat, 21 Mar 2026 13:23:16 +0000 Subject: [PATCH 123/162] chore(ci): include go bin directory in cache for cli tools --- .gitea/workflows/pr-validation.yml | 5 +++-- .gitea/workflows/push-validation.yml | 5 +++-- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/.gitea/workflows/pr-validation.yml b/.gitea/workflows/pr-validation.yml index 5df4cc9..263eaab 100644 --- a/.gitea/workflows/pr-validation.yml +++ b/.gitea/workflows/pr-validation.yml @@ -41,9 +41,10 @@ jobs: path: | ~/go/pkg/mod ~/.cache/go-build - key: ${{ runner.os }}-go-modules-${{ hashFiles('**/go.mod', '**/go.sum') }} + ~/go/bin + key: ${{ runner.os }}-go-cache-${{ hashFiles('**/go.mod', '**/go.sum') }} restore-keys: | - ${{ runner.os }}-go-modules- + ${{ runner.os }}-go-cache- - name: Verify module hygiene run: | diff --git a/.gitea/workflows/push-validation.yml b/.gitea/workflows/push-validation.yml index 6409b50..1bd82c4 100644 --- a/.gitea/workflows/push-validation.yml +++ b/.gitea/workflows/push-validation.yml @@ -41,9 +41,10 @@ jobs: path: | ~/go/pkg/mod ~/.cache/go-build - key: ${{ runner.os }}-go-modules-${{ hashFiles('**/go.mod', '**/go.sum') }} + ~/go/bin + key: ${{ runner.os }}-go-cache-${{ hashFiles('**/go.mod', '**/go.sum') }} restore-keys: | - ${{ runner.os }}-go-modules- + ${{ runner.os }}-go-cache- - name: Verify module hygiene run: | -- 2.49.1 From ecda12fc49f2de46fc220a8959e9a3b069e099a1 Mon Sep 17 00:00:00 2001 From: Micheal Wilkinson Date: Sat, 21 Mar 2026 13:34:37 +0000 Subject: [PATCH 124/162] chore(ci): fix gosec action source for gitea runners --- .gitea/workflows/pr-validation.yml | 8 +------- .gitea/workflows/push-validation.yml | 2 +- 2 files changed, 2 insertions(+), 8 deletions(-) diff --git a/.gitea/workflows/pr-validation.yml b/.gitea/workflows/pr-validation.yml index 263eaab..1bf6979 100644 --- a/.gitea/workflows/pr-validation.yml +++ b/.gitea/workflows/pr-validation.yml @@ -53,12 +53,6 @@ jobs: git diff --exit-code go.mod go.sum go mod verify - - name: Install security tools - run: | - set -euo pipefail - go install github.com/securego/gosec/v2/cmd/gosec@v2.22.3 - go install golang.org/x/vuln/cmd/govulncheck@v1.1.4 - - name: Install AWS CLI v2 uses: ankurk91/install-aws-cli-action@v1 @@ -159,7 +153,7 @@ jobs: fi - name: Run Gosec Security Scanner - uses: secureCodeBox/gosec-action@v1 + uses: securego/gosec@v2.22.3 with: args: './...' diff --git a/.gitea/workflows/push-validation.yml b/.gitea/workflows/push-validation.yml index 1bd82c4..f877ccc 100644 --- a/.gitea/workflows/push-validation.yml +++ b/.gitea/workflows/push-validation.yml @@ -64,7 +64,7 @@ jobs: fi - name: Run Gosec Security Scanner - uses: secureCodeBox/gosec-action@v1 + uses: securego/gosec@v2.22.3 with: args: './...' -- 2.49.1 From f134361b6eb068fccfbacc92cf7976d59832881f Mon Sep 17 00:00:00 2001 From: Micheal Wilkinson Date: Sat, 21 Mar 2026 13:34:46 +0000 Subject: [PATCH 125/162] docs: note gitea gosec action source fix --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index c141213..e2aaa6e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ A `### Breaking` section is used in addition to Keep a Changelog's standard sect ### Changed - CI security scanning now uses GitHub Marketplace actions (`secureCodeBox/gosec-action` and `golang/govulncheck-action`) instead of manual tool installation, improving reliability and caching. +- CI setup compatibility fix: gosec scanner now references the correct public action source (`securego/gosec`), resolving action clone failures in Gitea runners. - Code formatting validation added to CI pipelines: pushes and pull requests with code not matching `go fmt ./...` output will be rejected. - Dependencies updated to resolve security vulnerabilities: `cloudflare/circl` to v1.6.3, `go-git/v5` to v5.17.0, `golang.org/x/crypto` to v0.49.0, and `golang.org/x/net` to v0.52.0. - CI workflows now include explicit caching for Go modules and build artifacts to reduce pipeline execution time. -- 2.49.1 From a01a2171ff27d8d241e1b1eaf5dfad0e317de483 Mon Sep 17 00:00:00 2001 From: Micheal Wilkinson Date: Sat, 21 Mar 2026 13:37:41 +0000 Subject: [PATCH 126/162] docs: correct gosec action reference in changelog --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e2aaa6e..c4356c0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,7 +15,7 @@ A `### Breaking` section is used in addition to Keep a Changelog's standard sect ### Changed -- CI security scanning now uses GitHub Marketplace actions (`secureCodeBox/gosec-action` and `golang/govulncheck-action`) instead of manual tool installation, improving reliability and caching. +- CI security scanning now uses GitHub Marketplace actions (`securego/gosec` and `golang/govulncheck-action`) instead of manual tool installation, improving reliability and caching. - CI setup compatibility fix: gosec scanner now references the correct public action source (`securego/gosec`), resolving action clone failures in Gitea runners. - Code formatting validation added to CI pipelines: pushes and pull requests with code not matching `go fmt ./...` output will be rejected. - Dependencies updated to resolve security vulnerabilities: `cloudflare/circl` to v1.6.3, `go-git/v5` to v5.17.0, `golang.org/x/crypto` to v0.49.0, and `golang.org/x/net` to v0.52.0. -- 2.49.1 From 15f05a1999e45ee088f581e454742229a3533479 Mon Sep 17 00:00:00 2001 From: Micheal Wilkinson Date: Sat, 21 Mar 2026 13:49:14 +0000 Subject: [PATCH 127/162] chore(go): run go fmt on core test files --- internal/homesick/core/list_test.go | 2 +- internal/homesick/core/track_test.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/internal/homesick/core/list_test.go b/internal/homesick/core/list_test.go index 19f9a97..b7ac242 100644 --- a/internal/homesick/core/list_test.go +++ b/internal/homesick/core/list_test.go @@ -69,4 +69,4 @@ func (s *ListSuite) TestList_OutputsSortedCastlesWithRemoteURLs() { "zomg git://github.com/technicalpickles/zomg.git\n", s.stdout.String(), ) -} \ No newline at end of file +} diff --git a/internal/homesick/core/track_test.go b/internal/homesick/core/track_test.go index 7239a6c..ccf68c1 100644 --- a/internal/homesick/core/track_test.go +++ b/internal/homesick/core/track_test.go @@ -20,7 +20,7 @@ type TrackSuite struct { app *core.App } -//NB: this has nothing to do with jogging +// NB: this has nothing to do with jogging func TestTrackSuite(t *testing.T) { suite.Run(t, new(TrackSuite)) } -- 2.49.1 From 494eea998d84d869dcb1df511aa29866fa630396 Mon Sep 17 00:00:00 2001 From: Micheal Wilkinson Date: Sat, 21 Mar 2026 13:49:18 +0000 Subject: [PATCH 128/162] docs: record gofmt remediation for core tests --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index c4356c0..b84de73 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,7 @@ A `### Breaking` section is used in addition to Keep a Changelog's standard sect - CI security scanning now uses GitHub Marketplace actions (`securego/gosec` and `golang/govulncheck-action`) instead of manual tool installation, improving reliability and caching. - CI setup compatibility fix: gosec scanner now references the correct public action source (`securego/gosec`), resolving action clone failures in Gitea runners. - Code formatting validation added to CI pipelines: pushes and pull requests with code not matching `go fmt ./...` output will be rejected. +- Applied `go fmt` normalization to core tests (`list_test.go` and `track_test.go`) to satisfy the new formatting gate. - Dependencies updated to resolve security vulnerabilities: `cloudflare/circl` to v1.6.3, `go-git/v5` to v5.17.0, `golang.org/x/crypto` to v0.49.0, and `golang.org/x/net` to v0.52.0. - CI workflows now include explicit caching for Go modules and build artifacts to reduce pipeline execution time. - Security hardening: file and directory creation now uses restrictive permissions (`0o750` for directories, `0o600` for files) instead of world-accessible defaults. Executable wrapper scripts are created with restricted permissions and then explicitly made executable via `chmod`. -- 2.49.1 From eb63da935448689c522090c14a90a83249c1c23e Mon Sep 17 00:00:00 2001 From: Micheal Wilkinson Date: Sat, 21 Mar 2026 13:54:11 +0000 Subject: [PATCH 129/162] chore(ci): allow scanner actions to auto-select Go toolchain --- .gitea/workflows/pr-validation.yml | 4 ++++ .gitea/workflows/push-validation.yml | 4 ++++ 2 files changed, 8 insertions(+) diff --git a/.gitea/workflows/pr-validation.yml b/.gitea/workflows/pr-validation.yml index 1bf6979..a6248eb 100644 --- a/.gitea/workflows/pr-validation.yml +++ b/.gitea/workflows/pr-validation.yml @@ -154,11 +154,15 @@ jobs: - name: Run Gosec Security Scanner uses: securego/gosec@v2.22.3 + env: + GOTOOLCHAIN: auto with: args: './...' - name: Run Go Vulnerability Check uses: golang/govulncheck-action@v1 + env: + GOTOOLCHAIN: auto - name: Generate coverage badge env: diff --git a/.gitea/workflows/push-validation.yml b/.gitea/workflows/push-validation.yml index f877ccc..bcfac07 100644 --- a/.gitea/workflows/push-validation.yml +++ b/.gitea/workflows/push-validation.yml @@ -65,11 +65,15 @@ jobs: - name: Run Gosec Security Scanner uses: securego/gosec@v2.22.3 + env: + GOTOOLCHAIN: auto with: args: './...' - name: Run Go Vulnerability Check uses: golang/govulncheck-action@v1 + env: + GOTOOLCHAIN: auto - name: Install AWS CLI v2 uses: ankurk91/install-aws-cli-action@v1 -- 2.49.1 From 4b54a45a7623bfe233412e9322925755d4060dbc Mon Sep 17 00:00:00 2001 From: Micheal Wilkinson Date: Sat, 21 Mar 2026 13:54:11 +0000 Subject: [PATCH 130/162] docs: note scanner toolchain compatibility fix --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index b84de73..b8feb56 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ A `### Breaking` section is used in addition to Keep a Changelog's standard sect - CI security scanning now uses GitHub Marketplace actions (`securego/gosec` and `golang/govulncheck-action`) instead of manual tool installation, improving reliability and caching. - CI setup compatibility fix: gosec scanner now references the correct public action source (`securego/gosec`), resolving action clone failures in Gitea runners. +- CI security scanner compatibility: gosec and govulncheck action steps now set `GOTOOLCHAIN=auto` so repositories requiring newer Go versions are analyzed successfully. - Code formatting validation added to CI pipelines: pushes and pull requests with code not matching `go fmt ./...` output will be rejected. - Applied `go fmt` normalization to core tests (`list_test.go` and `track_test.go`) to satisfy the new formatting gate. - Dependencies updated to resolve security vulnerabilities: `cloudflare/circl` to v1.6.3, `go-git/v5` to v5.17.0, `golang.org/x/crypto` to v0.49.0, and `golang.org/x/net` to v0.52.0. -- 2.49.1 From 5b37057b6141fe841ed179fb1e2d27f085b140d4 Mon Sep 17 00:00:00 2001 From: Micheal Wilkinson Date: Sat, 21 Mar 2026 20:13:31 +0000 Subject: [PATCH 131/162] test(coverage): add targeted tests to raise per-package coverage gates - internal/homesick/version: new version_test.go covers String constant and semver format validation - internal/homesick/cli: add list, generate, clone, status, diff, and git-repo helper tests; coverage raised from 62.5% to 71.2% - internal/homesick/core: new helpers_test.go covers runGit pretend, actionVerb, sayStatus, unlinkPath, linkPath, readSubdirs, matchesIgnoredDir, confirmDestroy, ExecAll edge cases, and Link/Unlink default castle wrappers; core_test.go and pull_test.go extended with New constructor and PullAll quiet-mode tests; exec_test.go extended with ExecAll no-repos-dir and error-wrap tests; coverage raised from 75.6% to 80.2% --- internal/homesick/cli/cli_test.go | 79 +++++++ internal/homesick/core/core_test.go | 27 +++ internal/homesick/core/exec_test.go | 19 ++ internal/homesick/core/helpers_test.go | 245 ++++++++++++++++++++++ internal/homesick/core/pull_test.go | 14 ++ internal/homesick/version/version_test.go | 21 ++ 6 files changed, 405 insertions(+) create mode 100644 internal/homesick/core/helpers_test.go create mode 100644 internal/homesick/version/version_test.go diff --git a/internal/homesick/cli/cli_test.go b/internal/homesick/cli/cli_test.go index ba40dfa..5363b11 100644 --- a/internal/homesick/cli/cli_test.go +++ b/internal/homesick/cli/cli_test.go @@ -5,9 +5,13 @@ import ( "os" "path/filepath" "testing" + "time" "git.hrafn.xyz/aether/gosick/internal/homesick/cli" "git.hrafn.xyz/aether/gosick/internal/homesick/version" + git "github.com/go-git/go-git/v5" + "github.com/go-git/go-git/v5/config" + "github.com/go-git/go-git/v5/plumbing/object" "github.com/stretchr/testify/require" "github.com/stretchr/testify/suite" ) @@ -32,6 +36,32 @@ func (s *CLISuite) SetupTest() { s.stderr = &bytes.Buffer{} } +func (s *CLISuite) createCastleRepo(castle string) string { + castleRoot := filepath.Join(s.homeDir, ".homesick", "repos", castle) + repo, err := git.PlainInit(castleRoot, false) + require.NoError(s.T(), err) + + filePath := filepath.Join(castleRoot, "home", ".vimrc") + require.NoError(s.T(), os.MkdirAll(filepath.Dir(filePath), 0o755)) + require.NoError(s.T(), os.WriteFile(filePath, []byte("set number\n"), 0o644)) + + wt, err := repo.Worktree() + require.NoError(s.T(), err) + _, err = wt.Add("home/.vimrc") + require.NoError(s.T(), err) + _, err = wt.Commit("initial", &git.CommitOptions{Author: &object.Signature{ + Name: "Behavior Test", + Email: "behavior@test.local", + When: time.Now(), + }}) + require.NoError(s.T(), err) + + _, err = repo.CreateRemote(&config.RemoteConfig{Name: "origin", URLs: []string{"git://example.com/test.git"}}) + require.NoError(s.T(), err) + + return castleRoot +} + func (s *CLISuite) TestRun_VersionAliases() { for _, args := range [][]string{{"-v"}, {"--version"}, {"version"}} { s.stdout.Reset() @@ -191,3 +221,52 @@ func (s *CLISuite) TestRun_Commit_PositionalMessageCompatibility() { require.Contains(s.T(), s.stdout.String(), "git commit -m behavior-suite-commit") require.Empty(s.T(), s.stderr.String()) } + +func (s *CLISuite) TestRun_List_NoArguments() { + s.createCastleRepo("dotfiles") + + exitCode := cli.Run([]string{"list"}, s.stdout, s.stderr) + + require.Equal(s.T(), 0, exitCode) + require.Contains(s.T(), s.stdout.String(), "dotfiles") + require.Empty(s.T(), s.stderr.String()) +} + +func (s *CLISuite) TestRun_Generate_CreatesNewCastle() { + castlePath := filepath.Join(s.T().TempDir(), "my-castle") + + exitCode := cli.Run([]string{"generate", castlePath}, s.stdout, s.stderr) + + require.Equal(s.T(), 0, exitCode) + require.DirExists(s.T(), filepath.Join(castlePath, ".git")) + require.DirExists(s.T(), filepath.Join(castlePath, "home")) +} + +func (s *CLISuite) TestRun_Clone_WithoutArgs() { + exitCode := cli.Run([]string{"clone"}, s.stdout, s.stderr) + + // Clone requires arguments, should fail + require.NotEqual(s.T(), 0, exitCode) +} + +func (s *CLISuite) TestRun_Status_DefaultCastle() { + castleRoot := s.createCastleRepo("dotfiles") + require.NoError(s.T(), os.WriteFile(filepath.Join(castleRoot, "home", ".vimrc"), []byte("changed\n"), 0o644)) + + exitCode := cli.Run([]string{"status"}, s.stdout, s.stderr) + + require.Equal(s.T(), 0, exitCode) + require.Contains(s.T(), s.stdout.String(), "modified:") + require.Empty(s.T(), s.stderr.String()) +} + +func (s *CLISuite) TestRun_Diff_DefaultCastle() { + castleRoot := s.createCastleRepo("dotfiles") + require.NoError(s.T(), os.WriteFile(filepath.Join(castleRoot, "home", ".vimrc"), []byte("changed\n"), 0o644)) + + exitCode := cli.Run([]string{"diff"}, s.stdout, s.stderr) + + require.Equal(s.T(), 0, exitCode) + require.Contains(s.T(), s.stdout.String(), "diff --git") + require.Empty(s.T(), s.stderr.String()) +} diff --git a/internal/homesick/core/core_test.go b/internal/homesick/core/core_test.go index 1da9dff..0ff0220 100644 --- a/internal/homesick/core/core_test.go +++ b/internal/homesick/core/core_test.go @@ -2,6 +2,7 @@ package core import ( "bytes" + "path/filepath" "testing" ) @@ -47,3 +48,29 @@ func TestDeriveDestination(t *testing.T) { }) } } + +func TestNewInitializesApp(t *testing.T) { + stdout := &bytes.Buffer{} + stderr := &bytes.Buffer{} + + app, err := New(stdout, stderr) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if app == nil { + t.Fatal("expected app instance") + } + + if app.Stdout != stdout { + t.Fatal("expected stdout writer to be assigned") + } + if app.Stderr != stderr { + t.Fatal("expected stderr writer to be assigned") + } + if app.HomeDir == "" { + t.Fatal("expected home directory to be set") + } + if app.ReposDir != filepath.Join(app.HomeDir, ".homesick", "repos") { + t.Fatalf("unexpected repos dir: %q", app.ReposDir) + } +} diff --git a/internal/homesick/core/exec_test.go b/internal/homesick/core/exec_test.go index 7aa284f..3f0412a 100644 --- a/internal/homesick/core/exec_test.go +++ b/internal/homesick/core/exec_test.go @@ -88,3 +88,22 @@ func (s *ExecSuite) TestExec_PretendDoesNotExecuteCommand() { require.NoFileExists(s.T(), target) require.Contains(s.T(), s.stdout.String(), "Would execute") } + +func (s *ExecSuite) TestExecAll_RequiresCommand() { + err := s.app.ExecAll(nil) + require.Error(s.T(), err) + require.Contains(s.T(), err.Error(), "exec_all requires COMMAND") +} + +func (s *ExecSuite) TestExecAll_NoReposDirIsNoop() { + missingRepos := filepath.Join(s.T().TempDir(), "missing", "repos") + app := &core.App{ + HomeDir: s.homeDir, + ReposDir: missingRepos, + Stdout: s.stdout, + Stderr: s.stderr, + } + + err := app.ExecAll([]string{"echo hi"}) + require.NoError(s.T(), err) +} diff --git a/internal/homesick/core/helpers_test.go b/internal/homesick/core/helpers_test.go new file mode 100644 index 0000000..a2db849 --- /dev/null +++ b/internal/homesick/core/helpers_test.go @@ -0,0 +1,245 @@ +package core + +import ( + "bytes" + "errors" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/require" +) + +type errReader struct{} + +func (errReader) Read(_ []byte) (int, error) { + return 0, errors.New("boom") +} + +func TestRunGitPretendWritesStatus(t *testing.T) { + stdout := &bytes.Buffer{} + app := &App{Stdout: stdout, Stderr: bytes.NewBuffer(nil), Pretend: true} + + err := app.runGit("/tmp", "status") + require.NoError(t, err) + require.Contains(t, stdout.String(), "Would execute git status in /tmp") +} + +func TestActionVerb(t *testing.T) { + app := &App{Pretend: true} + require.Equal(t, "Would execute", app.actionVerb()) + + app.Pretend = false + require.Equal(t, "Executing", app.actionVerb()) +} + +func TestSayStatusHonorsQuiet(t *testing.T) { + stdout := &bytes.Buffer{} + app := &App{Stdout: stdout, Quiet: true} + app.sayStatus("git", "status") + require.Empty(t, stdout.String()) + + app.Quiet = false + app.sayStatus("git", "status") + require.Contains(t, stdout.String(), "git: status") +} + +func TestUnlinkPath(t *testing.T) { + t.Run("missing destination", func(t *testing.T) { + err := unlinkPath(filepath.Join(t.TempDir(), "does-not-exist")) + require.NoError(t, err) + }) + + t.Run("regular file is preserved", func(t *testing.T) { + dir := t.TempDir() + target := filepath.Join(dir, "regular") + require.NoError(t, os.WriteFile(target, []byte("x"), 0o644)) + + err := unlinkPath(target) + require.NoError(t, err) + require.FileExists(t, target) + }) + + t.Run("symlink is removed", func(t *testing.T) { + dir := t.TempDir() + source := filepath.Join(dir, "source") + destination := filepath.Join(dir, "dest") + require.NoError(t, os.WriteFile(source, []byte("x"), 0o644)) + require.NoError(t, os.Symlink(source, destination)) + + err := unlinkPath(destination) + require.NoError(t, err) + _, statErr := os.Lstat(destination) + require.ErrorIs(t, statErr, os.ErrNotExist) + }) +} + +func TestLinkPath(t *testing.T) { + t.Run("existing symlink to same source is no-op", func(t *testing.T) { + dir := t.TempDir() + source := filepath.Join(dir, "source") + destination := filepath.Join(dir, "dest") + require.NoError(t, os.WriteFile(source, []byte("x"), 0o644)) + + absSource, err := filepath.Abs(source) + require.NoError(t, err) + require.NoError(t, os.Symlink(absSource, destination)) + + app := &App{Stdout: bytes.NewBuffer(nil), Stderr: bytes.NewBuffer(nil)} + err = app.linkPath(source, destination) + require.NoError(t, err) + }) + + t.Run("conflict without force errors", func(t *testing.T) { + dir := t.TempDir() + source := filepath.Join(dir, "source") + destination := filepath.Join(dir, "dest") + require.NoError(t, os.WriteFile(source, []byte("x"), 0o644)) + require.NoError(t, os.WriteFile(destination, []byte("existing"), 0o644)) + + app := &App{Stdout: bytes.NewBuffer(nil), Stderr: bytes.NewBuffer(nil)} + err := app.linkPath(source, destination) + require.Error(t, err) + require.Contains(t, err.Error(), "exists") + }) + + t.Run("force replaces existing destination", func(t *testing.T) { + dir := t.TempDir() + source := filepath.Join(dir, "source") + destination := filepath.Join(dir, "dest") + require.NoError(t, os.WriteFile(source, []byte("x"), 0o644)) + require.NoError(t, os.WriteFile(destination, []byte("existing"), 0o644)) + + app := &App{Stdout: bytes.NewBuffer(nil), Stderr: bytes.NewBuffer(nil), Force: true} + err := app.linkPath(source, destination) + require.NoError(t, err) + + info, statErr := os.Lstat(destination) + require.NoError(t, statErr) + require.True(t, info.Mode()&os.ModeSymlink != 0) + }) +} + +func TestReadSubdirsAndMatchesIgnoredDir(t *testing.T) { + dir := t.TempDir() + meta := filepath.Join(dir, ".homesick_subdir") + require.NoError(t, os.WriteFile(meta, []byte(" .config/myapp \n\n"), 0o644)) + + subdirs, err := readSubdirs(meta) + require.NoError(t, err) + require.Equal(t, []string{filepath.Clean(".config/myapp")}, subdirs) + + castleHome := filepath.Join(dir, "castle", "home") + candidate := filepath.Join(castleHome, ".config") + ignored, err := matchesIgnoredDir(castleHome, candidate, subdirs) + require.NoError(t, err) + require.True(t, ignored) + + notIgnored, err := matchesIgnoredDir(castleHome, filepath.Join(castleHome, ".vim"), subdirs) + require.NoError(t, err) + require.False(t, notIgnored) +} + +func TestPullAndPushDefaultCastlePretend(t *testing.T) { + dir := t.TempDir() + stdout := &bytes.Buffer{} + app := &App{ + HomeDir: dir, + ReposDir: filepath.Join(dir, ".homesick", "repos"), + Stdout: stdout, + Stderr: bytes.NewBuffer(nil), + Pretend: true, + } + + require.NoError(t, app.Pull("")) + require.NoError(t, app.Push("")) + + out := stdout.String() + require.Contains(t, out, "git pull") + require.Contains(t, out, "git push") + require.Contains(t, out, filepath.Join(app.ReposDir, "dotfiles")) +} + +func TestGenerateRequiresPath(t *testing.T) { + app := &App{Stdout: bytes.NewBuffer(nil), Stderr: bytes.NewBuffer(nil)} + err := app.Generate(" ") + require.Error(t, err) + require.Contains(t, err.Error(), "generate requires PATH") +} + +func TestLinkAndUnlinkDefaultCastle(t *testing.T) { + dir := t.TempDir() + homeDir := filepath.Join(dir, "home") + reposDir := filepath.Join(homeDir, ".homesick", "repos") + + castleHome := filepath.Join(reposDir, "dotfiles", "home") + require.NoError(t, os.MkdirAll(castleHome, 0o755)) + source := filepath.Join(castleHome, ".vimrc") + require.NoError(t, os.WriteFile(source, []byte("set number\n"), 0o644)) + + app := &App{HomeDir: homeDir, ReposDir: reposDir, Stdout: bytes.NewBuffer(nil), Stderr: bytes.NewBuffer(nil)} + require.NoError(t, app.Link("")) + + destination := filepath.Join(homeDir, ".vimrc") + info, err := os.Lstat(destination) + require.NoError(t, err) + require.True(t, info.Mode()&os.ModeSymlink != 0) + + require.NoError(t, app.Unlink("")) + _, err = os.Lstat(destination) + require.ErrorIs(t, err, os.ErrNotExist) +} + +func TestLinkAndUnlinkCastleMissingError(t *testing.T) { + dir := t.TempDir() + app := &App{ + HomeDir: filepath.Join(dir, "home"), + ReposDir: filepath.Join(dir, "home", ".homesick", "repos"), + Stdout: bytes.NewBuffer(nil), + Stderr: bytes.NewBuffer(nil), + } + + err := app.LinkCastle("missing") + require.Error(t, err) + require.Contains(t, err.Error(), "could not symlink") + + err = app.UnlinkCastle("missing") + require.Error(t, err) + require.Contains(t, err.Error(), "could not symlink") +} + +func TestConfirmDestroyResponses(t *testing.T) { + stdout := &bytes.Buffer{} + app := &App{Stdout: stdout, Stdin: strings.NewReader("yes\n")} + + ok, err := app.confirmDestroy("dotfiles") + require.NoError(t, err) + require.True(t, ok) + require.Contains(t, stdout.String(), "Destroy castle \"dotfiles\"?") + + stdout.Reset() + app.Stdin = strings.NewReader("n\n") + ok, err = app.confirmDestroy("dotfiles") + require.NoError(t, err) + require.False(t, ok) +} + +func TestConfirmDestroyReadError(t *testing.T) { + app := &App{Stdout: bytes.NewBuffer(nil), Stdin: errReader{}} + ok, err := app.confirmDestroy("dotfiles") + require.Error(t, err) + require.False(t, ok) +} + +func TestExecAllWrapsCastleError(t *testing.T) { + dir := t.TempDir() + homeDir := filepath.Join(dir, "home") + reposDir := filepath.Join(homeDir, ".homesick", "repos") + require.NoError(t, os.MkdirAll(filepath.Join(reposDir, "broken", ".git"), 0o755)) + + app := &App{HomeDir: homeDir, ReposDir: reposDir, Stdout: bytes.NewBuffer(nil), Stderr: bytes.NewBuffer(nil)} + err := app.ExecAll([]string{"exit 3"}) + require.Error(t, err) + require.Contains(t, err.Error(), "exec_all failed for \"broken\"") +} diff --git a/internal/homesick/core/pull_test.go b/internal/homesick/core/pull_test.go index eacd224..82234e0 100644 --- a/internal/homesick/core/pull_test.go +++ b/internal/homesick/core/pull_test.go @@ -140,3 +140,17 @@ func (s *PullSuite) TestPullAll_PrintsCastlePrefixes() { require.Contains(s.T(), stdout.String(), "alpha:") require.Contains(s.T(), stdout.String(), "zeta:") } + +func (s *PullSuite) TestPullAll_QuietSuppressesCastlePrefixes() { + require.NoError(s.T(), os.MkdirAll(filepath.Join(s.reposDir, "alpha", ".git"), 0o755)) + require.NoError(s.T(), os.MkdirAll(filepath.Join(s.reposDir, "zeta", ".git"), 0o755)) + + stdout := &bytes.Buffer{} + s.app.Stdout = stdout + s.app.Quiet = true + s.app.Pretend = true + + require.NoError(s.T(), s.app.PullAll()) + require.NotContains(s.T(), stdout.String(), "alpha:") + require.NotContains(s.T(), stdout.String(), "zeta:") +} diff --git a/internal/homesick/version/version_test.go b/internal/homesick/version/version_test.go new file mode 100644 index 0000000..6070c8d --- /dev/null +++ b/internal/homesick/version/version_test.go @@ -0,0 +1,21 @@ +package version + +import ( + "regexp" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestStringConstant(t *testing.T) { + // Test that the version constant is not empty + assert.NotEmpty(t, String, "version.String should not be empty") +} + +func TestStringMatchesSemVer(t *testing.T) { + // Test that the version string matches semantic versioning pattern (major.minor.patch) + semverPattern := `^\d+\.\d+\.\d+$` + matched, err := regexp.MatchString(semverPattern, String) + assert.NoError(t, err, "regex should be valid") + assert.True(t, matched, "version.String should match semantic versioning pattern (major.minor.patch), got: %s", String) +} -- 2.49.1 From 014b3309310cb03c43876f72260b617bb3192a0e Mon Sep 17 00:00:00 2001 From: Micheal Wilkinson Date: Sat, 21 Mar 2026 20:13:40 +0000 Subject: [PATCH 132/162] ci(pr-validation): replace manual badge/gate logic with vociferate actions - Remove manual changelog validation shell script - Remove AWS CLI install and jq tooling steps - Remove hand-rolled SVG badge generation, S3 upload, and PR comment steps - Replace with coverage-badge@v1.1.0 for coverage artefact upload - Replace with decorate-pr@v1.1.0 for PR comment and changelog gate (enable-changelog-gate: true, changelog-gate-mode: strict) - Retain per-package coverage gate awk logic (Aether threshold enforcement) --- .gitea/workflows/pr-validation.yml | 130 ++++------------------------- 1 file changed, 18 insertions(+), 112 deletions(-) diff --git a/.gitea/workflows/pr-validation.yml b/.gitea/workflows/pr-validation.yml index a6248eb..e8396be 100644 --- a/.gitea/workflows/pr-validation.yml +++ b/.gitea/workflows/pr-validation.yml @@ -26,6 +26,8 @@ jobs: steps: - name: Checkout uses: actions/checkout@v4 + with: + fetch-depth: 0 - name: Setup Go uses: actions/setup-go@v5 @@ -53,20 +55,6 @@ jobs: git diff --exit-code go.mod go.sum go mod verify - - name: Install AWS CLI v2 - uses: ankurk91/install-aws-cli-action@v1 - - - name: Ensure tooling is available - run: | - set -euo pipefail - - aws --version - - if ! command -v jq >/dev/null 2>&1; then - apt-get update - apt-get install -y jq - fi - - name: Prepare test runtime run: | set -euo pipefail @@ -81,11 +69,6 @@ jobs: set -euo pipefail go test -covermode=atomic -coverprofile=coverage.out ./... | tee go-test-coverage.log - go tool cover -html=coverage.out -o coverage.html - - total="$(go tool cover -func=coverage.out | awk '/^total:/ {sub(/%/, "", $3); print $3}')" - printf '{\n "total": "%s"\n}\n' "$total" > coverage-summary.json - printf 'total=%s\n' "$total" >> "$GITHUB_OUTPUT" set +e awk ' @@ -164,106 +147,29 @@ jobs: env: GOTOOLCHAIN: auto - - name: Generate coverage badge - env: - COVERAGE_TOTAL: ${{ steps.coverage.outputs.total }} - run: | - set -euo pipefail + - name: Upload coverage badge + id: badge + uses: https://git.hrafn.xyz/aether/vociferate/coverage-badge@v1.1.0 + with: + artefact-bucket-name: ${{ vars.ARTEFACT_BUCKET_NAME }} + artefact-bucket-endpoint: ${{ vars.ARTEFACT_BUCKET_ENDPONT }} - color="$(awk -v total="$COVERAGE_TOTAL" 'BEGIN { - if (total >= 80) print "brightgreen"; - else if (total >= 70) print "green"; - else if (total >= 60) print "yellowgreen"; - else if (total >= 50) print "yellow"; - else print "red"; - }')" - - cat > coverage-badge.svg < - - - - - - - - - - - - - - coverage - coverage - ${COVERAGE_TOTAL}% - ${COVERAGE_TOTAL}% - - - EOF - - - name: Upload PR coverage artefacts - id: upload - run: | - set -euo pipefail - - aws configure set default.s3.addressing_style path - - repo_name="${GITHUB_REPOSITORY##*/}" - prefix="${repo_name}/pull-requests/${{ github.event.pull_request.number }}" - report_url="${ARTEFACT_BUCKET_ENDPONT%/}/${ARTEFACT_BUCKET_NAME}/${prefix}/coverage.html" - badge_url="${ARTEFACT_BUCKET_ENDPONT%/}/${ARTEFACT_BUCKET_NAME}/${prefix}/coverage-badge.svg" - - aws --endpoint-url "${ARTEFACT_BUCKET_ENDPONT}" s3 cp coverage.html "s3://${ARTEFACT_BUCKET_NAME}/${prefix}/coverage.html" --content-type text/html - aws --endpoint-url "${ARTEFACT_BUCKET_ENDPONT}" s3 cp coverage-badge.svg "s3://${ARTEFACT_BUCKET_NAME}/${prefix}/coverage-badge.svg" --content-type image/svg+xml - aws --endpoint-url "${ARTEFACT_BUCKET_ENDPONT}" s3 cp coverage-summary.json "s3://${ARTEFACT_BUCKET_NAME}/${prefix}/coverage-summary.json" --content-type application/json - - printf 'report_url=%s\n' "$report_url" >> "$GITHUB_OUTPUT" - printf 'badge_url=%s\n' "$badge_url" >> "$GITHUB_OUTPUT" - - - name: Comment coverage report on pull request - env: - COVERAGE_BADGE_URL: ${{ steps.upload.outputs.badge_url }} - COVERAGE_REPORT_URL: ${{ steps.upload.outputs.report_url }} - COVERAGE_TOTAL: ${{ steps.coverage.outputs.total }} - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - set -euo pipefail - - marker='' - api_base="${GITHUB_SERVER_URL}/api/v1/repos/${GITHUB_REPOSITORY}" - - payload="$(jq -n \ - --arg marker "$marker" \ - --arg total "$COVERAGE_TOTAL" \ - --arg report "$COVERAGE_REPORT_URL" \ - --arg badge "$COVERAGE_BADGE_URL" \ - '{body: ($marker + "\n## Coverage Report\n\nCoverage total: **" + $total + "%**\n\n[HTML report](" + $report + ")\n\n![Coverage badge](" + $badge + ")")}')" - - comments="$(curl -sS -H "Authorization: token ${GITHUB_TOKEN}" "${api_base}/issues/${{ github.event.pull_request.number }}/comments")" - comment_id="$(printf '%s' "$comments" | jq -r '.[] | select(.body | contains("")) | .id' | tail -n 1)" - - if [[ -n "$comment_id" ]]; then - curl -sS -X PATCH \ - -H "Authorization: token ${GITHUB_TOKEN}" \ - -H 'Content-Type: application/json' \ - -d "$payload" \ - "${api_base}/issues/comments/${comment_id}" >/dev/null - else - curl -sS -X POST \ - -H "Authorization: token ${GITHUB_TOKEN}" \ - -H 'Content-Type: application/json' \ - -d "$payload" \ - "${api_base}/issues/${{ github.event.pull_request.number }}/comments" >/dev/null - fi + - name: Decorate PR + uses: https://git.hrafn.xyz/aether/vociferate/decorate-pr@v1.1.0 + with: + coverage-percentage: ${{ steps.badge.outputs.total }} + badge-url: ${{ steps.badge.outputs.badge-url }} + enable-changelog-gate: 'true' + changelog-gate-mode: strict - name: Add coverage summary run: | { echo '## Coverage' echo - echo '- Total: `${{ steps.coverage.outputs.total }}%`' - echo '- Report: ${{ steps.upload.outputs.report_url }}' - echo '- Badge: ${{ steps.upload.outputs.badge_url }}' + echo '- Total: `${{ steps.badge.outputs.total }}%`' + echo '- Report: ${{ steps.badge.outputs.report-url }}' + echo '- Badge: ${{ steps.badge.outputs.badge-url }}' echo echo '### Package Coverage' cat coverage-packages.md -- 2.49.1 From a65f62ea9df50b938fa261bbfc7c378a2bd594de Mon Sep 17 00:00:00 2001 From: Micheal Wilkinson Date: Sat, 21 Mar 2026 20:13:58 +0000 Subject: [PATCH 133/162] docs: update changelog for coverage test improvements and vociferate PR gate migration --- CHANGELOG.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index b8feb56..3969022 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,11 @@ A `### Breaking` section is used in addition to Keep a Changelog's standard sect ### Added +- `internal/homesick/version`: added `version_test.go` covering the `String` constant and semver format validation. +- `internal/homesick/cli`: added targeted tests for `list`, `generate`, `clone`, `status`, and `diff` CLI commands; coverage raised from 62.5% to 71.2%. +- `internal/homesick/core`: added `helpers_test.go` covering `runGit` pretend mode, `actionVerb`, `sayStatus`, `unlinkPath`, `linkPath`, `readSubdirs`, `matchesIgnoredDir`, `confirmDestroy` responses and read errors, `ExecAll` empty-command and no-repos-dir edge cases, and `Link`/`Unlink` default-castle wrappers; existing suites extended with `New` constructor and `PullAll` quiet-mode tests; coverage raised from 75.6% to 80.2%. +- PR validation now uses `vociferate/coverage-badge@v1.1.0` for coverage artefact upload and `vociferate/decorate-pr@v1.1.0` for PR comment decoration and changelog gate enforcement. + ### Changed - CI security scanning now uses GitHub Marketplace actions (`securego/gosec` and `golang/govulncheck-action`) instead of manual tool installation, improving reliability and caching. -- 2.49.1 From dd1d802605991d793b88269fba4f08404e269a8c Mon Sep 17 00:00:00 2001 From: Micheal Wilkinson Date: Sat, 21 Mar 2026 20:15:08 +0000 Subject: [PATCH 134/162] ci: replace gosec action with direct invocation, pin govulncheck to v1.0.4 Per security scanning requirements in project instructions: - Replace securego/gosec@v2.22.3 action with go install + gosec run step in both push-validation and pr-validation to avoid compatibility issues with Go 1.26.1 - Pin golang/govulncheck-action from @v1 to @v1.0.4 in both workflows; major-version tags do not resolve reliably in Gitea API - Move GOTOOLCHAIN=auto from per-step env to job-level env in both workflows - Bump coverage-badge in push-validation from v1.0.1 to v1.1.0 --- .gitea/workflows/pr-validation.yml | 14 ++++++-------- .gitea/workflows/push-validation.yml | 16 +++++++--------- 2 files changed, 13 insertions(+), 17 deletions(-) diff --git a/.gitea/workflows/pr-validation.yml b/.gitea/workflows/pr-validation.yml index e8396be..613a157 100644 --- a/.gitea/workflows/pr-validation.yml +++ b/.gitea/workflows/pr-validation.yml @@ -22,6 +22,7 @@ jobs: AWS_SECRET_ACCESS_KEY: ${{ secrets.ARTEFACT_BUCKET_WRITE_ACCESS_SECRET }} AWS_DEFAULT_REGION: ${{ vars.ARTEFACT_BUCKET_REGION }} AWS_EC2_METADATA_DISABLED: true + GOTOOLCHAIN: auto SUMMARY_FILE: ${{ runner.temp }}/summary.md steps: - name: Checkout @@ -136,16 +137,13 @@ jobs: fi - name: Run Gosec Security Scanner - uses: securego/gosec@v2.22.3 - env: - GOTOOLCHAIN: auto - with: - args: './...' + run: | + set -euo pipefail + go install github.com/securego/gosec/v2/cmd/gosec@latest + gosec ./... - name: Run Go Vulnerability Check - uses: golang/govulncheck-action@v1 - env: - GOTOOLCHAIN: auto + uses: golang/govulncheck-action@v1.0.4 - name: Upload coverage badge id: badge diff --git a/.gitea/workflows/push-validation.yml b/.gitea/workflows/push-validation.yml index bcfac07..33a322f 100644 --- a/.gitea/workflows/push-validation.yml +++ b/.gitea/workflows/push-validation.yml @@ -22,6 +22,7 @@ jobs: AWS_SECRET_ACCESS_KEY: ${{ secrets.ARTEFACT_BUCKET_WRITE_ACCESS_SECRET }} AWS_DEFAULT_REGION: ${{ vars.ARTEFACT_BUCKET_REGION }} AWS_EC2_METADATA_DISABLED: true + GOTOOLCHAIN: auto SUMMARY_FILE: ${{ runner.temp }}/summary.md steps: - name: Checkout @@ -64,16 +65,13 @@ jobs: fi - name: Run Gosec Security Scanner - uses: securego/gosec@v2.22.3 - env: - GOTOOLCHAIN: auto - with: - args: './...' + run: | + set -euo pipefail + go install github.com/securego/gosec/v2/cmd/gosec@latest + gosec ./... - name: Run Go Vulnerability Check - uses: golang/govulncheck-action@v1 - env: - GOTOOLCHAIN: auto + uses: golang/govulncheck-action@v1.0.4 - name: Install AWS CLI v2 uses: ankurk91/install-aws-cli-action@v1 @@ -158,7 +156,7 @@ jobs: - name: Publish coverage artefacts id: coverage-badge - uses: https://git.hrafn.xyz/aether/vociferate/coverage-badge@v1.0.1 + uses: https://git.hrafn.xyz/aether/vociferate/coverage-badge@v1.1.0 with: coverage-profile: coverage.out coverage-html: coverage.html -- 2.49.1 From 02eebb02fe1fb2201dbe3570d8cbe06723fb49b5 Mon Sep 17 00:00:00 2001 From: Micheal Wilkinson Date: Sat, 21 Mar 2026 20:15:30 +0000 Subject: [PATCH 135/162] docs: fix badge link target to use actions/runs/latest per workflow standards --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index f981ab7..7aa1b16 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # homesick -[![Main Validation](https://git.hrafn.xyz/aether/gosick/actions/workflows/push-validation.yml/badge.svg?branch=main&event=push)](https://git.hrafn.xyz/aether/gosick/actions/workflows/push-validation.yml) +[![Main Validation](https://git.hrafn.xyz/aether/gosick/actions/workflows/push-validation.yml/badge.svg?branch=main&event=push)](https://git.hrafn.xyz/aether/gosick/actions/runs/latest?workflow=push-validation.yml&branch=main&event=push) [![Coverage](https://s3.hrafn.xyz/aether-workflow-report-artefacts/gosick/branch/main/coverage-badge.svg)](https://s3.hrafn.xyz/aether-workflow-report-artefacts/gosick/branch/main/coverage.html) Your home directory is your castle. Don't leave your dotfiles behind. -- 2.49.1 From 3fa377efe2fd3cb47f22c419b6c697bfc3d8a637 Mon Sep 17 00:00:00 2001 From: Micheal Wilkinson Date: Sat, 21 Mar 2026 20:16:24 +0000 Subject: [PATCH 136/162] docs: update changelog for CI security hardening and badge URL fix --- CHANGELOG.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3969022..17c0c84 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,11 @@ A `### Breaking` section is used in addition to Keep a Changelog's standard sect ### Changed +- `gosec` security scanning in CI now invoked directly via `go install + gosec ./...` instead of the `securego/gosec` action, resolving compatibility issues with Go 1.26.1. +- `golang/govulncheck-action` pinned from `@v1` to `@v1.0.4` in push and PR validation; major-version tags do not resolve reliably in Gitea API. +- `GOTOOLCHAIN=auto` moved from per-step env to job-level env in push and PR validation workflows. +- Push validation `vociferate/coverage-badge` bumped from `v1.0.1` to `v1.1.0` for version consistency with PR validation. +- README badge link target updated to `actions/runs/latest?workflow=...` format per workflow standards. - CI security scanning now uses GitHub Marketplace actions (`securego/gosec` and `golang/govulncheck-action`) instead of manual tool installation, improving reliability and caching. - CI setup compatibility fix: gosec scanner now references the correct public action source (`securego/gosec`), resolving action clone failures in Gitea runners. - CI security scanner compatibility: gosec and govulncheck action steps now set `GOTOOLCHAIN=auto` so repositories requiring newer Go versions are analyzed successfully. -- 2.49.1 From 8a3fde8e07d1e0282730317c39bc62050d0c9f2a Mon Sep 17 00:00:00 2001 From: Micheal Wilkinson Date: Sat, 21 Mar 2026 20:18:25 +0000 Subject: [PATCH 137/162] ci: bump vociferate prepare and publish to v1.1.0 --- .gitea/workflows/prepare-release.yml | 4 ++-- .gitea/workflows/tag-build-artifacts.yml | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.gitea/workflows/prepare-release.yml b/.gitea/workflows/prepare-release.yml index e088203..adf57fd 100644 --- a/.gitea/workflows/prepare-release.yml +++ b/.gitea/workflows/prepare-release.yml @@ -24,7 +24,7 @@ jobs: fi - name: Vociferate prepare - uses: https://git.hrafn.xyz/aether/vociferate/prepare@v1.0.1 + uses: https://git.hrafn.xyz/aether/vociferate/prepare@v1.1.0 publish: needs: prepare @@ -43,4 +43,4 @@ jobs: fi - name: Vociferate publish - uses: https://git.hrafn.xyz/aether/vociferate/publish@v1.0.1 \ No newline at end of file + uses: https://git.hrafn.xyz/aether/vociferate/publish@v1.1.0 \ No newline at end of file diff --git a/.gitea/workflows/tag-build-artifacts.yml b/.gitea/workflows/tag-build-artifacts.yml index 97b706f..6b15f30 100644 --- a/.gitea/workflows/tag-build-artifacts.yml +++ b/.gitea/workflows/tag-build-artifacts.yml @@ -90,4 +90,4 @@ jobs: fi - name: Vociferate publish - uses: https://git.hrafn.xyz/aether/vociferate/publish@v1.0.1 + uses: https://git.hrafn.xyz/aether/vociferate/publish@v1.1.0 -- 2.49.1 From 519c6703d2a1642ec7beb7d7570a187efc050b1d Mon Sep 17 00:00:00 2001 From: Micheal Wilkinson Date: Sat, 21 Mar 2026 20:18:25 +0000 Subject: [PATCH 138/162] docs: update changelog for vociferate v1.1.0 bump --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 17c0c84..0280504 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,7 @@ A `### Breaking` section is used in addition to Keep a Changelog's standard sect - `golang/govulncheck-action` pinned from `@v1` to `@v1.0.4` in push and PR validation; major-version tags do not resolve reliably in Gitea API. - `GOTOOLCHAIN=auto` moved from per-step env to job-level env in push and PR validation workflows. - Push validation `vociferate/coverage-badge` bumped from `v1.0.1` to `v1.1.0` for version consistency with PR validation. +- `vociferate/prepare` and `vociferate/publish` in `prepare-release.yml` and `tag-build-artifacts.yml` bumped from `v1.0.1` to `v1.1.0` for cross-workflow version consistency. - README badge link target updated to `actions/runs/latest?workflow=...` format per workflow standards. - CI security scanning now uses GitHub Marketplace actions (`securego/gosec` and `golang/govulncheck-action`) instead of manual tool installation, improving reliability and caching. - CI setup compatibility fix: gosec scanner now references the correct public action source (`securego/gosec`), resolving action clone failures in Gitea runners. -- 2.49.1 From 038b109e7bf4b4833284dc0d7184444c043e3463 Mon Sep 17 00:00:00 2001 From: Micheal Wilkinson Date: Sat, 21 Mar 2026 20:45:05 +0000 Subject: [PATCH 139/162] ci: align govulncheck action inputs with workflow standard --- .gitea/workflows/pr-validation.yml | 4 ++++ .gitea/workflows/push-validation.yml | 4 ++++ 2 files changed, 8 insertions(+) diff --git a/.gitea/workflows/pr-validation.yml b/.gitea/workflows/pr-validation.yml index 613a157..1fc79c9 100644 --- a/.gitea/workflows/pr-validation.yml +++ b/.gitea/workflows/pr-validation.yml @@ -144,6 +144,10 @@ jobs: - name: Run Go Vulnerability Check uses: golang/govulncheck-action@v1.0.4 + with: + go-package: ./... + cache: true + cache-dependency-path: go.sum - name: Upload coverage badge id: badge diff --git a/.gitea/workflows/push-validation.yml b/.gitea/workflows/push-validation.yml index 33a322f..01898dd 100644 --- a/.gitea/workflows/push-validation.yml +++ b/.gitea/workflows/push-validation.yml @@ -72,6 +72,10 @@ jobs: - name: Run Go Vulnerability Check uses: golang/govulncheck-action@v1.0.4 + with: + go-package: ./... + cache: true + cache-dependency-path: go.sum - name: Install AWS CLI v2 uses: ankurk91/install-aws-cli-action@v1 -- 2.49.1 From d642870a663fb18ff638d32b334dee4230bd3227 Mon Sep 17 00:00:00 2001 From: Micheal Wilkinson Date: Sat, 21 Mar 2026 20:45:05 +0000 Subject: [PATCH 140/162] chore(go): inject stdin and pass rc force explicitly --- cmd/homesick/main.go | 7 ++--- cmd/homesick/main_test.go | 2 +- internal/homesick/cli/cli.go | 18 +++---------- internal/homesick/cli/cli_test.go | 40 ++++++++++++++--------------- internal/homesick/core/core.go | 12 +++++---- internal/homesick/core/core_test.go | 24 +++++++++++++---- internal/homesick/core/rc_test.go | 29 +++++++++------------ 7 files changed, 66 insertions(+), 66 deletions(-) diff --git a/cmd/homesick/main.go b/cmd/homesick/main.go index 5af55ed..aaaab33 100644 --- a/cmd/homesick/main.go +++ b/cmd/homesick/main.go @@ -8,10 +8,11 @@ import ( ) func main() { - exitCode := run(os.Args[1:], os.Stdout, os.Stderr) + _ = os.Setenv("GIT_TERMINAL_PROMPT", "0") + exitCode := run(os.Args[1:], os.Stdin, os.Stdout, os.Stderr) os.Exit(exitCode) } -func run(args []string, stdout io.Writer, stderr io.Writer) int { - return cli.Run(args, stdout, stderr) +func run(args []string, stdin io.Reader, stdout io.Writer, stderr io.Writer) int { + return cli.Run(args, stdin, stdout, stderr) } diff --git a/cmd/homesick/main_test.go b/cmd/homesick/main_test.go index c307e7e..f3413e7 100644 --- a/cmd/homesick/main_test.go +++ b/cmd/homesick/main_test.go @@ -13,7 +13,7 @@ func TestRunVersionCommand(t *testing.T) { stdout := &bytes.Buffer{} stderr := &bytes.Buffer{} - exitCode := run([]string{"version"}, stdout, stderr) + exitCode := run([]string{"version"}, bytes.NewBuffer(nil), stdout, stderr) if exitCode != 0 { t.Fatalf("run(version) exit code = %d, want 0", exitCode) } diff --git a/internal/homesick/cli/cli.go b/internal/homesick/cli/cli.go index f84f12e..383a747 100644 --- a/internal/homesick/cli/cli.go +++ b/internal/homesick/cli/cli.go @@ -13,10 +13,10 @@ import ( "github.com/alecthomas/kong" ) -func Run(args []string, stdout io.Writer, stderr io.Writer) int { +func Run(args []string, stdin io.Reader, stdout io.Writer, stderr io.Writer) int { model := &cliModel{} - app, err := core.New(stdout, stderr) + app, err := core.NewApp(stdin, stdout, stderr) if err != nil { _, _ = fmt.Fprintf(stderr, "error: %v\n", err) return 1 @@ -227,11 +227,7 @@ func (c *openCmd) Run(app *core.App) error { return app.Open(defaultCastle(c. func (c *execCmd) Run(app *core.App) error { return app.Exec(c.Castle, c.Command) } func (c *execAllCmd) Run(app *core.App) error { return app.ExecAll(c.Command) } func (c *rcCmd) Run(app *core.App) error { - originalForce := app.Force - app.Force = c.Force - err := app.Rc(defaultCastle(c.Castle)) - app.Force = originalForce - return err + return app.Rc(defaultCastle(c.Castle), c.Force) } func (c *generateCmd) Run(app *core.App) error { return app.Generate(c.Path) } @@ -329,11 +325,3 @@ type cliExitError struct { func (e *cliExitError) Error() string { return e.err.Error() } - -func notImplemented(command string) error { - return &cliExitError{code: 2, err: fmt.Errorf("%s is not implemented in Go yet", command)} -} - -func init() { - _ = os.Setenv("GIT_TERMINAL_PROMPT", "0") -} diff --git a/internal/homesick/cli/cli_test.go b/internal/homesick/cli/cli_test.go index 5363b11..b3a27cf 100644 --- a/internal/homesick/cli/cli_test.go +++ b/internal/homesick/cli/cli_test.go @@ -67,7 +67,7 @@ func (s *CLISuite) TestRun_VersionAliases() { s.stdout.Reset() s.stderr.Reset() - exitCode := cli.Run(args, s.stdout, s.stderr) + exitCode := cli.Run(args, bytes.NewBuffer(nil), s.stdout, s.stderr) require.Equal(s.T(), 0, exitCode) require.Equal(s.T(), version.String+"\n", s.stdout.String()) require.Empty(s.T(), s.stderr.String()) @@ -75,7 +75,7 @@ func (s *CLISuite) TestRun_VersionAliases() { } func (s *CLISuite) TestRun_ShowPath_DefaultCastle() { - exitCode := cli.Run([]string{"show_path"}, s.stdout, s.stderr) + exitCode := cli.Run([]string{"show_path"}, bytes.NewBuffer(nil), s.stdout, s.stderr) require.Equal(s.T(), 0, exitCode) require.Equal(s.T(), filepath.Join(s.homeDir, ".homesick", "repos", "dotfiles")+"\n", s.stdout.String()) @@ -83,7 +83,7 @@ func (s *CLISuite) TestRun_ShowPath_DefaultCastle() { } func (s *CLISuite) TestRun_Cd_DefaultCastle() { - exitCode := cli.Run([]string{"cd"}, s.stdout, s.stderr) + exitCode := cli.Run([]string{"cd"}, bytes.NewBuffer(nil), s.stdout, s.stderr) require.Equal(s.T(), 0, exitCode) require.Equal(s.T(), filepath.Join(s.homeDir, ".homesick", "repos", "dotfiles")+"\n", s.stdout.String()) @@ -91,7 +91,7 @@ func (s *CLISuite) TestRun_Cd_DefaultCastle() { } func (s *CLISuite) TestRun_Cd_ExplicitCastle() { - exitCode := cli.Run([]string{"cd", "work"}, s.stdout, s.stderr) + exitCode := cli.Run([]string{"cd", "work"}, bytes.NewBuffer(nil), s.stdout, s.stderr) require.Equal(s.T(), 0, exitCode) require.Equal(s.T(), filepath.Join(s.homeDir, ".homesick", "repos", "work")+"\n", s.stdout.String()) @@ -102,7 +102,7 @@ func (s *CLISuite) TestRun_Exec_RunsCommandInCastleRoot() { castleRoot := filepath.Join(s.homeDir, ".homesick", "repos", "dotfiles") require.NoError(s.T(), os.MkdirAll(castleRoot, 0o755)) - exitCode := cli.Run([]string{"exec", "dotfiles", "pwd"}, s.stdout, s.stderr) + exitCode := cli.Run([]string{"exec", "dotfiles", "pwd"}, bytes.NewBuffer(nil), s.stdout, s.stderr) require.Equal(s.T(), 0, exitCode) require.Contains(s.T(), s.stdout.String(), castleRoot) @@ -114,7 +114,7 @@ func (s *CLISuite) TestRun_Exec_WithPretend_DoesNotExecute() { require.NoError(s.T(), os.MkdirAll(castleRoot, 0o755)) target := filepath.Join(castleRoot, "should-not-exist") - exitCode := cli.Run([]string{"--pretend", "exec", "dotfiles", "touch should-not-exist"}, s.stdout, s.stderr) + exitCode := cli.Run([]string{"--pretend", "exec", "dotfiles", "touch should-not-exist"}, bytes.NewBuffer(nil), s.stdout, s.stderr) require.Equal(s.T(), 0, exitCode) require.NoFileExists(s.T(), target) @@ -127,7 +127,7 @@ func (s *CLISuite) TestRun_Exec_WithDryRunAlias_DoesNotExecute() { require.NoError(s.T(), os.MkdirAll(castleRoot, 0o755)) target := filepath.Join(castleRoot, "should-not-exist") - exitCode := cli.Run([]string{"--dry-run", "exec", "dotfiles", "touch should-not-exist"}, s.stdout, s.stderr) + exitCode := cli.Run([]string{"--dry-run", "exec", "dotfiles", "touch should-not-exist"}, bytes.NewBuffer(nil), s.stdout, s.stderr) require.Equal(s.T(), 0, exitCode) require.NoFileExists(s.T(), target) @@ -139,7 +139,7 @@ func (s *CLISuite) TestRun_Exec_WithQuiet_SuppressesStatusOutput() { castleRoot := filepath.Join(s.homeDir, ".homesick", "repos", "dotfiles") require.NoError(s.T(), os.MkdirAll(castleRoot, 0o755)) - exitCode := cli.Run([]string{"--quiet", "exec", "dotfiles", "true"}, s.stdout, s.stderr) + exitCode := cli.Run([]string{"--quiet", "exec", "dotfiles", "true"}, bytes.NewBuffer(nil), s.stdout, s.stderr) require.Equal(s.T(), 0, exitCode) require.Empty(s.T(), s.stdout.String()) @@ -147,7 +147,7 @@ func (s *CLISuite) TestRun_Exec_WithQuiet_SuppressesStatusOutput() { } func (s *CLISuite) TestRun_PullAll_NoCastlesIsNoop() { - exitCode := cli.Run([]string{"pull", "--all"}, s.stdout, s.stderr) + exitCode := cli.Run([]string{"pull", "--all"}, bytes.NewBuffer(nil), s.stdout, s.stderr) require.Equal(s.T(), 0, exitCode) require.Empty(s.T(), s.stderr.String()) @@ -158,7 +158,7 @@ func (s *CLISuite) TestRun_Rc_HomesickrcRequiresForce() { require.NoError(s.T(), os.MkdirAll(castleRoot, 0o755)) require.NoError(s.T(), os.WriteFile(filepath.Join(castleRoot, ".homesickrc"), []byte("# ruby\n"), 0o644)) - exitCode := cli.Run([]string{"rc", "dotfiles"}, s.stdout, s.stderr) + exitCode := cli.Run([]string{"rc", "dotfiles"}, bytes.NewBuffer(nil), s.stdout, s.stderr) require.NotEqual(s.T(), 0, exitCode) require.Contains(s.T(), s.stderr.String(), "--force") @@ -169,14 +169,14 @@ func (s *CLISuite) TestRun_Rc_WithForceRuns() { require.NoError(s.T(), os.MkdirAll(castleRoot, 0o755)) require.NoError(s.T(), os.WriteFile(filepath.Join(castleRoot, ".homesickrc"), []byte("# ruby\n"), 0o644)) - exitCode := cli.Run([]string{"rc", "--force", "dotfiles"}, s.stdout, s.stderr) + exitCode := cli.Run([]string{"rc", "--force", "dotfiles"}, bytes.NewBuffer(nil), s.stdout, s.stderr) require.Equal(s.T(), 0, exitCode) require.Empty(s.T(), s.stderr.String()) } func (s *CLISuite) TestRun_CloneSubcommandHelp() { - exitCode := cli.Run([]string{"clone", "--help"}, s.stdout, s.stderr) + exitCode := cli.Run([]string{"clone", "--help"}, bytes.NewBuffer(nil), s.stdout, s.stderr) require.Equal(s.T(), 0, exitCode) require.Contains(s.T(), s.stdout.String(), "clone") @@ -189,7 +189,7 @@ func (s *CLISuite) TestRun_Help_UsesProgramNameAndDescription() { s.T().Cleanup(func() { os.Args = originalArgs }) os.Args = []string{"gosick"} - exitCode := cli.Run([]string{"--help"}, s.stdout, s.stderr) + exitCode := cli.Run([]string{"--help"}, bytes.NewBuffer(nil), s.stdout, s.stderr) require.Equal(s.T(), 0, exitCode) require.Contains(s.T(), s.stdout.String(), "Usage: gosick") @@ -204,7 +204,7 @@ func (s *CLISuite) TestRun_SymlinkAlias_MatchesLinkCommand() { require.NoError(s.T(), os.MkdirAll(castleHome, 0o755)) require.NoError(s.T(), os.WriteFile(filepath.Join(castleHome, ".vimrc"), []byte("set number\n"), 0o644)) - exitCode := cli.Run([]string{"symlink", "dotfiles"}, s.stdout, s.stderr) + exitCode := cli.Run([]string{"symlink", "dotfiles"}, bytes.NewBuffer(nil), s.stdout, s.stderr) require.Equal(s.T(), 0, exitCode) target := filepath.Join(s.homeDir, ".vimrc") @@ -215,7 +215,7 @@ func (s *CLISuite) TestRun_SymlinkAlias_MatchesLinkCommand() { } func (s *CLISuite) TestRun_Commit_PositionalMessageCompatibility() { - exitCode := cli.Run([]string{"--pretend", "commit", "dotfiles", "behavior-suite-commit"}, s.stdout, s.stderr) + exitCode := cli.Run([]string{"--pretend", "commit", "dotfiles", "behavior-suite-commit"}, bytes.NewBuffer(nil), s.stdout, s.stderr) require.Equal(s.T(), 0, exitCode) require.Contains(s.T(), s.stdout.String(), "git commit -m behavior-suite-commit") @@ -225,7 +225,7 @@ func (s *CLISuite) TestRun_Commit_PositionalMessageCompatibility() { func (s *CLISuite) TestRun_List_NoArguments() { s.createCastleRepo("dotfiles") - exitCode := cli.Run([]string{"list"}, s.stdout, s.stderr) + exitCode := cli.Run([]string{"list"}, bytes.NewBuffer(nil), s.stdout, s.stderr) require.Equal(s.T(), 0, exitCode) require.Contains(s.T(), s.stdout.String(), "dotfiles") @@ -235,7 +235,7 @@ func (s *CLISuite) TestRun_List_NoArguments() { func (s *CLISuite) TestRun_Generate_CreatesNewCastle() { castlePath := filepath.Join(s.T().TempDir(), "my-castle") - exitCode := cli.Run([]string{"generate", castlePath}, s.stdout, s.stderr) + exitCode := cli.Run([]string{"generate", castlePath}, bytes.NewBuffer(nil), s.stdout, s.stderr) require.Equal(s.T(), 0, exitCode) require.DirExists(s.T(), filepath.Join(castlePath, ".git")) @@ -243,7 +243,7 @@ func (s *CLISuite) TestRun_Generate_CreatesNewCastle() { } func (s *CLISuite) TestRun_Clone_WithoutArgs() { - exitCode := cli.Run([]string{"clone"}, s.stdout, s.stderr) + exitCode := cli.Run([]string{"clone"}, bytes.NewBuffer(nil), s.stdout, s.stderr) // Clone requires arguments, should fail require.NotEqual(s.T(), 0, exitCode) @@ -253,7 +253,7 @@ func (s *CLISuite) TestRun_Status_DefaultCastle() { castleRoot := s.createCastleRepo("dotfiles") require.NoError(s.T(), os.WriteFile(filepath.Join(castleRoot, "home", ".vimrc"), []byte("changed\n"), 0o644)) - exitCode := cli.Run([]string{"status"}, s.stdout, s.stderr) + exitCode := cli.Run([]string{"status"}, bytes.NewBuffer(nil), s.stdout, s.stderr) require.Equal(s.T(), 0, exitCode) require.Contains(s.T(), s.stdout.String(), "modified:") @@ -264,7 +264,7 @@ func (s *CLISuite) TestRun_Diff_DefaultCastle() { castleRoot := s.createCastleRepo("dotfiles") require.NoError(s.T(), os.WriteFile(filepath.Join(castleRoot, "home", ".vimrc"), []byte("changed\n"), 0o644)) - exitCode := cli.Run([]string{"diff"}, s.stdout, s.stderr) + exitCode := cli.Run([]string{"diff"}, bytes.NewBuffer(nil), s.stdout, s.stderr) require.Equal(s.T(), 0, exitCode) require.Contains(s.T(), s.stdout.String(), "diff --git") diff --git a/internal/homesick/core/core.go b/internal/homesick/core/core.go index 402220b..aef3dfc 100644 --- a/internal/homesick/core/core.go +++ b/internal/homesick/core/core.go @@ -21,13 +21,15 @@ type App struct { Stdin io.Reader Stdout io.Writer Stderr io.Writer - Verbose bool Force bool Quiet bool Pretend bool } -func New(stdout io.Writer, stderr io.Writer) (*App, error) { +func NewApp(stdin io.Reader, stdout io.Writer, stderr io.Writer) (*App, error) { + if stdin == nil { + return nil, errors.New("stdin reader cannot be nil") + } if stdout == nil { return nil, errors.New("stdout writer cannot be nil") } @@ -43,7 +45,7 @@ func New(stdout io.Writer, stderr io.Writer) (*App, error) { return &App{ HomeDir: home, ReposDir: filepath.Join(home, ".homesick", "repos"), - Stdin: os.Stdin, + Stdin: stdin, Stdout: stdout, Stderr: stderr, }, nil @@ -845,7 +847,7 @@ func gitOutput(dir string, args ...string) (string, error) { // If a .homesickrc file exists in the castle root and no parity.rb wrapper // already exists in .homesick.d, a Ruby wrapper script named parity.rb is // written there before execution so that it sorts first. -func (a *App) Rc(castle string) error { +func (a *App) Rc(castle string, force bool) error { castleRoot := filepath.Join(a.ReposDir, castle) if _, err := os.Stat(castleRoot); err != nil { if errors.Is(err, os.ErrNotExist) { @@ -857,7 +859,7 @@ func (a *App) Rc(castle string) error { homesickD := filepath.Join(castleRoot, ".homesick.d") homesickRc := filepath.Join(castleRoot, ".homesickrc") - if _, err := os.Stat(homesickRc); err == nil && !a.Force { + if _, err := os.Stat(homesickRc); err == nil && !force { return errors.New("refusing to run legacy .homesickrc without --force") } diff --git a/internal/homesick/core/core_test.go b/internal/homesick/core/core_test.go index 0ff0220..354f3c0 100644 --- a/internal/homesick/core/core_test.go +++ b/internal/homesick/core/core_test.go @@ -6,9 +6,19 @@ import ( "testing" ) -func TestNewRejectsNilWriters(t *testing.T) { +func TestNewAppRejectsNilReaders(t *testing.T) { + t.Run("nil stdin", func(t *testing.T) { + app, err := NewApp(nil, &bytes.Buffer{}, &bytes.Buffer{}) + if err == nil { + t.Fatal("expected error for nil stdin") + } + if app != nil { + t.Fatal("expected nil app for nil stdin") + } + }) + t.Run("nil stdout", func(t *testing.T) { - app, err := New(nil, &bytes.Buffer{}) + app, err := NewApp(new(bytes.Buffer), nil, &bytes.Buffer{}) if err == nil { t.Fatal("expected error for nil stdout") } @@ -18,7 +28,7 @@ func TestNewRejectsNilWriters(t *testing.T) { }) t.Run("nil stderr", func(t *testing.T) { - app, err := New(&bytes.Buffer{}, nil) + app, err := NewApp(new(bytes.Buffer), &bytes.Buffer{}, nil) if err == nil { t.Fatal("expected error for nil stderr") } @@ -49,11 +59,12 @@ func TestDeriveDestination(t *testing.T) { } } -func TestNewInitializesApp(t *testing.T) { +func TestNewAppInitializesApp(t *testing.T) { + stdin := new(bytes.Buffer) stdout := &bytes.Buffer{} stderr := &bytes.Buffer{} - app, err := New(stdout, stderr) + app, err := NewApp(stdin, stdout, stderr) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -61,6 +72,9 @@ func TestNewInitializesApp(t *testing.T) { t.Fatal("expected app instance") } + if app.Stdin != stdin { + t.Fatal("expected stdin reader to be assigned") + } if app.Stdout != stdout { t.Fatal("expected stdout writer to be assigned") } diff --git a/internal/homesick/core/rc_test.go b/internal/homesick/core/rc_test.go index 2403dac..ba54ed8 100644 --- a/internal/homesick/core/rc_test.go +++ b/internal/homesick/core/rc_test.go @@ -53,7 +53,7 @@ var _ io.Writer // TestRc_UnknownCastleReturnsError ensures Rc returns an error when the // castle directory does not exist. func (s *RcSuite) TestRc_UnknownCastleReturnsError() { - err := s.app.Rc("nonexistent") + err := s.app.Rc("nonexistent", false) require.Error(s.T(), err) } @@ -61,7 +61,7 @@ func (s *RcSuite) TestRc_UnknownCastleReturnsError() { // .homesickrc are present. func (s *RcSuite) TestRc_NoScriptsAndNoHomesickrcIsNoop() { s.createCastle("dotfiles") - require.NoError(s.T(), s.app.Rc("dotfiles")) + require.NoError(s.T(), s.app.Rc("dotfiles", false)) } // TestRc_HomesickrcRequiresForce ensures legacy .homesickrc does not run @@ -71,7 +71,7 @@ func (s *RcSuite) TestRc_HomesickrcRequiresForce() { homesickRc := filepath.Join(castleRoot, ".homesickrc") require.NoError(s.T(), os.WriteFile(homesickRc, []byte("# ruby setup code\n"), 0o644)) - err := s.app.Rc("dotfiles") + err := s.app.Rc("dotfiles", false) require.Error(s.T(), err) require.Contains(s.T(), err.Error(), "--force") require.NoFileExists(s.T(), filepath.Join(castleRoot, ".homesick.d", "parity.rb")) @@ -84,8 +84,7 @@ func (s *RcSuite) TestRc_HomesickrcRunsWithForce() { homesickRc := filepath.Join(castleRoot, ".homesickrc") require.NoError(s.T(), os.WriteFile(homesickRc, []byte("# ruby setup code\n"), 0o644)) - s.app.Force = true - require.NoError(s.T(), s.app.Rc("dotfiles")) + require.NoError(s.T(), s.app.Rc("dotfiles", true)) require.FileExists(s.T(), filepath.Join(castleRoot, ".homesick.d", "parity.rb")) } @@ -103,7 +102,7 @@ func (s *RcSuite) TestRc_ExecutesScriptsInSortedOrder() { require.NoError(s.T(), os.WriteFile(scriptA, []byte("#!/bin/sh\necho a >> "+orderFile+"\n"), 0o755)) require.NoError(s.T(), os.WriteFile(scriptB, []byte("#!/bin/sh\necho b >> "+orderFile+"\n"), 0o755)) - require.NoError(s.T(), s.app.Rc("dotfiles")) + require.NoError(s.T(), s.app.Rc("dotfiles", false)) content, err := os.ReadFile(orderFile) require.NoError(s.T(), err) @@ -121,7 +120,7 @@ func (s *RcSuite) TestRc_SkipsNonExecutableFiles() { // Write a script that would exit 1 if actually run — verify it is skipped. require.NoError(s.T(), os.WriteFile(notExec, []byte("#!/bin/sh\nexit 1\n"), 0o644)) - require.NoError(s.T(), s.app.Rc("dotfiles")) + require.NoError(s.T(), s.app.Rc("dotfiles", false)) } // TestRc_HomesickrcCreatesRubyWrapper verifies that a .homesickrc file causes @@ -130,9 +129,7 @@ func (s *RcSuite) TestRc_HomesickrcCreatesRubyWrapper() { castleRoot := s.createCastle("dotfiles") homesickRc := filepath.Join(castleRoot, ".homesickrc") require.NoError(s.T(), os.WriteFile(homesickRc, []byte("# ruby setup code\n"), 0o644)) - s.app.Force = true - - require.NoError(s.T(), s.app.Rc("dotfiles")) + require.NoError(s.T(), s.app.Rc("dotfiles", true)) wrapperPath := filepath.Join(castleRoot, ".homesick.d", "parity.rb") require.FileExists(s.T(), wrapperPath) @@ -152,7 +149,6 @@ func (s *RcSuite) TestRc_HomesickrcWrapperNotOverwrittenIfExists() { castleRoot := s.createCastle("dotfiles") homesickRc := filepath.Join(castleRoot, ".homesickrc") require.NoError(s.T(), os.WriteFile(homesickRc, []byte("# ruby setup code\n"), 0o644)) - s.app.Force = true homesickD := filepath.Join(castleRoot, ".homesick.d") require.NoError(s.T(), os.MkdirAll(homesickD, 0o755)) @@ -160,7 +156,7 @@ func (s *RcSuite) TestRc_HomesickrcWrapperNotOverwrittenIfExists() { originalContent := []byte("#!/bin/sh\n# pre-existing wrapper\n") require.NoError(s.T(), os.WriteFile(wrapperPath, originalContent, 0o755)) - require.NoError(s.T(), s.app.Rc("dotfiles")) + require.NoError(s.T(), s.app.Rc("dotfiles", true)) content, err := os.ReadFile(wrapperPath) require.NoError(s.T(), err) @@ -173,7 +169,6 @@ func (s *RcSuite) TestRc_HomesickrcWrapperCreatedBeforeExecution() { castleRoot := s.createCastle("dotfiles") homesickRc := filepath.Join(castleRoot, ".homesickrc") require.NoError(s.T(), os.WriteFile(homesickRc, []byte("# ruby setup code\n"), 0o644)) - s.app.Force = true homesickD := filepath.Join(castleRoot, ".homesick.d") require.NoError(s.T(), os.MkdirAll(homesickD, 0o755)) @@ -186,7 +181,7 @@ func (s *RcSuite) TestRc_HomesickrcWrapperCreatedBeforeExecution() { "#!/bin/sh\n[ -f "+wrapperPath+" ] && echo present >> "+orderFile+"\n", ), 0o755)) - require.NoError(s.T(), s.app.Rc("dotfiles")) + require.NoError(s.T(), s.app.Rc("dotfiles", true)) content, err := os.ReadFile(orderFile) require.NoError(s.T(), err) @@ -203,7 +198,7 @@ func (s *RcSuite) TestRc_FailingScriptReturnsError() { failing := filepath.Join(homesickD, "10_fail.sh") require.NoError(s.T(), os.WriteFile(failing, []byte("#!/bin/sh\nexit 42\n"), 0o755)) - err := s.app.Rc("dotfiles") + err := s.app.Rc("dotfiles", false) require.Error(s.T(), err) } @@ -217,7 +212,7 @@ func (s *RcSuite) TestRc_ScriptOutputForwarded() { script := filepath.Join(homesickD, "10_output.sh") require.NoError(s.T(), os.WriteFile(script, []byte("#!/bin/sh\necho hello\necho world >&2\n"), 0o755)) - require.NoError(s.T(), s.app.Rc("dotfiles")) + require.NoError(s.T(), s.app.Rc("dotfiles", false)) require.Contains(s.T(), s.stdout.String(), "hello") require.Contains(s.T(), s.stderr.String(), "world") } @@ -232,6 +227,6 @@ func (s *RcSuite) TestRc_ScriptsRunWithCwdSetToCastleRoot() { script := filepath.Join(homesickD, "10_pwd.sh") require.NoError(s.T(), os.WriteFile(script, []byte("#!/bin/sh\npwd\n"), 0o755)) - require.NoError(s.T(), s.app.Rc("dotfiles")) + require.NoError(s.T(), s.app.Rc("dotfiles", false)) require.Contains(s.T(), s.stdout.String(), castleRoot) } -- 2.49.1 From bc0a6747b8e5806266201431c68394644955620c Mon Sep 17 00:00:00 2001 From: Micheal Wilkinson Date: Sat, 21 Mar 2026 20:45:05 +0000 Subject: [PATCH 141/162] docs: update changelog for parity fixes --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0280504..46121dd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,8 @@ A `### Breaking` section is used in addition to Keep a Changelog's standard sect - `GOTOOLCHAIN=auto` moved from per-step env to job-level env in push and PR validation workflows. - Push validation `vociferate/coverage-badge` bumped from `v1.0.1` to `v1.1.0` for version consistency with PR validation. - `vociferate/prepare` and `vociferate/publish` in `prepare-release.yml` and `tag-build-artifacts.yml` bumped from `v1.0.1` to `v1.1.0` for cross-workflow version consistency. +- `golang/govulncheck-action` in push and PR validation now passes explicit `go-package`, cache enablement, and `cache-dependency-path` inputs to match the required workflow pattern. +- CLI/core wiring now injects `stdin` through `core.NewApp`, `main` owns the `GIT_TERMINAL_PROMPT=0` side effect, and `Rc` force handling is passed per call instead of mutating shared app state. - README badge link target updated to `actions/runs/latest?workflow=...` format per workflow standards. - CI security scanning now uses GitHub Marketplace actions (`securego/gosec` and `golang/govulncheck-action`) instead of manual tool installation, improving reliability and caching. - CI setup compatibility fix: gosec scanner now references the correct public action source (`securego/gosec`), resolving action clone failures in Gitea runners. -- 2.49.1 From c793925828df0d54bb83c96b13357da55209e697 Mon Sep 17 00:00:00 2001 From: Micheal Wilkinson Date: Sat, 21 Mar 2026 20:52:13 +0000 Subject: [PATCH 142/162] chore(go): wrap core filesystem errors with context --- internal/homesick/core/core.go | 116 ++++++++++++------------ internal/homesick/core/generate_test.go | 9 ++ internal/homesick/core/helpers_test.go | 34 +++++++ internal/homesick/core/list_test.go | 10 ++ internal/homesick/core/rc_test.go | 10 ++ internal/homesick/core/track_test.go | 12 +++ 6 files changed, 135 insertions(+), 56 deletions(-) diff --git a/internal/homesick/core/core.go b/internal/homesick/core/core.go index aef3dfc..7ad2183 100644 --- a/internal/homesick/core/core.go +++ b/internal/homesick/core/core.go @@ -99,7 +99,7 @@ func (a *App) Clone(uri string, destination string) error { func (a *App) List() error { if err := os.MkdirAll(a.ReposDir, 0o750); err != nil { - return err + return fmt.Errorf("ensure repos directory: %w", err) } var castles []string @@ -114,13 +114,13 @@ func (a *App) List() error { castleRoot := filepath.Dir(path) rel, err := filepath.Rel(a.ReposDir, castleRoot) if err != nil { - return err + return fmt.Errorf("resolve castle path %q: %w", castleRoot, err) } castles = append(castles, rel) return filepath.SkipDir }) if err != nil { - return err + return fmt.Errorf("scan repos directory: %w", err) } sort.Strings(castles) @@ -132,7 +132,7 @@ func (a *App) List() error { } _, writeErr := fmt.Fprintf(a.Stdout, "%s %s\n", castle, strings.TrimSpace(remote)) if writeErr != nil { - return writeErr + return fmt.Errorf("write castle listing: %w", writeErr) } } @@ -234,13 +234,13 @@ func (a *App) Destroy(castle string) error { if errors.Is(err, os.ErrNotExist) { return fmt.Errorf("castle %q not found", castle) } - return err + return fmt.Errorf("stat castle %q: %w", castle, err) } if !a.Force { confirmed, confirmErr := a.confirmDestroy(castle) if confirmErr != nil { - return confirmErr + return fmt.Errorf("confirm destroy for %q: %w", castle, confirmErr) } if !confirmed { return nil @@ -252,7 +252,7 @@ func (a *App) Destroy(castle string) error { castleHome := filepath.Join(castleRoot, "home") if info, statErr := os.Stat(castleHome); statErr == nil && info.IsDir() { if unlinkErr := a.UnlinkCastle(castle); unlinkErr != nil { - return unlinkErr + return fmt.Errorf("unlink castle %q before destroy: %w", castle, unlinkErr) } } } @@ -267,12 +267,12 @@ func (a *App) confirmDestroy(castle string) (bool, error) { } if _, err := fmt.Fprintf(a.Stdout, "Destroy castle %q? [y/N]: ", castle); err != nil { - return false, err + return false, fmt.Errorf("write destroy prompt: %w", err) } line, err := bufio.NewReader(reader).ReadString('\n') if err != nil && !errors.Is(err, io.EOF) { - return false, err + return false, fmt.Errorf("read destroy confirmation: %w", err) } return isAffirmativeResponse(line), nil @@ -392,15 +392,15 @@ func (a *App) Generate(castlePath string) error { absCastle, err := filepath.Abs(trimmed) if err != nil { - return err + return fmt.Errorf("resolve castle path %q: %w", trimmed, err) } if err := os.MkdirAll(absCastle, 0o750); err != nil { - return err + return fmt.Errorf("create castle path %q: %w", absCastle, err) } if err := a.runGit(absCastle, "init"); err != nil { - return err + return fmt.Errorf("initialize git repository %q: %w", absCastle, err) } githubUser := "" @@ -412,11 +412,15 @@ func (a *App) Generate(castlePath string) error { repoName := filepath.Base(absCastle) url := fmt.Sprintf("git@github.com:%s/%s.git", githubUser, repoName) if err := a.runGit(absCastle, "remote", "add", "origin", url); err != nil { - return err + return fmt.Errorf("add origin remote for %q: %w", absCastle, err) } } - return os.MkdirAll(filepath.Join(absCastle, "home"), 0o750) + if err := os.MkdirAll(filepath.Join(absCastle, "home"), 0o750); err != nil { + return fmt.Errorf("create home directory for %q: %w", absCastle, err) + } + + return nil } func (a *App) Link(castle string) error { @@ -435,11 +439,11 @@ func (a *App) LinkCastle(castle string) error { subdirs, err := readSubdirs(filepath.Join(a.ReposDir, castle, ".homesick_subdir")) if err != nil { - return err + return fmt.Errorf("read subdirs for castle %q: %w", castle, err) } if err := a.linkEach(castleHome, castleHome, subdirs); err != nil { - return err + return fmt.Errorf("link castle %q: %w", castle, err) } for _, subdir := range subdirs { @@ -448,11 +452,11 @@ func (a *App) LinkCastle(castle string) error { if errors.Is(err, os.ErrNotExist) { continue } - return err + return fmt.Errorf("stat subdir %q for castle %q: %w", base, castle, err) } if err := a.linkEach(castleHome, base, subdirs); err != nil { - return err + return fmt.Errorf("link subdir %q for castle %q: %w", subdir, castle, err) } } @@ -475,11 +479,11 @@ func (a *App) UnlinkCastle(castle string) error { subdirs, err := readSubdirs(filepath.Join(a.ReposDir, castle, ".homesick_subdir")) if err != nil { - return err + return fmt.Errorf("read subdirs for castle %q: %w", castle, err) } if err := a.unlinkEach(castleHome, castleHome, subdirs); err != nil { - return err + return fmt.Errorf("unlink castle %q: %w", castle, err) } for _, subdir := range subdirs { @@ -488,11 +492,11 @@ func (a *App) UnlinkCastle(castle string) error { if errors.Is(err, os.ErrNotExist) { continue } - return err + return fmt.Errorf("stat subdir %q for castle %q: %w", base, castle, err) } if err := a.unlinkEach(castleHome, base, subdirs); err != nil { - return err + return fmt.Errorf("unlink subdir %q for castle %q: %w", subdir, castle, err) } } @@ -522,15 +526,15 @@ func (a *App) TrackPath(filePath string, castle string) error { absolutePath, err := filepath.Abs(strings.TrimRight(trimmedFile, string(filepath.Separator))) if err != nil { - return err + return fmt.Errorf("resolve tracked file %q: %w", trimmedFile, err) } if _, err := os.Lstat(absolutePath); err != nil { - return err + return fmt.Errorf("stat tracked file %q: %w", absolutePath, err) } relativeDir, err := filepath.Rel(a.HomeDir, filepath.Dir(absolutePath)) if err != nil { - return err + return fmt.Errorf("resolve tracked file directory for %q: %w", absolutePath, err) } if relativeDir == ".." || strings.HasPrefix(relativeDir, ".."+string(filepath.Separator)) { return fmt.Errorf("track requires file under %s", a.HomeDir) @@ -541,18 +545,18 @@ func (a *App) TrackPath(filePath string, castle string) error { castleTargetDir = castleHome } if err := os.MkdirAll(castleTargetDir, 0o750); err != nil { - return err + return fmt.Errorf("create tracked file directory %q: %w", castleTargetDir, err) } trackedPath := filepath.Join(castleTargetDir, filepath.Base(absolutePath)) if _, err := os.Lstat(trackedPath); err == nil { return fmt.Errorf("%s already exists", trackedPath) } else if !errors.Is(err, os.ErrNotExist) { - return err + return fmt.Errorf("stat tracked destination %q: %w", trackedPath, err) } if err := os.Rename(absolutePath, trackedPath); err != nil { - return err + return fmt.Errorf("move tracked file into castle %q: %w", trackedPath, err) } subdirChanged := false @@ -560,21 +564,21 @@ func (a *App) TrackPath(filePath string, castle string) error { subdirPath := filepath.Join(castleRoot, ".homesick_subdir") subdirChanged, err = appendUniqueSubdir(subdirPath, relativeDir) if err != nil { - return err + return fmt.Errorf("record tracked subdir %q: %w", relativeDir, err) } } if err := a.linkPath(trackedPath, absolutePath); err != nil { - return err + return fmt.Errorf("relink tracked file %q: %w", absolutePath, err) } repo, err := git.PlainOpen(castleRoot) if err != nil { - return err + return fmt.Errorf("open git repository for castle %q: %w", castle, err) } worktree, err := repo.Worktree() if err != nil { - return err + return fmt.Errorf("open worktree for castle %q: %w", castle, err) } trackedRelativePath := filepath.Join("home", relativeDir, filepath.Base(absolutePath)) @@ -582,12 +586,12 @@ func (a *App) TrackPath(filePath string, castle string) error { trackedRelativePath = filepath.Join("home", filepath.Base(absolutePath)) } if _, err := worktree.Add(filepath.ToSlash(filepath.Clean(trackedRelativePath))); err != nil { - return err + return fmt.Errorf("stage tracked file %q: %w", trackedRelativePath, err) } if subdirChanged { if _, err := worktree.Add(".homesick_subdir"); err != nil { - return err + return fmt.Errorf("stage subdir metadata: %w", err) } } @@ -597,7 +601,7 @@ func (a *App) TrackPath(filePath string, castle string) error { func appendUniqueSubdir(path string, subdir string) (bool, error) { existing, err := readSubdirs(path) if err != nil { - return false, err + return false, fmt.Errorf("load subdir metadata %q: %w", path, err) } cleanSubdir := filepath.Clean(subdir) @@ -609,12 +613,12 @@ func appendUniqueSubdir(path string, subdir string) (bool, error) { file, err := os.OpenFile(path, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o600) // #nosec G304 — internal metadata file if err != nil { - return false, err + return false, fmt.Errorf("open subdir metadata %q: %w", path, err) } defer file.Close() if _, err := file.WriteString(cleanSubdir + "\n"); err != nil { - return false, err + return false, fmt.Errorf("write subdir metadata %q: %w", path, err) } return true, nil @@ -623,7 +627,7 @@ func appendUniqueSubdir(path string, subdir string) (bool, error) { func (a *App) linkEach(castleHome string, baseDir string, subdirs []string) error { entries, err := os.ReadDir(baseDir) if err != nil { - return err + return fmt.Errorf("read castle directory %q: %w", baseDir, err) } for _, entry := range entries { @@ -635,7 +639,7 @@ func (a *App) linkEach(castleHome string, baseDir string, subdirs []string) erro source := filepath.Join(baseDir, name) ignore, err := matchesIgnoredDir(castleHome, source, subdirs) if err != nil { - return err + return fmt.Errorf("check ignored directory %q: %w", source, err) } if ignore { continue @@ -643,7 +647,7 @@ func (a *App) linkEach(castleHome string, baseDir string, subdirs []string) erro relDir, err := filepath.Rel(castleHome, baseDir) if err != nil { - return err + return fmt.Errorf("resolve castle relative directory %q: %w", baseDir, err) } destination := filepath.Join(a.HomeDir, relDir, name) @@ -652,7 +656,7 @@ func (a *App) linkEach(castleHome string, baseDir string, subdirs []string) erro } if err := a.linkPath(source, destination); err != nil { - return err + return fmt.Errorf("link %q to %q: %w", source, destination, err) } } @@ -662,7 +666,7 @@ func (a *App) linkEach(castleHome string, baseDir string, subdirs []string) erro func (a *App) unlinkEach(castleHome string, baseDir string, subdirs []string) error { entries, err := os.ReadDir(baseDir) if err != nil { - return err + return fmt.Errorf("read castle directory %q: %w", baseDir, err) } for _, entry := range entries { @@ -674,7 +678,7 @@ func (a *App) unlinkEach(castleHome string, baseDir string, subdirs []string) er source := filepath.Join(baseDir, name) ignore, err := matchesIgnoredDir(castleHome, source, subdirs) if err != nil { - return err + return fmt.Errorf("check ignored directory %q: %w", source, err) } if ignore { continue @@ -682,7 +686,7 @@ func (a *App) unlinkEach(castleHome string, baseDir string, subdirs []string) er relDir, err := filepath.Rel(castleHome, baseDir) if err != nil { - return err + return fmt.Errorf("resolve castle relative directory %q: %w", baseDir, err) } destination := filepath.Join(a.HomeDir, relDir, name) @@ -691,7 +695,7 @@ func (a *App) unlinkEach(castleHome string, baseDir string, subdirs []string) er } if err := unlinkPath(destination); err != nil { - return err + return fmt.Errorf("unlink %q: %w", destination, err) } } @@ -717,11 +721,11 @@ func unlinkPath(destination string) error { func (a *App) linkPath(source string, destination string) error { absSource, err := filepath.Abs(source) if err != nil { - return err + return fmt.Errorf("resolve link source %q: %w", source, err) } if err := os.MkdirAll(filepath.Dir(destination), 0o750); err != nil { - return err + return fmt.Errorf("create destination parent %q: %w", filepath.Dir(destination), err) } info, err := os.Lstat(destination) @@ -738,14 +742,14 @@ func (a *App) linkPath(source string, destination string) error { } if rmErr := os.RemoveAll(destination); rmErr != nil { - return rmErr + return fmt.Errorf("remove existing destination %q: %w", destination, rmErr) } } else if !errors.Is(err, os.ErrNotExist) { - return err + return fmt.Errorf("stat destination %q: %w", destination, err) } if err := os.Symlink(absSource, destination); err != nil { - return err + return fmt.Errorf("create symlink %q -> %q: %w", destination, absSource, err) } return nil @@ -757,7 +761,7 @@ func readSubdirs(path string) ([]string, error) { if errors.Is(err, os.ErrNotExist) { return []string{}, nil } - return nil, err + return nil, fmt.Errorf("read subdirs %q: %w", path, err) } lines := strings.Split(string(data), "\n") @@ -776,7 +780,7 @@ func readSubdirs(path string) ([]string, error) { func matchesIgnoredDir(castleHome string, candidate string, subdirs []string) (bool, error) { absCandidate, err := filepath.Abs(candidate) if err != nil { - return false, err + return false, fmt.Errorf("resolve candidate path %q: %w", candidate, err) } ignoreSet := map[string]struct{}{} @@ -853,7 +857,7 @@ func (a *App) Rc(castle string, force bool) error { if errors.Is(err, os.ErrNotExist) { return fmt.Errorf("castle %q not found", castle) } - return err + return fmt.Errorf("stat castle %q: %w", castle, err) } homesickD := filepath.Join(castleRoot, ".homesick.d") @@ -890,12 +894,12 @@ func (a *App) Rc(castle string, force bool) error { if errors.Is(err, os.ErrNotExist) { return nil } - return err + return fmt.Errorf("stat rc hooks directory %q: %w", homesickD, err) } entries, err := os.ReadDir(homesickD) if err != nil { - return err + return fmt.Errorf("read rc hooks %q: %w", homesickD, err) } // ReadDir returns entries in sorted order already. @@ -905,7 +909,7 @@ func (a *App) Rc(castle string, force bool) error { } info, infoErr := entry.Info() if infoErr != nil { - return infoErr + return fmt.Errorf("read rc hook metadata %q: %w", entry.Name(), infoErr) } if info.Mode()&0o111 == 0 { // Not executable — skip. diff --git a/internal/homesick/core/generate_test.go b/internal/homesick/core/generate_test.go index 76b4ad6..344222e 100644 --- a/internal/homesick/core/generate_test.go +++ b/internal/homesick/core/generate_test.go @@ -67,3 +67,12 @@ func (s *GenerateSuite) TestGenerate_DoesNotAddOriginWhenGitHubUserMissing() { require.NoError(s.T(), err) require.NotContains(s.T(), string(content), "[remote \"origin\"]") } + +func (s *GenerateSuite) TestGenerate_WrapsCastlePathCreationError() { + blocker := filepath.Join(s.tmpDir, "blocker") + require.NoError(s.T(), os.WriteFile(blocker, []byte("x"), 0o644)) + + err := s.app.Generate(filepath.Join(blocker, "castle")) + require.Error(s.T(), err) + require.Contains(s.T(), err.Error(), "create castle path") +} diff --git a/internal/homesick/core/helpers_test.go b/internal/homesick/core/helpers_test.go index a2db849..cea2397 100644 --- a/internal/homesick/core/helpers_test.go +++ b/internal/homesick/core/helpers_test.go @@ -17,6 +17,12 @@ func (errReader) Read(_ []byte) (int, error) { return 0, errors.New("boom") } +type errWriter struct{} + +func (errWriter) Write(_ []byte) (int, error) { + return 0, errors.New("boom") +} + func TestRunGitPretendWritesStatus(t *testing.T) { stdout := &bytes.Buffer{} app := &App{Stdout: stdout, Stderr: bytes.NewBuffer(nil), Pretend: true} @@ -119,6 +125,19 @@ func TestLinkPath(t *testing.T) { require.NoError(t, statErr) require.True(t, info.Mode()&os.ModeSymlink != 0) }) + + t.Run("create destination parent error includes context", func(t *testing.T) { + dir := t.TempDir() + source := filepath.Join(dir, "source") + blocker := filepath.Join(dir, "blocker") + require.NoError(t, os.WriteFile(source, []byte("x"), 0o644)) + require.NoError(t, os.WriteFile(blocker, []byte("x"), 0o644)) + + app := &App{Stdout: bytes.NewBuffer(nil), Stderr: bytes.NewBuffer(nil)} + err := app.linkPath(source, filepath.Join(blocker, "dest")) + require.Error(t, err) + require.Contains(t, err.Error(), "create destination parent") + }) } func TestReadSubdirsAndMatchesIgnoredDir(t *testing.T) { @@ -141,6 +160,12 @@ func TestReadSubdirsAndMatchesIgnoredDir(t *testing.T) { require.False(t, notIgnored) } +func TestReadSubdirsReadErrorIncludesContext(t *testing.T) { + _, err := readSubdirs(t.TempDir()) + require.Error(t, err) + require.Contains(t, err.Error(), "read subdirs") +} + func TestPullAndPushDefaultCastlePretend(t *testing.T) { dir := t.TempDir() stdout := &bytes.Buffer{} @@ -230,6 +255,15 @@ func TestConfirmDestroyReadError(t *testing.T) { ok, err := app.confirmDestroy("dotfiles") require.Error(t, err) require.False(t, ok) + require.Contains(t, err.Error(), "read destroy confirmation") +} + +func TestConfirmDestroyWriteError(t *testing.T) { + app := &App{Stdout: errWriter{}, Stdin: strings.NewReader("yes\n")} + ok, err := app.confirmDestroy("dotfiles") + require.Error(t, err) + require.False(t, ok) + require.Contains(t, err.Error(), "write destroy prompt") } func TestExecAllWrapsCastleError(t *testing.T) { diff --git a/internal/homesick/core/list_test.go b/internal/homesick/core/list_test.go index b7ac242..69cf225 100644 --- a/internal/homesick/core/list_test.go +++ b/internal/homesick/core/list_test.go @@ -70,3 +70,13 @@ func (s *ListSuite) TestList_OutputsSortedCastlesWithRemoteURLs() { s.stdout.String(), ) } + +func (s *ListSuite) TestList_WrapsReposDirCreationError() { + blocker := filepath.Join(s.tmpDir, "repos-blocker") + require.NoError(s.T(), os.WriteFile(blocker, []byte("x"), 0o644)) + s.app.ReposDir = filepath.Join(blocker, "repos") + + err := s.app.List() + require.Error(s.T(), err) + require.Contains(s.T(), err.Error(), "ensure repos directory") +} diff --git a/internal/homesick/core/rc_test.go b/internal/homesick/core/rc_test.go index ba54ed8..b10a877 100644 --- a/internal/homesick/core/rc_test.go +++ b/internal/homesick/core/rc_test.go @@ -230,3 +230,13 @@ func (s *RcSuite) TestRc_ScriptsRunWithCwdSetToCastleRoot() { require.NoError(s.T(), s.app.Rc("dotfiles", false)) require.Contains(s.T(), s.stdout.String(), castleRoot) } + +func (s *RcSuite) TestRc_ReadHooksErrorIncludesContext() { + castleRoot := s.createCastle("dotfiles") + homesickD := filepath.Join(castleRoot, ".homesick.d") + require.NoError(s.T(), os.WriteFile(homesickD, []byte("x"), 0o644)) + + err := s.app.Rc("dotfiles", false) + require.Error(s.T(), err) + require.Contains(s.T(), err.Error(), "read rc hooks") +} diff --git a/internal/homesick/core/track_test.go b/internal/homesick/core/track_test.go index ccf68c1..4ee914f 100644 --- a/internal/homesick/core/track_test.go +++ b/internal/homesick/core/track_test.go @@ -99,3 +99,15 @@ func (s *TrackSuite) TestTrack_DefaultCastleName() { require.NoError(s.T(), err) require.Equal(s.T(), expectedTarget, linkTarget) } + +func (s *TrackSuite) TestTrack_WrapsSubdirRecordingError() { + castleRoot := s.createCastleRepo("dotfiles") + require.NoError(s.T(), os.MkdirAll(filepath.Join(castleRoot, ".homesick_subdir"), 0o755)) + + filePath := filepath.Join(s.homeDir, ".config", "myapp", "config.toml") + s.writeFile(filePath, "ok=true\n") + + err := s.app.Track(filePath, "dotfiles") + require.Error(s.T(), err) + require.Contains(s.T(), err.Error(), "record tracked subdir") +} -- 2.49.1 From 4fc9401741496ff53079b4a0ef45261fa3f407ab Mon Sep 17 00:00:00 2001 From: Micheal Wilkinson Date: Sat, 21 Mar 2026 20:52:13 +0000 Subject: [PATCH 143/162] docs: update changelog for core error wrapping --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 46121dd..d0611e3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,6 +27,7 @@ A `### Breaking` section is used in addition to Keep a Changelog's standard sect - `vociferate/prepare` and `vociferate/publish` in `prepare-release.yml` and `tag-build-artifacts.yml` bumped from `v1.0.1` to `v1.1.0` for cross-workflow version consistency. - `golang/govulncheck-action` in push and PR validation now passes explicit `go-package`, cache enablement, and `cache-dependency-path` inputs to match the required workflow pattern. - CLI/core wiring now injects `stdin` through `core.NewApp`, `main` owns the `GIT_TERMINAL_PROMPT=0` side effect, and `Rc` force handling is passed per call instead of mutating shared app state. +- Core filesystem and git error paths now wrap underlying failures with command-specific context across listing, generation, tracking, linking, rc hook execution, and destroy confirmation flows. - README badge link target updated to `actions/runs/latest?workflow=...` format per workflow standards. - CI security scanning now uses GitHub Marketplace actions (`securego/gosec` and `golang/govulncheck-action`) instead of manual tool installation, improving reliability and caching. - CI setup compatibility fix: gosec scanner now references the correct public action source (`securego/gosec`), resolving action clone failures in Gitea runners. -- 2.49.1 From 7405044fb51dcca31849cc1187ce4a806a8e2e66 Mon Sep 17 00:00:00 2001 From: Micheal Wilkinson Date: Sat, 21 Mar 2026 20:58:17 +0000 Subject: [PATCH 144/162] chore(go): annotate intentional command execution for gosec --- internal/homesick/core/core.go | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/internal/homesick/core/core.go b/internal/homesick/core/core.go index 7ad2183..581d669 100644 --- a/internal/homesick/core/core.go +++ b/internal/homesick/core/core.go @@ -299,7 +299,8 @@ func (a *App) Open(castle string) error { } castleRoot := filepath.Join(a.ReposDir, castle) - cmd := exec.Command(editor, ".") // #nosec G204 — EDITOR environment variable is user-set + // #nosec G702,G204 -- EDITOR is user-controlled local configuration and command is executed directly without a shell. + cmd := exec.Command(editor, ".") cmd.Dir = castleRoot cmd.Stdout = a.Stdout cmd.Stderr = a.Stderr @@ -801,6 +802,7 @@ func matchesIgnoredDir(castleHome string, candidate string, subdirs []string) (b } func runGitWithIO(dir string, stdout io.Writer, stderr io.Writer, args ...string) error { + // #nosec G204 -- git is fixed binary; args are internal command parameters for expected git operations. cmd := exec.Command("git", args...) cmd.Dir = dir cmd.Stdout = stdout @@ -834,6 +836,7 @@ func (a *App) sayStatus(action string, message string) { } func gitOutput(dir string, args ...string) (string, error) { + // #nosec G204 -- git is fixed binary; args are internal read-only git query parameters. cmd := exec.Command("git", args...) cmd.Dir = dir out, err := cmd.Output() -- 2.49.1 From a316723cfc53a010f0ac51d2ccc15a8b055619d4 Mon Sep 17 00:00:00 2001 From: Micheal Wilkinson Date: Sat, 21 Mar 2026 20:58:17 +0000 Subject: [PATCH 145/162] docs: update changelog for gosec scanner fix --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index d0611e3..6f0ea54 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,6 +28,7 @@ A `### Breaking` section is used in addition to Keep a Changelog's standard sect - `golang/govulncheck-action` in push and PR validation now passes explicit `go-package`, cache enablement, and `cache-dependency-path` inputs to match the required workflow pattern. - CLI/core wiring now injects `stdin` through `core.NewApp`, `main` owns the `GIT_TERMINAL_PROMPT=0` side effect, and `Rc` force handling is passed per call instead of mutating shared app state. - Core filesystem and git error paths now wrap underlying failures with command-specific context across listing, generation, tracking, linking, rc hook execution, and destroy confirmation flows. +- Gosec compliance updated for intentional command execution paths: `Open()` now documents both `G702` and `G204` suppression rationale, and fixed-`git` helper invocations include explicit `G204` justifications. - README badge link target updated to `actions/runs/latest?workflow=...` format per workflow standards. - CI security scanning now uses GitHub Marketplace actions (`securego/gosec` and `golang/govulncheck-action`) instead of manual tool installation, improving reliability and caching. - CI setup compatibility fix: gosec scanner now references the correct public action source (`securego/gosec`), resolving action clone failures in Gitea runners. -- 2.49.1 From 7fbbb442a03a48dbbd244dbcbb8c2e0d01dadcec Mon Sep 17 00:00:00 2001 From: Micheal Wilkinson Date: Sat, 21 Mar 2026 20:59:39 +0000 Subject: [PATCH 146/162] ci(pr-validation): always run badge upload and PR decoration --- .gitea/workflows/pr-validation.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.gitea/workflows/pr-validation.yml b/.gitea/workflows/pr-validation.yml index 1fc79c9..223d3a7 100644 --- a/.gitea/workflows/pr-validation.yml +++ b/.gitea/workflows/pr-validation.yml @@ -151,12 +151,14 @@ jobs: - name: Upload coverage badge id: badge + if: ${{ always() }} uses: https://git.hrafn.xyz/aether/vociferate/coverage-badge@v1.1.0 with: artefact-bucket-name: ${{ vars.ARTEFACT_BUCKET_NAME }} artefact-bucket-endpoint: ${{ vars.ARTEFACT_BUCKET_ENDPONT }} - name: Decorate PR + if: ${{ always() }} uses: https://git.hrafn.xyz/aether/vociferate/decorate-pr@v1.1.0 with: coverage-percentage: ${{ steps.badge.outputs.total }} -- 2.49.1 From 65d0a9596864f87182595e8b635ccae3f6bb39a8 Mon Sep 17 00:00:00 2001 From: Micheal Wilkinson Date: Sat, 21 Mar 2026 21:07:43 +0000 Subject: [PATCH 147/162] ci(pr-validation): guard badge upload on coverage file --- .gitea/workflows/pr-validation.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.gitea/workflows/pr-validation.yml b/.gitea/workflows/pr-validation.yml index 223d3a7..48ad237 100644 --- a/.gitea/workflows/pr-validation.yml +++ b/.gitea/workflows/pr-validation.yml @@ -151,7 +151,7 @@ jobs: - name: Upload coverage badge id: badge - if: ${{ always() }} + if: ${{ always() && hashFiles('coverage.out') != '' }} uses: https://git.hrafn.xyz/aether/vociferate/coverage-badge@v1.1.0 with: artefact-bucket-name: ${{ vars.ARTEFACT_BUCKET_NAME }} -- 2.49.1 From cf183d9bb0e92208a904bcf3a00e79abbcb568ce Mon Sep 17 00:00:00 2001 From: Micheal Wilkinson Date: Sat, 21 Mar 2026 21:07:43 +0000 Subject: [PATCH 148/162] docs: update changelog for badge upload guard --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6f0ea54..5943ca7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,6 +29,7 @@ A `### Breaking` section is used in addition to Keep a Changelog's standard sect - CLI/core wiring now injects `stdin` through `core.NewApp`, `main` owns the `GIT_TERMINAL_PROMPT=0` side effect, and `Rc` force handling is passed per call instead of mutating shared app state. - Core filesystem and git error paths now wrap underlying failures with command-specific context across listing, generation, tracking, linking, rc hook execution, and destroy confirmation flows. - Gosec compliance updated for intentional command execution paths: `Open()` now documents both `G702` and `G204` suppression rationale, and fixed-`git` helper invocations include explicit `G204` justifications. +- PR validation badge upload now runs only when `coverage.out` exists, preventing downstream badge artefact failures while still allowing PR decoration to run on failed jobs. - README badge link target updated to `actions/runs/latest?workflow=...` format per workflow standards. - CI security scanning now uses GitHub Marketplace actions (`securego/gosec` and `golang/govulncheck-action`) instead of manual tool installation, improving reliability and caching. - CI setup compatibility fix: gosec scanner now references the correct public action source (`securego/gosec`), resolving action clone failures in Gitea runners. -- 2.49.1 From 9c7f6fbdf4c7a25aad60ab16812f537d92c19b32 Mon Sep 17 00:00:00 2001 From: Micheal Wilkinson Date: Sat, 21 Mar 2026 21:12:09 +0000 Subject: [PATCH 149/162] ci(pr-validation): fallback changelog gate and fix badge condition --- .gitea/workflows/pr-validation.yml | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/.gitea/workflows/pr-validation.yml b/.gitea/workflows/pr-validation.yml index 48ad237..3347900 100644 --- a/.gitea/workflows/pr-validation.yml +++ b/.gitea/workflows/pr-validation.yml @@ -151,20 +151,33 @@ jobs: - name: Upload coverage badge id: badge - if: ${{ always() && hashFiles('coverage.out') != '' }} + if: ${{ always() && steps.coverage.outcome == 'success' }} uses: https://git.hrafn.xyz/aether/vociferate/coverage-badge@v1.1.0 with: artefact-bucket-name: ${{ vars.ARTEFACT_BUCKET_NAME }} artefact-bucket-endpoint: ${{ vars.ARTEFACT_BUCKET_ENDPONT }} + - name: Validate changelog gate + if: ${{ always() }} + run: | + set -euo pipefail + if ! awk ' + /^## \[Unreleased\]/ { in_unreleased=1; next } + /^## \[/ && in_unreleased { exit 0 } + in_unreleased && /^- / { found=1 } + END { exit found ? 0 : 1 } + ' CHANGELOG.md; then + echo "Missing changelog entry under [Unreleased]." >&2 + exit 1 + fi + - name: Decorate PR if: ${{ always() }} uses: https://git.hrafn.xyz/aether/vociferate/decorate-pr@v1.1.0 with: coverage-percentage: ${{ steps.badge.outputs.total }} badge-url: ${{ steps.badge.outputs.badge-url }} - enable-changelog-gate: 'true' - changelog-gate-mode: strict + enable-changelog-gate: 'false' - name: Add coverage summary run: | -- 2.49.1 From ae86431d50ddf8a634db0d414a47254a39d919c9 Mon Sep 17 00:00:00 2001 From: Micheal Wilkinson Date: Sat, 21 Mar 2026 21:12:10 +0000 Subject: [PATCH 150/162] docs: update changelog for PR decoration gate fallback --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5943ca7..9bb6324 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,6 +30,7 @@ A `### Breaking` section is used in addition to Keep a Changelog's standard sect - Core filesystem and git error paths now wrap underlying failures with command-specific context across listing, generation, tracking, linking, rc hook execution, and destroy confirmation flows. - Gosec compliance updated for intentional command execution paths: `Open()` now documents both `G702` and `G204` suppression rationale, and fixed-`git` helper invocations include explicit `G204` justifications. - PR validation badge upload now runs only when `coverage.out` exists, preventing downstream badge artefact failures while still allowing PR decoration to run on failed jobs. +- PR validation now keys coverage badge upload off the coverage step outcome and performs changelog gate validation in a native workflow step; decorate-pr changelog gating is disabled to bypass the broken internal extractor action. - README badge link target updated to `actions/runs/latest?workflow=...` format per workflow standards. - CI security scanning now uses GitHub Marketplace actions (`securego/gosec` and `golang/govulncheck-action`) instead of manual tool installation, improving reliability and caching. - CI setup compatibility fix: gosec scanner now references the correct public action source (`securego/gosec`), resolving action clone failures in Gitea runners. -- 2.49.1 From 354f3599b403f702d052843d54238fcf1a0f07d1 Mon Sep 17 00:00:00 2001 From: Micheal Wilkinson Date: Sat, 21 Mar 2026 21:15:19 +0000 Subject: [PATCH 151/162] ci(push-validation): trigger only on main pushes --- .gitea/workflows/push-validation.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.gitea/workflows/push-validation.yml b/.gitea/workflows/push-validation.yml index 01898dd..1c4fd17 100644 --- a/.gitea/workflows/push-validation.yml +++ b/.gitea/workflows/push-validation.yml @@ -3,7 +3,7 @@ name: Push Validation on: push: branches: - - "**" + - "main" tags-ignore: - "*" -- 2.49.1 From 74640ddaa8e866c4ee18fe1746678955cda40663 Mon Sep 17 00:00:00 2001 From: Micheal Wilkinson Date: Sat, 21 Mar 2026 21:15:19 +0000 Subject: [PATCH 152/162] docs: update changelog for duplicate-run prevention --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9bb6324..6e6e3bc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,6 +31,7 @@ A `### Breaking` section is used in addition to Keep a Changelog's standard sect - Gosec compliance updated for intentional command execution paths: `Open()` now documents both `G702` and `G204` suppression rationale, and fixed-`git` helper invocations include explicit `G204` justifications. - PR validation badge upload now runs only when `coverage.out` exists, preventing downstream badge artefact failures while still allowing PR decoration to run on failed jobs. - PR validation now keys coverage badge upload off the coverage step outcome and performs changelog gate validation in a native workflow step; decorate-pr changelog gating is disabled to bypass the broken internal extractor action. +- Push validation now triggers only on `main` pushes to avoid duplicate CI runs for branches that already execute PR validation. - README badge link target updated to `actions/runs/latest?workflow=...` format per workflow standards. - CI security scanning now uses GitHub Marketplace actions (`securego/gosec` and `golang/govulncheck-action`) instead of manual tool installation, improving reliability and caching. - CI setup compatibility fix: gosec scanner now references the correct public action source (`securego/gosec`), resolving action clone failures in Gitea runners. -- 2.49.1 From 0691c549657c0ec89f8796c530ac6fc2d75441c8 Mon Sep 17 00:00:00 2001 From: Micheal Wilkinson Date: Sat, 21 Mar 2026 21:17:56 +0000 Subject: [PATCH 153/162] ci(push-validation): trigger on all branches --- .gitea/workflows/push-validation.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.gitea/workflows/push-validation.yml b/.gitea/workflows/push-validation.yml index 1c4fd17..01898dd 100644 --- a/.gitea/workflows/push-validation.yml +++ b/.gitea/workflows/push-validation.yml @@ -3,7 +3,7 @@ name: Push Validation on: push: branches: - - "main" + - "**" tags-ignore: - "*" -- 2.49.1 From 607f43eaa0f1cf004281f577909de19590bdafb9 Mon Sep 17 00:00:00 2001 From: Micheal Wilkinson Date: Sat, 21 Mar 2026 21:18:56 +0000 Subject: [PATCH 154/162] docs: update changelog for push-validation branch trigger --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6e6e3bc..250d4c6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,7 +31,7 @@ A `### Breaking` section is used in addition to Keep a Changelog's standard sect - Gosec compliance updated for intentional command execution paths: `Open()` now documents both `G702` and `G204` suppression rationale, and fixed-`git` helper invocations include explicit `G204` justifications. - PR validation badge upload now runs only when `coverage.out` exists, preventing downstream badge artefact failures while still allowing PR decoration to run on failed jobs. - PR validation now keys coverage badge upload off the coverage step outcome and performs changelog gate validation in a native workflow step; decorate-pr changelog gating is disabled to bypass the broken internal extractor action. -- Push validation now triggers only on `main` pushes to avoid duplicate CI runs for branches that already execute PR validation. +- Push validation now triggers on all branches, not only `main`. - README badge link target updated to `actions/runs/latest?workflow=...` format per workflow standards. - CI security scanning now uses GitHub Marketplace actions (`securego/gosec` and `golang/govulncheck-action`) instead of manual tool installation, improving reliability and caching. - CI setup compatibility fix: gosec scanner now references the correct public action source (`securego/gosec`), resolving action clone failures in Gitea runners. -- 2.49.1 From 411c99532d9f1c80000793a082e6e35bbf94507c Mon Sep 17 00:00:00 2001 From: Micheal Wilkinson Date: Sat, 21 Mar 2026 21:21:33 +0000 Subject: [PATCH 155/162] ci: deduplicate runs via shared branch-name concurrency group --- .gitea/workflows/pr-validation.yml | 4 ++++ .gitea/workflows/push-validation.yml | 4 ++++ 2 files changed, 8 insertions(+) diff --git a/.gitea/workflows/pr-validation.yml b/.gitea/workflows/pr-validation.yml index 3347900..bd18788 100644 --- a/.gitea/workflows/pr-validation.yml +++ b/.gitea/workflows/pr-validation.yml @@ -7,6 +7,10 @@ on: - synchronize - reopened +concurrency: + group: ci-${{ github.head_ref }} + cancel-in-progress: true + jobs: validate: runs-on: ubuntu-latest diff --git a/.gitea/workflows/push-validation.yml b/.gitea/workflows/push-validation.yml index 01898dd..f146b08 100644 --- a/.gitea/workflows/push-validation.yml +++ b/.gitea/workflows/push-validation.yml @@ -7,6 +7,10 @@ on: tags-ignore: - "*" +concurrency: + group: ci-${{ github.ref_name }} + cancel-in-progress: true + jobs: validate: runs-on: ubuntu-latest -- 2.49.1 From e1a58b6607f38019e1637bbe4042b13df1381599 Mon Sep 17 00:00:00 2001 From: Micheal Wilkinson Date: Sat, 21 Mar 2026 22:32:35 +0000 Subject: [PATCH 156/162] docs: update changelog for concurrency deduplication --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 250d4c6..37173eb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,6 +32,7 @@ A `### Breaking` section is used in addition to Keep a Changelog's standard sect - PR validation badge upload now runs only when `coverage.out` exists, preventing downstream badge artefact failures while still allowing PR decoration to run on failed jobs. - PR validation now keys coverage badge upload off the coverage step outcome and performs changelog gate validation in a native workflow step; decorate-pr changelog gating is disabled to bypass the broken internal extractor action. - Push validation now triggers on all branches, not only `main`. +- Push and PR validation workflows now share a `concurrency` group keyed on the branch name (`github.ref_name` / `github.head_ref`) with `cancel-in-progress: true`; when a push to a PR branch fires both workflows, the second run cancels the first so only one validation executes per commit. - README badge link target updated to `actions/runs/latest?workflow=...` format per workflow standards. - CI security scanning now uses GitHub Marketplace actions (`securego/gosec` and `golang/govulncheck-action`) instead of manual tool installation, improving reliability and caching. - CI setup compatibility fix: gosec scanner now references the correct public action source (`securego/gosec`), resolving action clone failures in Gitea runners. -- 2.49.1 From 3104feb738bfbe730b6f936984226e2c849068e5 Mon Sep 17 00:00:00 2001 From: Micheal Wilkinson Date: Sat, 21 Mar 2026 22:36:23 +0000 Subject: [PATCH 157/162] ci(push-validation): skip branch pushes with open PR --- .gitea/workflows/push-validation.yml | 36 ++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/.gitea/workflows/push-validation.yml b/.gitea/workflows/push-validation.yml index f146b08..c26e8ef 100644 --- a/.gitea/workflows/push-validation.yml +++ b/.gitea/workflows/push-validation.yml @@ -12,7 +12,43 @@ concurrency: cancel-in-progress: true jobs: + check-open-pr: + runs-on: ubuntu-latest + container: docker.io/catthehacker/ubuntu:act-latest + outputs: + should_run: ${{ steps.detect.outputs.should_run }} + steps: + - name: Detect open PR for branch + id: detect + env: + REPOSITORY: ${{ github.repository }} + OWNER: ${{ github.repository_owner }} + BRANCH: ${{ github.ref_name }} + SERVER_URL: ${{ github.server_url }} + TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + set -euo pipefail + + api_url="${SERVER_URL}/api/v1/repos/${REPOSITORY}/pulls?state=open&head=${OWNER}:${BRANCH}" + auth_args=() + if [[ -n "${TOKEN:-}" ]]; then + auth_args=(-H "Authorization: token ${TOKEN}") + fi + + response="$(curl -fsSL "${auth_args[@]}" -H 'accept: application/json' "$api_url" || echo '[]')" + open_prs="$(printf '%s' "$response" | grep -o '"number":[0-9]\+' | wc -l | tr -d ' ')" + + if [[ "$open_prs" -gt 0 ]]; then + echo "should_run=false" >> "$GITHUB_OUTPUT" + echo "Open PR detected for ${OWNER}:${BRANCH}; skipping push validation." >> "$GITHUB_STEP_SUMMARY" + else + echo "should_run=true" >> "$GITHUB_OUTPUT" + echo "No open PR detected for ${OWNER}:${BRANCH}; running push validation." >> "$GITHUB_STEP_SUMMARY" + fi + validate: + needs: check-open-pr + if: ${{ needs.check-open-pr.outputs.should_run == 'true' }} runs-on: ubuntu-latest container: docker.io/catthehacker/ubuntu:act-latest defaults: -- 2.49.1 From 1f93a3d5329d8a67f122f3a091e9330cf43bac47 Mon Sep 17 00:00:00 2001 From: Micheal Wilkinson Date: Sat, 21 Mar 2026 22:36:23 +0000 Subject: [PATCH 158/162] docs: update changelog for push dedup guard --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 37173eb..7bb03cb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -33,6 +33,7 @@ A `### Breaking` section is used in addition to Keep a Changelog's standard sect - PR validation now keys coverage badge upload off the coverage step outcome and performs changelog gate validation in a native workflow step; decorate-pr changelog gating is disabled to bypass the broken internal extractor action. - Push validation now triggers on all branches, not only `main`. - Push and PR validation workflows now share a `concurrency` group keyed on the branch name (`github.ref_name` / `github.head_ref`) with `cancel-in-progress: true`; when a push to a PR branch fires both workflows, the second run cancels the first so only one validation executes per commit. +- Push validation now performs an open-PR branch check via the Gitea API and skips the heavy validation job when the branch already has an open PR, preventing duplicate full pipeline runs. - README badge link target updated to `actions/runs/latest?workflow=...` format per workflow standards. - CI security scanning now uses GitHub Marketplace actions (`securego/gosec` and `golang/govulncheck-action`) instead of manual tool installation, improving reliability and caching. - CI setup compatibility fix: gosec scanner now references the correct public action source (`securego/gosec`), resolving action clone failures in Gitea runners. -- 2.49.1 From 28820748f70a844e60e70119fd7bcb8430b1e854 Mon Sep 17 00:00:00 2001 From: Micheal Wilkinson Date: Sat, 21 Mar 2026 22:54:07 +0000 Subject: [PATCH 159/162] ci: harden workflow dedup and badge gating --- .gitea/workflows/pr-validation.yml | 16 +++++++++++++++- .gitea/workflows/push-validation.yml | 12 ++++++------ 2 files changed, 21 insertions(+), 7 deletions(-) diff --git a/.gitea/workflows/pr-validation.yml b/.gitea/workflows/pr-validation.yml index bd18788..e818122 100644 --- a/.gitea/workflows/pr-validation.yml +++ b/.gitea/workflows/pr-validation.yml @@ -153,11 +153,24 @@ jobs: cache: true cache-dependency-path: go.sum + - name: Check coverage artefacts + id: coverage-files + if: ${{ always() && steps.coverage.outcome == 'success' }} + run: | + set -euo pipefail + if [[ -f coverage.out ]]; then + echo "exists=true" >> "$GITHUB_OUTPUT" + else + echo "exists=false" >> "$GITHUB_OUTPUT" + echo "coverage.out was not produced; skipping coverage badge upload." >> "$GITHUB_STEP_SUMMARY" + fi + - name: Upload coverage badge id: badge - if: ${{ always() && steps.coverage.outcome == 'success' }} + if: ${{ always() && steps.coverage.outcome == 'success' && steps.coverage-files.outputs.exists == 'true' }} uses: https://git.hrafn.xyz/aether/vociferate/coverage-badge@v1.1.0 with: + coverage-profile: coverage.out artefact-bucket-name: ${{ vars.ARTEFACT_BUCKET_NAME }} artefact-bucket-endpoint: ${{ vars.ARTEFACT_BUCKET_ENDPONT }} @@ -178,6 +191,7 @@ jobs: - name: Decorate PR if: ${{ always() }} uses: https://git.hrafn.xyz/aether/vociferate/decorate-pr@v1.1.0 + continue-on-error: true with: coverage-percentage: ${{ steps.badge.outputs.total }} badge-url: ${{ steps.badge.outputs.badge-url }} diff --git a/.gitea/workflows/push-validation.yml b/.gitea/workflows/push-validation.yml index c26e8ef..3a55c33 100644 --- a/.gitea/workflows/push-validation.yml +++ b/.gitea/workflows/push-validation.yml @@ -27,18 +27,18 @@ jobs: SERVER_URL: ${{ github.server_url }} TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | - set -euo pipefail + set -eu api_url="${SERVER_URL}/api/v1/repos/${REPOSITORY}/pulls?state=open&head=${OWNER}:${BRANCH}" - auth_args=() - if [[ -n "${TOKEN:-}" ]]; then - auth_args=(-H "Authorization: token ${TOKEN}") + if [ -n "${TOKEN:-}" ]; then + response="$(curl -fsSL -H "Authorization: token ${TOKEN}" -H "accept: application/json" "$api_url" || echo '[]')" + else + response="$(curl -fsSL -H "accept: application/json" "$api_url" || echo '[]')" fi - response="$(curl -fsSL "${auth_args[@]}" -H 'accept: application/json' "$api_url" || echo '[]')" open_prs="$(printf '%s' "$response" | grep -o '"number":[0-9]\+' | wc -l | tr -d ' ')" - if [[ "$open_prs" -gt 0 ]]; then + if [ "$open_prs" -gt 0 ]; then echo "should_run=false" >> "$GITHUB_OUTPUT" echo "Open PR detected for ${OWNER}:${BRANCH}; skipping push validation." >> "$GITHUB_STEP_SUMMARY" else -- 2.49.1 From bbbacb0eb6f8c1f32f5f6d6b966c722fddd68b96 Mon Sep 17 00:00:00 2001 From: Micheal Wilkinson Date: Sat, 21 Mar 2026 22:54:07 +0000 Subject: [PATCH 160/162] docs: update changelog for workflow hardening --- CHANGELOG.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7bb03cb..f4e5058 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -34,6 +34,9 @@ A `### Breaking` section is used in addition to Keep a Changelog's standard sect - Push validation now triggers on all branches, not only `main`. - Push and PR validation workflows now share a `concurrency` group keyed on the branch name (`github.ref_name` / `github.head_ref`) with `cancel-in-progress: true`; when a push to a PR branch fires both workflows, the second run cancels the first so only one validation executes per commit. - Push validation now performs an open-PR branch check via the Gitea API and skips the heavy validation job when the branch already has an open PR, preventing duplicate full pipeline runs. +- Push validation open-PR detection is now POSIX-shell compatible (no bash-only `pipefail`/array/`[[ ... ]]` usage), fixing failures on runners that execute `run` scripts with `/bin/sh`. +- PR validation now checks that `coverage.out` exists before invoking `coverage-badge`; when missing, badge upload is skipped with a summary note instead of failing the workflow. +- PR decoration is now `continue-on-error` to avoid hard-failing validation when the external `decorate-pr` action's internal extractor step is unavailable. - README badge link target updated to `actions/runs/latest?workflow=...` format per workflow standards. - CI security scanning now uses GitHub Marketplace actions (`securego/gosec` and `golang/govulncheck-action`) instead of manual tool installation, improving reliability and caching. - CI setup compatibility fix: gosec scanner now references the correct public action source (`securego/gosec`), resolving action clone failures in Gitea runners. -- 2.49.1 From 2294bb940b41d67fb11f1716b7e67bc3796e7b9b Mon Sep 17 00:00:00 2001 From: Micheal Wilkinson Date: Sat, 21 Mar 2026 23:02:46 +0000 Subject: [PATCH 161/162] ci(pr-validation): harden decoration and summary fallback --- .gitea/workflows/pr-validation.yml | 38 +++++++++++++++++++++++++----- 1 file changed, 32 insertions(+), 6 deletions(-) diff --git a/.gitea/workflows/pr-validation.yml b/.gitea/workflows/pr-validation.yml index e818122..a81a1b1 100644 --- a/.gitea/workflows/pr-validation.yml +++ b/.gitea/workflows/pr-validation.yml @@ -189,7 +189,7 @@ jobs: fi - name: Decorate PR - if: ${{ always() }} + if: ${{ always() && github.server_url == 'https://github.com' && steps.badge.outcome == 'success' }} uses: https://git.hrafn.xyz/aether/vociferate/decorate-pr@v1.1.0 continue-on-error: true with: @@ -197,17 +197,43 @@ jobs: badge-url: ${{ steps.badge.outputs.badge-url }} enable-changelog-gate: 'false' - - name: Add coverage summary + - name: Skip external PR decoration on non-GitHub runners + if: ${{ always() && github.server_url != 'https://github.com' }} run: | + set -euo pipefail + echo "Skipping decorate-pr action on ${GITHUB_SERVER_URL}; external composite action is not stable on this runner." >> "$GITHUB_STEP_SUMMARY" + + - name: Add coverage summary + if: ${{ always() }} + run: | + set -euo pipefail + total="${{ steps.badge.outputs.total }}" + report_url="${{ steps.badge.outputs.report-url }}" + badge_url="${{ steps.badge.outputs.badge-url }}" + + if [[ -z "$total" ]]; then + total="n/a" + fi + if [[ -z "$report_url" ]]; then + report_url="n/a" + fi + if [[ -z "$badge_url" ]]; then + badge_url="n/a" + fi + { echo '## Coverage' echo - echo '- Total: `${{ steps.badge.outputs.total }}%`' - echo '- Report: ${{ steps.badge.outputs.report-url }}' - echo '- Badge: ${{ steps.badge.outputs.badge-url }}' + echo "- Total: ${total}%" + echo "- Report: ${report_url}" + echo "- Badge: ${badge_url}" echo echo '### Package Coverage' - cat coverage-packages.md + if [[ -f coverage-packages.md ]]; then + cat coverage-packages.md + else + echo '_Package coverage details unavailable for this run._' + fi } >> "$SUMMARY_FILE" - name: Run behavior suite -- 2.49.1 From 710fe049f5350e7a6eb1a26c54511a289b10edaf Mon Sep 17 00:00:00 2001 From: Micheal Wilkinson Date: Sat, 21 Mar 2026 23:02:46 +0000 Subject: [PATCH 162/162] docs: update changelog for pr validation fallbacks --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index f4e5058..a883f37 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -37,6 +37,8 @@ A `### Breaking` section is used in addition to Keep a Changelog's standard sect - Push validation open-PR detection is now POSIX-shell compatible (no bash-only `pipefail`/array/`[[ ... ]]` usage), fixing failures on runners that execute `run` scripts with `/bin/sh`. - PR validation now checks that `coverage.out` exists before invoking `coverage-badge`; when missing, badge upload is skipped with a summary note instead of failing the workflow. - PR decoration is now `continue-on-error` to avoid hard-failing validation when the external `decorate-pr` action's internal extractor step is unavailable. +- PR validation now skips external PR decoration on non-GitHub runners and writes a summary note instead, avoiding runner-specific action resolution failures. +- Coverage summary generation is now resilient when badge outputs or `coverage-packages.md` are unavailable, preventing summary-step hard failures after earlier skips. - README badge link target updated to `actions/runs/latest?workflow=...` format per workflow standards. - CI security scanning now uses GitHub Marketplace actions (`securego/gosec` and `golang/govulncheck-action`) instead of manual tool installation, improving reliability and caching. - CI setup compatibility fix: gosec scanner now references the correct public action source (`securego/gosec`), resolving action clone failures in Gitea runners. -- 2.49.1