diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5353716..2ba64ae 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -7,10 +7,10 @@ on: pull_request: env: - otp: "28.0" - gleam: "1.15.2" + otp: "29.x" + gleam: "1.17.0" rebar: "3" - nodelts: "22.x" + nodelts: "24.x" jobs: build: @@ -40,6 +40,8 @@ jobs: windows-erlang: runs-on: windows-latest + env: + ImageOS: win25 steps: - uses: actions/checkout@v4 - uses: erlef/setup-beam@v1 @@ -49,13 +51,12 @@ jobs: rebar3-version: ${{ env.rebar }} - run: git.exe config --global user.email "cactus-windows-erlang-test@example.com" - run: git.exe config --global user.name "cactus-windows erlang-test" - - run: gleam.exe run --target erlang - - run: gleam.exe test --target erlang - - run: git.exe checkout -b test-erlang-windows - - run: git.exe push --dry-run --set-upstream origin test-erlang-windows + - run: bash ./scripts/target_test.sh erlang windows-node: runs-on: windows-latest + env: + ImageOS: win25 steps: - uses: actions/checkout@v4 - uses: erlef/setup-beam@v1 @@ -70,16 +71,13 @@ jobs: - run: yarn install - run: git.exe config --global user.email "cactus-windows-node-test@example.com" - run: git.exe config --global user.name "cactus-windows node-test" - - run: gleam.exe run --target javascript --runtime nodejs - - run: gleam.exe test --target javascript --runtime nodejs - - run: git.exe checkout -b test-node-windows - - run: git.exe push --dry-run --set-upstream origin test-node-windows + - run: bash ./scripts/target_test.sh javascript nodejs node: runs-on: ubuntu-latest strategy: matrix: - node-version: [22.x, 24.x] + node-version: [24.x] steps: - uses: actions/checkout@v4 - uses: erlef/setup-beam@v1 diff --git a/.github/workflows/deps.yml b/.github/workflows/deps.yml index 13b2f0a..d07294a 100644 --- a/.github/workflows/deps.yml +++ b/.github/workflows/deps.yml @@ -2,6 +2,7 @@ name: Dependency Check on: schedule: + # run every friday at 9am UTC - cron: "0 9 * * 6" push: branches: @@ -9,8 +10,8 @@ on: pull_request: env: - otp: "28.0" - gleam: "1.15.2" + otp: "29.x" + gleam: "1.17.0" rebar: "3" jobs: @@ -18,13 +19,25 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 + - uses: actions/cache@v5 + with: + path: | + build + .go-over/ + key: ${{ runner.os }}-deps-check-${{ hashFiles('**/manifest.toml') }} - uses: erlef/setup-beam@v1 with: otp-version: ${{ env.otp }} gleam-version: ${{ env.gleam }} rebar3-version: ${{ env.rebar }} - run: gleam build - - run: gleam run -m go_over -- --outdated + - run: gleam run -m go_over -- --format sarif --sarif-output go-over.sarif --local + - uses: github/codeql-action/upload-sarif@v3 + with: + sarif_file: go-over.sarif + - run: gleam deps outdated + # create an issue in the repo if there are + # outdated or vulnerable dependencies - uses: jayqi/failed-build-issue-action@v1 if: failure() with: diff --git a/.tool-versions b/.tool-versions index e363f2c..7be0964 100644 --- a/.tool-versions +++ b/.tool-versions @@ -1,4 +1,4 @@ -gleam 1.15.2 -erlang 28.0.2 -nodejs 22.17.1 -deno 2.4.2 +gleam 1.17.0 +erlang 29.0.2 +nodejs 24.16.0 +deno 2.8.3 diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..f6eeb0a --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,77 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +## 1.4.1 + +### Added + +- Warning when pre-commit/pre-merge-commit cannot stash because a git stash + already exists and the working tree is still dirty + +### Changed + +- `pre-merge-commit` uses the same stash/pop behavior as `pre-commit` +- File filters respect `cwd`: only paths under the action's `cwd` are considered +- **`skip_if` removed** β€” use `skip_env` instead (e.g. `skip_env = "CI=true"`) +- `skip_env` supported at hook and action level; values may contain `=` (parsed + via first `=`) + +### Removed + +- `skip_if` β€” replaced by `skip_env` for all skip conditions + +### Fixed + +- `always_init` re-init errors are no longer swallowed during hook runs +- Invalid `always_init` type surfaces a config error instead of defaulting to + `false` +- Carriage returns stripped from git file list output (Windows compatibility) +- Hook script creation tolerates existing `.git/hooks` directory or hook file +- `--config` with Windows drive paths (`D:/...`) no longer joined against cwd + +## 1.4.0 + +### Added + +- `files_scope` at hook and action level (`staged`, `all`, `unstaged`) +- `on_failure` hook option (`stop` or `continue`) +- `skip_if = "ci"` (skips when `CI=true` or `CI=1`) +- `skip_env` per action (`NAME=value`) +- `env` inline table per action +- `cwd` per action +- CLI: `clean`, `--verbose`, `--dry-run`, `--config` +- Pre-commit stash/pop with `cactus-pre-commit` tag + +### Changed + +- Requires Gleam 1.x (`gleam_stdlib >= 1.0`) +- Hook scripts embed compile target and JS runtime β€” re-run `init` after + changing target/runtime +- `always_init` on hook run respects Windows platform for hook templates +- File-filtered actions skip when no files in scope match watched patterns +- Pre-commit does not stash when unrelated stashes already exist on the stack + +### Fixed + +- `skip_if = "ci"` no longer treats `CI=false` as a CI environment +- Pre-commit reports an error when cactus stash cannot be restored after a + successful stash +- Stash pop failures take precedence over hook action failures when both occur + +### Glob limitations + +Glob matching supports: + +- Extension suffixes (e.g. `.gleam`) +- Exact paths +- Simple globs with `*` and `**/` (e.g. `src/**/*.gleam`, `*.gleam`) + +Not supported: + +- Multiple `*` wildcards in a single path segment (e.g. `*.*.gleam`) +- Full POSIX glob semantics + +## 1.3.5 + +Previous stable release on `main`. diff --git a/README.md b/README.md index 422eca9..fcc004f 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,8 @@ -# 🌡 Cactus +# Cactus + +

+ Cactus logo +

[![Package Version](https://img.shields.io/hexpm/v/cactus)](https://hex.pm/packages/cactus) [![Hex Docs](https://img.shields.io/badge/hex-docs-ffaff3)](https://hexdocs.pm/cactus/) @@ -9,13 +13,13 @@ A tool for managing git lifecycle hooks with ✨ gleam! Pre commit, Pre push and more! -# πŸ”½ Install +# Install ```sh gleam add --dev cactus ``` -#### 🌸 Javascript +#### Javascript Bun, Deno & Nodejs are _all_ supported! @@ -25,38 +29,189 @@ Bun, Deno & Nodejs are _all_ supported! # ▢️ Usage -**_FIRST_** configure hooks and then run +**_FIRST_** configure hooks in `gleam.toml`, then initialize them: + +```sh +# Erlang target +gleam run --target erlang -m cactus + +# JavaScript target (pick one runtime) +gleam run --target javascript --runtime nodejs -m cactus +gleam run --target javascript --runtime bun -m cactus +gleam run --target javascript --runtime deno -m cactus +``` + +The `--target` and `--runtime` flags you pass here are **baked into** the +generated hook scripts under `.git/hooks/`. Use the same target/runtime your +project builds with, since hooks invoke `gleam run -m cactus -- `. + +### CLI commands + +| Command | Description | +| ---------------- | ------------------------------------------------------------ | +| `init` (default) | Initialize hooks for the current OS (`gleam` vs `gleam.exe`) | +| `unix-init` | Force Unix-style hook scripts | +| `windows-init` | Force Windows-style hook scripts (`gleam.exe`) | +| `help` | Show usage | +| `clean` | Remove cactus-generated hook scripts from `.git/hooks/` | +| `` | Run a hook's actions (e.g. `pre-commit`) | + +Pass global flags before the command: ```sh -# initialize configured hooks -# specify the target depending on how you want the hooks to run -gleam run -m cactus +gleam run -m cactus -- --verbose --dry-run init +gleam run -m cactus -- --config path/to/gleam.toml init ``` ### Config -Settings that can be added to your project's `gleam.toml` +Settings that can be added to your project's `gleam.toml`: ```toml [cactus] -# init hooks on every run (default: false) +# Re-initialize hooks on every hook run (default: false) always_init = false -# hook name (all git hooks are supported) [cactus.pre-commit] -# list of actions for the hook +# Default files_scope for all actions in this hook (default: "all") +files_scope = "staged" +# stop on first failure (default) or run all actions and fail at end +on_failure = "stop" +# Skip the entire hook when CI=true (see "skip_env" below) +skip_env = "CI=true" + actions = [ - # command: name of the command or binary to be run: required - # kind: is it a gleam subcommand, a binary or a module: ["sub_command", "binary", "module"], default: module - # args: additional args to be passed to the command, default: [] - # files: file paths & endings that you want to trigger the action, default: [] (meaning always trigger) - - # check formatting - { command = "format", kind = "sub_command", args = ["--check"], files = [".gleam", "src/f/oo.gleam"] }, - # run tests + # command: required β€” binary path, gleam module, or gleam subcommand name + # kind: "module" (default), "sub_command", or "binary" + # args: extra arguments (default: []) + # files: paths/extensions/globs that trigger the action (default: [] = always run) + # files_scope: "staged" | "all" | "unstaged" β€” overrides hook default + # cwd: working directory for the action (default: project root) + # skip_env: skip when NAME=value β€” see "skip_env" below + # env: { KEY = "value" } β€” extra environment variables + + { command = "format", kind = "sub_command", args = ["--check"], files = [".gleam"], files_scope = "staged" }, { command = "./scripts/test.sh", kind = "binary" }, - # check dependencies πŸ•΅οΈβ€β™‚οΈ - # self plug of https://github.com/bwireman/go-over - { command = "go_over", args=["--outdated"] } + { command = "go_over", kind = "module" }, +] +``` + +#### `files` filter + +An action runs when **any** watched pattern matches **any** file in the chosen +`files_scope`: + +- **Extension suffixes** β€” entries starting with `.` (e.g. `.gleam` matches + `src/foo.gleam`) +- **Exact paths** β€” e.g. `src/foo.gleam` or `./src/foo.gleam` +- **Glob patterns** β€” e.g. `src/**/*.gleam` (see limitations in + [CHANGELOG](CHANGELOG.md)) + +An empty `files` list means the action always runs. + +#### `files_scope` + +| Value | Git commands used | +| ---------- | ------------------------------------------------- | +| `staged` | `git diff --cached --name-only` | +| `unstaged` | `git diff --name-only` + untracked files | +| `all` | union of staged and unstaged (default when unset) | + +For pre-commit hooks, `files_scope = "staged"` is recommended so linters only +run when relevant staged files change. + +#### `skip_env` + +Skip a hook or individual action when an environment variable equals a specific +value. Useful when CI runs the same checks separately and you do not want hooks +to duplicate work (or fail) in the pipeline. + +**Syntax** β€” `NAME=value` (only the first `=` separates name from value, so the +value may contain `=`): + +```toml +[cactus.pre-push] +skip_env = "CI=true" # skip every action when CI=true +actions = [ + { command = "./scripts/test.sh", kind = "binary" }, + { + command = "format", + kind = "sub_command", + args = ["--check"], + skip_env = "SKIP_HOOKS=1", + }, ] ``` + +**Matching** β€” the env var must match **exactly** (case-sensitive). Unset vars +never match. + +| Example `skip_env` | Skips when… | +| ------------------ | ------------------------------- | +| `CI=true` | `CI` is set to `true` | +| `SKIP_HOOKS=1` | `SKIP_HOOKS` is set to `1` | +| `CI=1` | `CI` is set to `1` (not `true`) | + +Most CI providers set `CI=true`. Use that unless your pipeline uses a different +value. + +**Hook vs action** β€” a hook-level `skip_env` applies to every action in that +hook. An action without its own `skip_env` inherits the hook default. Set +`skip_env` on a single action to skip only that step. + +Use `--verbose` to see when hooks or actions are skipped. + +#### `cwd` + +When `cwd` is set on an action, the command runs in that directory. File +filtering (`files` / `files_scope`) only considers paths **under** that +directory (relative to the repository root). Use this for monorepo packages: + +```toml +{ command = "gleam test", kind = "binary", cwd = "packages/foo", files = [".gleam"], files_scope = "staged" } +``` + +#### Pre-commit stash behavior + +The `pre-commit` and `pre-merge-commit` hooks stash unstaged and untracked +changes before running actions, then restore them afterward. This keeps +formatters/linters from seeing dirty working-tree state. + +- Stashes are tagged with the message `cactus-pre-commit` +- Only cactus-tagged stashes are popped automatically +- If you already have unrelated stashes, cactus will not stash and prints a + warning when the working tree is still dirty β€” commit or stash manually first + +#### Supported hooks + +**Client-side** (typical local use): + +`applypatch-msg`, `commit-msg`, `post-checkout`, `post-commit`, `post-merge`, +`post-rewrite`, `pre-applypatch`, `pre-auto-gc`, `pre-commit`, +`pre-merge-commit`, `prepare-commit-msg`, `pre-push`, `pre-rebase`, `test` + +**Server-side** (remote/git server β€” rarely needed locally): + +`fsmonitor-watchman`, `post-update`, `pre-receive`, `push-to-checkout`, `update` + +### Windows + +Git hook scripts are shell scripts (`#!/bin/sh`). On Windows they require **Git +Bash** or another sh-compatible environment bundled with Git for Windows. Native +cmd/PowerShell hooks are not generated. + +Use `windows-init` or let `init` detect the platform to write `gleam.exe` in +hook scripts. + +# Troubleshooting + +| Problem | Fix | +| ----------------------------------- | -------------------------------------------------------------------------------------------------------- | +| Hooks not running | Run `gleam run -m cactus` from project root; ensure `.git/hooks/` exists and is executable | +| Wrong gleam/runtime in hook | Re-run init with correct `--target` and `--runtime`; choices are embedded in hook scripts | +| Action skipped unexpectedly | Check `files`, `files_scope`, and `skip_env`; use `--verbose` | +| Stash pop conflict after pre-commit | Run `git stash list`, resolve conflicts, `git stash drop` the `cactus-pre-commit` entry if needed | +| Not in a git repo | Initialize git first: `git init` | +| `--config` path not found | Pass absolute or relative path to a valid `gleam.toml` | +| Stash skipped warning during hook | An existing git stash blocked cactus from stashing; commit or stash manually so actions see a clean tree | +| Stash not restored after pre-commit | Check `git stash list`; cactus errors if the top stash is not `cactus-pre-commit` | diff --git a/REVIEW_REVAMP.md b/REVIEW_REVAMP.md new file mode 100644 index 0000000..8401221 --- /dev/null +++ b/REVIEW_REVAMP.md @@ -0,0 +1,348 @@ +# `revamp` Branch Review + +**Branch:** `revamp` vs `main`\ +**Version:** 1.3.5 β†’ 1.4.0\ +**Review date:** 2026-06-15\ +**Commits reviewed:** 5 (`1e9a1b1` … `d470683`) + +--- + +## Recommendation + +**Conditional ship** β€” merge to `main` after addressing the **major** +documentation/code mismatches below. No blocking test or build failures were +found locally; security review found no medium+ issues. The core architecture is +sound, but several user-facing behaviors contradict the README and should be +fixed or explicitly documented before a 1.4.0 release. + +--- + +## Phase 1 β€” Baseline verification + +| Step | Result | +| ------------------------------------------------- | ------------------------------- | +| `gleam format --check src test` | PASS | +| `gleam check` | PASS | +| `gleam build` | PASS | +| `gleam test --target erlang` | PASS (31 tests) | +| `gleam test --target javascript --runtime nodejs` | PASS (31 tests) | +| `./scripts/target_test.sh erlang` | PASS | +| `./scripts/target_test.sh javascript nodejs` | PASS | +| Dogfood: `init` β†’ hook contains `-m cactus --` | PASS | +| Dogfood: `--verbose --dry-run pre-commit` | PASS (stash + dry-run actions) | +| Dogfood: `clean` removes only cactus hooks | PASS | +| `gleam run -m go_over -- --outdated` | PASS (0 outdated packages) | +| `--help` CLI alias | PASS (parsed to `help` command) | + +--- + +## Blocking issues + +None β€” all automated checks pass. + +--- + +## Major issues + +### 1. Empty `files_scope` runs filtered actions (doc/code mismatch) + +**Location:** `src/cactus/modified.gleam:152-156`, +`test/modified_test.gleam:37-38` + +`modified_files_match` returns `True` when `modified_files` is empty, even if +`watched` (the `files` filter) is non-empty: + +```gleam +list.is_empty(modified_files) +|| list.is_empty(watched) +|| list.any(watched, ...) +``` + +**README says** (lines 101–102): _"An action runs when any watched pattern +matches any file in the chosen `files_scope`."_ + +**Actual behavior:** With `files = [".gleam"]` and `files_scope = "staged"`, if +nothing is staged, the action **still runs**. Unit tests explicitly assert +`modified_files_match([], [".foo", ".bar"])` is `true`. + +**Impact:** Pre-commit linters/formatters with file filters may run +unnecessarily when no matching files are in scope β€” opposite of documented and +recommended `files_scope = "staged"` UX. + +**Fix options:** Remove the `list.is_empty(modified_files)` short-circuit when +`watched` is non-empty, or update README to document this behavior. + +--- + +### 2. `skip_if = "ci"` treats any non-empty `CI` as skip signal + +**Location:** `src/cactus/run.gleam:252-257` + +```gleam +Some("ci") -> + case envoy.get("CI") { + Ok(value) -> value != "" + Error(_) -> False + } +``` + +**README says** (line 79): _"Skip all actions in this hook when CI=true"_ + +**Actual behavior:** `CI=false`, `CI=0`, or any other non-empty value also +skips. Only unset `CI` runs actions. + +**Impact:** Local developers with `CI=false` in shell profile may silently skip +hooks. + +**Fix:** Compare `string.lowercase(value)` to `"true"` or `"1"`. + +--- + +### 3. README claims unrelated stashes prevent stashing β€” not implemented + +**Location:** `README.md:131-132`, `src/cactus/git.gleam:46-56` + +README states: _"If you already have unrelated stashes, cactus will not stash."_ + +**Actual behavior:** `stash_unstaged_in` always runs `git stash push` when there +are local changes. No check for pre-existing stashes. Test +`stash_unstaged_in_keeps_existing_stash_test` uses a **clean working tree** +after creating a stash (returns `Ok(False)` due to no changes), not an +unrelated-stash guard. + +**Impact:** Misleading troubleshooting; users may expect protection that does +not exist. + +**Fix:** Either implement a pre-stash guard (`git stash list` check) or rewrite +README to describe actual behavior (cactus always stashes when dirty; pop only +pops cactus-tagged top stash). + +--- + +### 4. Stash restore silently no-ops when top stash is not cactus-tagged + +**Location:** `src/cactus/git.gleam:70-88` + +If `git stash list -1` does not contain `cactus-pre-commit`, +`pop_cactus_stash_in` returns `Ok("")` without error. Combined with +`run_with_stash`, a pre-commit that successfully stashed could exit 0 while +unstaged changes remain on the stash stack if the top entry is not the cactus +stash (race or edge case). + +**Impact:** Rare but high severity β€” silent loss of working-tree state after +hook. + +**Fix:** Return an error when `stashed == True` but pop finds no cactus-tagged +top stash, or pop by stash message index. + +--- + +### 5. Missing `CHANGELOG.md` + +**Location:** `README.md:108` links to `CHANGELOG.md` which does not exist on +the branch. + +**Impact:** Broken link; no migration notes for 1.4.0 breaking changes (see +below). + +**Fix:** Add `CHANGELOG.md` with 1.4.0 entry or remove the link. + +--- + +### 6. `merge_stash_pop_result` hides pop errors when hook also fails + +**Location:** `src/cactus/run.gleam:455-463` + +When both hook actions and `git stash pop` fail, only the hook `ActionFailedErr` +is returned. User may not realize unstaged changes were not restored. + +**Fix:** Combine error messages or prefer pop error when stash was taken. + +--- + +## Minor issues + +| # | Severity | Location | Finding | +| - | -------- | ------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| 1 | minor | `test/testdata/gleam/fake.toml` | Not tracked in git; test passes because missing file returns `Error` as expected. Consider adding an explicit fixture for clarity. | +| 2 | minor | `test/testdata/gleam/exec.toml` | Uses hook names `test-skip`, `test-dry`, `test-hook` which are **not** in `write.valid_hooks` β€” cannot be exercised via `gleam run -m cactus -- `. Only callable via `run.run()` in unit tests. | +| 3 | minor | `test/modified_test.gleam` | `glob_match_extension_test` asserts `src/foo.gleam` matches `*.gleam` β€” works via single-segment wildcard, but `wildcard_match` only handles one `*` per segment; patterns like `*.*.gleam` would fail silently. | +| 4 | minor | `README.md` (uncommitted) | Logo changed from centered `` to inline `![logo]` β€” decide before merge. | +| 5 | minor | `gleam run -m go_over` | `--outdated` flag deprecated in favor of `gleam deps outdated`. | +| 6 | minor | `test/util_test.gleam:19-21` | Duplicate assertion `parse_always_init("basic.toml")` twice. | + +--- + +## Nits + +- `ActionFailedErr` on `main` had no index/total; revamp adds `2/5` style + messages β€” good improvement, now tested in `util_err_test.gleam`. +- `help` no longer accepts raw `--help` in `cactus.gleam` case β€” correctly + delegated to `cli.gleam` parser. +- `package.json` version aligned to `1.4.0` (was `0.1.0` on main). + +--- + +## Breaking changes (for release notes) + +### Config schema (new in 1.4.0) + +| Field | Level | Default | Values | +| ------------- | ------------- | -------- | --------------------------------- | +| `files_scope` | hook + action | `all` | `staged`, `all`, `unstaged` | +| `on_failure` | hook | `stop` | `stop`, `continue` | +| `skip_if` | hook + action | none | `"ci"` only | +| `skip_env` | action | none | `NAME=value` | +| `env` | action | `{}` | inline string table | +| `cwd` | action | `.` | any path | +| `kind` | action | `module` | `module`, `sub_command`, `binary` | + +### CLI + +| Change | Notes | +| ----------------- | --------------------------------------------------- | +| `clean` command | Removes only hooks containing `-m cactus --` marker | +| `--verbose` | Per-action skip/run logging | +| `--dry-run` | Print actions without executing | +| `--config ` | Alternate `gleam.toml` location | + +### Hook scripts + +Generated scripts embed compile target and JS runtime. **Users must re-run +`init`** after changing `--target` or `--runtime`. + +### Dependencies + +| Package | main | revamp | +| -------------- | --------- | ----------------------------- | +| `gleam_stdlib` | `>= 0.34` | `>= 1.0` (requires Gleam 1.x) | +| `envoy` | β€” | new (CI/skip_env) | +| `go_over` | 3.x | 4.x RC | + +### Toolchain + +Gleam 1.15 β†’ 1.17, Erlang 28 β†’ 29, Node 22 β†’ 24 (`.tool-versions`, CI). + +### Bug fix vs main + +`always_init` on hook run now uses `windows_hooks()` instead of hardcoded +`False` β€” correct for Windows users. + +--- + +## Module review notes + +### `run.gleam` + +- TOML parsing is thorough with clear `InvalidFieldErr` paths. +- `files_scope` inheritance from hook β†’ action works via `hook_default` + parameter. +- `on_failure = "continue"` correctly collects first error and fails at end β€” + **untested at runtime**. +- Pre-commit `run_with_stash` flow is logically sound when stash/pop succeed. +- `do_run` uses `action.cwd` for both file scope queries and command execution β€” + correct for monorepo subdir actions. + +### `git.gleam` + +- Stash message `cactus-pre-commit` is constant and tested. +- `no_changes_to_stash` handles git's "No local changes to save" gracefully. +- Pop conflict appends helpful recovery instructions. + +### `modified.gleam` + +- `FilesScope` git commands match README table. +- Path normalization handles `./` prefix and Windows backslashes. +- Glob supports `**/` prefix and single `*` wildcards per segment. + +### `write.gleam` + +- `is_cactus_hook` marker prevents `clean` from deleting user hooks. +- Hook name whitelist blocks path traversal in hook filenames. +- Dual `@target` templates correctly embed runtime at compile time. + +### `cli.gleam` + `cactus.gleam` + +- Last positional argument wins as command β€” flags must precede command. +- `CLIErr` vs `ActionFailedErr` produce different exit messages (tested for + formatting). +- `get_package_version` reads from resolved config path. + +### `util.gleam` + +- Error types cover all failure modes; `err_as_str` tested. + +--- + +## Test coverage audit + +### Well covered + +- CLI flag parsing, config path resolution +- TOML action parsing (kinds, missing fields, invalid types) +- File pattern matching (extensions, paths, basic globs) +- Git file scopes with temp repos +- Stash push/pop (clean, unstaged, untracked) +- Hook init/clean/template generation +- Dry-run execution +- `merge_stash_pop_result` precedence +- E2E via `target_test.sh` + +### Gaps (non-blocking) + +| Area | Risk | +| ------------------------------------- | -------------------------------------------------------------------------- | +| `skip_if` / `skip_env` runtime | Fixture exists but no assertion test; invalid hook names block CLI testing | +| `on_failure = "continue"` | Parsed, never executed in tests | +| `env` on actions | `SetEnvironment` path untested | +| `cwd` on actions | Subdir git scope untested | +| Real `ActionFailedErr` from `run.run` | No integration test | +| Pre-commit E2E with stash + actions | Partial (unit stash tests only) | +| `main()` dispatch | No direct tests for `clean`, `init`, hook run | +| Glob edge cases (multiple `*`) | Not tested | + +--- + +## Documentation review + +| Item | Status | +| ---------------------------- | ------------------------------------------------------ | +| Config examples match parser | Mostly yes; `skip_if` CI semantics differ | +| `files` filter description | **Contradicts implementation** (see major #1) | +| Stash behavior section | **Partially inaccurate** (see major #3) | +| Troubleshooting table | Accurate for verbose/dry-run/config paths | +| Windows section | Accurate (Git Bash required) | +| Supported hooks list | Matches `write.valid_hooks` | +| Self-dogfooding `gleam.toml` | `files_scope = "staged"` on pre-commit β€” good practice | + +--- + +## Automated review + +### Bugbot (natural language diff) + +| Severity | Location | Finding | +| -------- | ------------------------ | --------------------------------- | +| high | `modified.gleam:152-156` | Empty scope runs filtered actions | +| high | `git.gleam:70-88` | Stash restore silently skipped | +| medium | `run.gleam:252-257` | `skip_if` treats `CI=false` as CI | +| medium | `run.gleam:455-463` | Pop error hidden on hook failure | + +### Security review (branch diff vs main) + +**No medium, high, or critical findings.** Subprocess uses argv arrays (no shell +injection). Hook names are whitelisted. `env`/`cwd` from config are intentional +project-owner capabilities within the git-hooks trust model. + +--- + +## Suggested pre-merge checklist + +1. Fix or document `modified_files_match` empty-list behavior +2. Fix `skip_if = "ci"` to match README (`CI=true` only) +3. Fix or rewrite README stash/unrelated-stash section +4. Add `CHANGELOG.md` with 1.4.0 migration notes (or remove broken link) +5. Consider error when cactus stash cannot be popped after successful stash +6. Add runtime tests for `skip_if`, `on_failure`, and `env` +7. Resolve README logo markup (centered vs inline) +8. Re-run full CI matrix on merge to `main` (Bun, Deno, Windows) diff --git a/gleam.toml b/gleam.toml index b100c2e..5277cd3 100644 --- a/gleam.toml +++ b/gleam.toml @@ -1,5 +1,5 @@ name = "cactus" -version = "1.3.5" +version = "1.4.0" licences = ["MIT"] repository = { type = "github", user = "bwireman", repo = "cactus" } description = "A tool for managing git lifecycle hooks with ✨ gleam! Pre commit, Pre push and more!" @@ -17,7 +17,6 @@ allow_all = true [go-over] cache = true global = true -outdated = true allowed_licenses = ["MIT", "Apache-2.0", "BSD 2-Clause", "WTFPL"] [go-over.ignore] @@ -27,6 +26,7 @@ packages = [] always_init = true [cactus.pre-commit] +files_scope = "staged" actions = [ { command = "./scripts/format.sh", kind = "binary", files = [ ".gleam", @@ -36,9 +36,8 @@ actions = [ [cactus.pre-push] actions = [ - { command = "go_over", kind = "module", args = [ - "--outdated", - ] }, + { command = "go_over", kind = "module" }, + { command = "deps", args = ["outdated"], kind = "sub_command" }, ] [cactus.test] @@ -54,7 +53,7 @@ actions = [ ] [dependencies] -gleam_stdlib = ">= 0.34.0 and < 2.0.0" +gleam_stdlib = ">= 1.0.0 and < 2.0.0" tom = ">= 2.0.0 and < 3.0.0" shellout = ">= 1.6.0 and < 2.0.0" simplifile = ">= 2.0.1 and < 3.0.0" @@ -62,7 +61,8 @@ filepath = ">= 1.0.0 and < 2.0.0" gleither = ">= 2.0.0 and < 3.0.0" gxyz = ">= 0.3.0 and < 1.0.0" platform = ">= 1.0.0 and < 2.0.0" +envoy = ">= 1.2.0 and < 2.0.0" [dev-dependencies] gleeunit = ">= 1.0.0 and < 2.0.0" -go_over = ">= 3.2.1 and < 4.0.0" +go_over = ">= 4.0.0-rc1 and < 5.0.0" diff --git a/images/cactus-logo.png b/images/cactus-logo.png new file mode 100644 index 0000000..30b6a17 Binary files /dev/null and b/images/cactus-logo.png differ diff --git a/images/demo.gif b/images/demo.gif index 31da862..83c9549 100644 Binary files a/images/demo.gif and b/images/demo.gif differ diff --git a/manifest.toml b/manifest.toml index 673b461..9d7f718 100644 --- a/manifest.toml +++ b/manifest.toml @@ -1,13 +1,18 @@ -# This file was generated by Gleam -# You typically do not need to edit this file +# Do not manually edit this file, it is managed by Gleam. +# +# This file locks the dependency versions used, to make your build +# deterministic and to prevent unexpected versions from being included +# in your application. +# +# You should check this file into your source control repository. packages = [ { name = "clip", version = "1.2.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "clip", source = "hex", outer_checksum = "FFDF5539D967399D22C58AADBF17423C56B5506055A7064D2A6620ED928E9ECF" }, - { name = "delay", version = "1.2.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "delay", source = "hex", outer_checksum = "7B5E8E358C075569323AE65EEB2AEDF1F93D21602A022EE104EA177E29694A28" }, + { name = "delay", version = "1.3.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "delay", source = "hex", outer_checksum = "2CD7711426C27F3694ADD6E89AB493990CBC32FFFE8FAF4AE1790D5CA7360089" }, { name = "directories", version = "1.2.0", build_tools = ["gleam"], requirements = ["envoy", "gleam_stdlib", "platform", "simplifile"], otp_app = "directories", source = "hex", outer_checksum = "D13090CFCDF6759B87217E8DDD73A75903A700148A82C1D33799F333E249BF9E" }, - { name = "envoy", version = "1.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "envoy", source = "hex", outer_checksum = "850DA9D29D2E5987735872A2B5C81035146D7FE19EFC486129E44440D03FD832" }, + { name = "envoy", version = "1.2.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "envoy", source = "hex", outer_checksum = "9C6FBB6BFA02A52798BEEC5977A738CAD6E4A057F4B67FD0C8061AD2502C191A" }, { name = "filepath", version = "1.1.2", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "filepath", source = "hex", outer_checksum = "B06A9AF0BF10E51401D64B98E4B627F1D2E48C154967DA7AF4D0914780A6D40A" }, - { name = "gleam_community_ansi", version = "1.4.4", build_tools = ["gleam"], requirements = ["gleam_community_colour", "gleam_regexp", "gleam_stdlib"], otp_app = "gleam_community_ansi", source = "hex", outer_checksum = "1B3AEA6074AB34D5F0674744F36DDC7290303A03295507E2DEC61EDD6F5777FE" }, + { name = "gleam_community_ansi", version = "1.5.0", build_tools = ["gleam"], requirements = ["gleam_community_colour", "gleam_regexp", "gleam_stdlib"], otp_app = "gleam_community_ansi", source = "hex", outer_checksum = "B5AA433AF84313E23FDF90CCFF752B9380FE9FFCE02B2949D49B7AACCC77B16D" }, { name = "gleam_community_colour", version = "2.0.4", build_tools = ["gleam"], requirements = ["gleam_json", "gleam_stdlib"], otp_app = "gleam_community_colour", source = "hex", outer_checksum = "6DB4665555D7D2B27F0EA32EF47E8BEBC4303821765F9C73D483F38EE24894F0" }, { name = "gleam_erlang", version = "1.3.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_erlang", source = "hex", outer_checksum = "1124AD3AA21143E5AF0FC5CF3D9529F6DB8CA03E43A55711B60B6B7B3874375C" }, { name = "gleam_hexpm", version = "3.0.1", build_tools = ["gleam"], requirements = ["gleam_stdlib", "gleam_time"], otp_app = "gleam_hexpm", source = "hex", outer_checksum = "AAA7813FFD1F32B12C9C0BA5C0BA451324DAC16B7D76E0540EFA526B5208CDAB" }, @@ -15,30 +20,32 @@ packages = [ { name = "gleam_httpc", version = "5.0.0", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_http", "gleam_stdlib"], otp_app = "gleam_httpc", source = "hex", outer_checksum = "C545172618D07811494E97AAA4A0FB34DA6F6D0061FDC8041C2F8E3BE2B2E48F" }, { name = "gleam_json", version = "3.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_json", source = "hex", outer_checksum = "44FDAA8847BE8FC48CA7A1C089706BD54BADCC4C45B237A992EDDF9F2CDB2836" }, { name = "gleam_regexp", version = "1.1.1", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_regexp", source = "hex", outer_checksum = "9C215C6CA84A5B35BB934A9B61A9A306EC743153BE2B0425A0D032E477B062A9" }, - { name = "gleam_stdlib", version = "0.71.0", build_tools = ["gleam"], requirements = [], otp_app = "gleam_stdlib", source = "hex", outer_checksum = "702F3BC2A14793906880B1078B19A6165F87323AEE8D0C4A34085846336FCAAE" }, + { name = "gleam_stdlib", version = "1.0.3", build_tools = ["gleam"], requirements = [], otp_app = "gleam_stdlib", source = "hex", outer_checksum = "1F543AFBA5D33DA493E6087F4E4C4F20D899411343512686C98A8ABB2963CF22" }, { name = "gleam_time", version = "1.8.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_time", source = "hex", outer_checksum = "533D8723774D61AD4998324F5DD1DABDCDBFABAFB9E87CB5D03C6955448FC97D" }, { name = "gleamsver", version = "1.0.1", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleamsver", source = "hex", outer_checksum = "EA74FDC66BF15CB2CF4F8FF9B6FA01D511712EE2B1F4BE0371076ED3F685EEAE" }, - { name = "glearray", version = "2.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "glearray", source = "hex", outer_checksum = "5E272F7CB278CC05A929C58DEB58F5D5AC6DB5B879A681E71138658D0061C38A" }, + { name = "glearray", version = "2.1.2", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "glearray", source = "hex", outer_checksum = "1554E48DD40114D7602F5BFF4D7278B6B3B735F137C7FDEEADFB2FE7951C94BE" }, { name = "gleave", version = "1.0.1", build_tools = ["gleam"], requirements = [], otp_app = "gleave", source = "hex", outer_checksum = "E34C23F8AD68A3DB19F19EE044193137B0245CB5CEFF83B3B310BD584BF74A07" }, - { name = "gleeunit", version = "1.9.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleeunit", source = "hex", outer_checksum = "DA9553CE58B67924B3C631F96FE3370C49EB6D6DC6B384EC4862CC4AAA718F3C" }, + { name = "gleeunit", version = "1.11.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleeunit", source = "hex", outer_checksum = "EC31ABA74256AEA531EDF8169931D775BBB384FED0A8A1BDC4DD9354E3E21826" }, { name = "gleither", version = "2.2.1", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleither", source = "hex", outer_checksum = "4CF283FE767D204A58557F5A5EA3EB247AAEF802B4E57297429F7543D368875C" }, - { name = "go_over", version = "3.2.2", build_tools = ["gleam"], requirements = ["clip", "delay", "directories", "filepath", "gleam_hexpm", "gleam_http", "gleam_httpc", "gleam_json", "gleam_stdlib", "gleam_time", "gleamsver", "gxyz", "shellout", "simplifile", "spinner", "tom", "yamerl"], otp_app = "go_over", source = "hex", outer_checksum = "FA7BE6172B9C79A9423AABD1C10D24DE0BA3CD716E63DB627822A8E1D58C28BE" }, + { name = "global_value", version = "1.0.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "global_value", source = "hex", outer_checksum = "23F74C91A7B819C43ABCCBF49DAD5BB8799D81F2A3736BA9A534BD47F309FF4F" }, + { name = "go_over", version = "4.0.0-rc1", build_tools = ["gleam"], requirements = ["clip", "delay", "directories", "filepath", "gleam_hexpm", "gleam_http", "gleam_httpc", "gleam_json", "gleam_stdlib", "gleam_time", "gleamsver", "global_value", "gxyz", "shellout", "simplifile", "spinner", "tom", "yamerl"], otp_app = "go_over", source = "hex", outer_checksum = "A1591330CA03B873ECBAC127C6CC17AF5E874D48B06D2CBC4AE82B00E8FC8BE7" }, { name = "gxyz", version = "0.6.1", build_tools = ["gleam"], requirements = ["gleam_stdlib", "gleave"], otp_app = "gxyz", source = "hex", outer_checksum = "44FD007EF457D51B9BB951C1FA447EE202A5A0D18F1C958E26CF8FE367AACA9E" }, { name = "platform", version = "1.0.0", build_tools = ["gleam"], requirements = [], otp_app = "platform", source = "hex", outer_checksum = "8339420A95AD89AAC0F82F4C3DB8DD401041742D6C3F46132A8739F6AEB75391" }, { name = "repeatedly", version = "2.1.2", build_tools = ["gleam"], requirements = [], otp_app = "repeatedly", source = "hex", outer_checksum = "93AE1938DDE0DC0F7034F32C1BF0D4E89ACEBA82198A1FE21F604E849DA5F589" }, { name = "shellout", version = "1.8.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "shellout", source = "hex", outer_checksum = "C416356D45151F298108C9DB9CD1EDE0313F620B5EDBB5766CD7237659D87841" }, { name = "simplifile", version = "2.4.0", build_tools = ["gleam"], requirements = ["filepath", "gleam_stdlib"], otp_app = "simplifile", source = "hex", outer_checksum = "7C18AFA4FED0B4CE1FA5B0B4BAC1FA1744427054EA993565F6F3F82E5453170D" }, { name = "spinner", version = "1.3.1", build_tools = ["gleam"], requirements = ["gleam_community_ansi", "gleam_stdlib", "glearray", "repeatedly"], otp_app = "spinner", source = "hex", outer_checksum = "21BDE7FF9D7D9ACBB4086C0D5C86F0A90CE6B0F3CB593B41D03384AE7724B5B4" }, - { name = "tom", version = "2.0.2", build_tools = ["gleam"], requirements = ["gleam_stdlib", "gleam_time"], otp_app = "tom", source = "hex", outer_checksum = "234A842F3D087D35737483F5DFB6DE9839E3366EF0CAF8726D2D094210227670" }, + { name = "tom", version = "2.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib", "gleam_time"], otp_app = "tom", source = "hex", outer_checksum = "DCF04CB7AB35D58CFC598C66EA2E1816D160759802C89B2BA6238780D59BC256" }, { name = "yamerl", version = "0.10.0", build_tools = ["rebar3"], requirements = [], otp_app = "yamerl", source = "hex", outer_checksum = "346ADB2963F1051DC837A2364E4ACF6EB7D80097C0F53CBDC3046EC8EC4B4E6E" }, ] [requirements] +envoy = { version = ">= 1.2.0 and < 2.0.0" } filepath = { version = ">= 1.0.0 and < 2.0.0" } -gleam_stdlib = { version = ">= 0.34.0 and < 2.0.0" } +gleam_stdlib = { version = ">= 1.0.0 and < 2.0.0" } gleeunit = { version = ">= 1.0.0 and < 2.0.0" } gleither = { version = ">= 2.0.0 and < 3.0.0" } -go_over = { version = ">= 3.2.1 and < 4.0.0" } +go_over = { version = ">= 4.0.0-rc1 and < 5.0.0" } gxyz = { version = ">= 0.3.0 and < 1.0.0" } platform = { version = ">= 1.0.0 and < 2.0.0" } shellout = { version = ">= 1.6.0 and < 2.0.0" } diff --git a/package.json b/package.json index 7b9f4cf..c7954d5 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "cactus", - "version": "0.1.0", + "version": "1.4.0", "main": "index.js", "repository": "git@github.com:bwireman/cactus.git", "author": "bwireman ", diff --git a/scripts/publish.sh b/scripts/publish.sh index ca07f5b..e5e8711 100755 --- a/scripts/publish.sh +++ b/scripts/publish.sh @@ -21,7 +21,7 @@ gleam format ./scripts/test.sh if [ -n "$(git status --porcelain)" ]; then - echo "Working dir mush be clean" + echo "Working dir must be clean" exit 1 fi diff --git a/scripts/update.sh b/scripts/update.sh index 74d98fa..b97274c 100755 --- a/scripts/update.sh +++ b/scripts/update.sh @@ -3,4 +3,5 @@ set -e cd "$(dirname "$0")/.." gleam update -gleam run -m go_over -- --outdated \ No newline at end of file +gleam run -m go_over +gleam deps outdated \ No newline at end of file diff --git a/src/cactus.gleam b/src/cactus.gleam index 2c76153..86d6430 100644 --- a/src/cactus.gleam +++ b/src/cactus.gleam @@ -1,22 +1,18 @@ +import cactus/cli import cactus/run -import cactus/util.{type CactusErr, CLIErr} +import cactus/util.{ + type CactusErr, ActionFailedErr, CLIErr, as_fs_err, err_as_str, format_info, + format_success, get_package_version, join_text, parse_always_init, + print_warning, quote, +} import cactus/write import filepath import gleam/io -import gleam/list import gleam/result -import gxyz/cli import platform import shellout import simplifile -fn get_cmd() -> String { - shellout.arguments() - |> cli.strip_js_from_argv() - |> list.last() - |> result.unwrap("") -} - const help_header = " _ | | _ _ | | | | @@ -27,8 +23,9 @@ const help_header = " _ |_| \\___\\__,_|\\___|\\__|\\__,_|___/ " -const help_body = " -version: 1.3.4 +fn help_body(version: String) -> String { + " +version: " <> version <> " -------------------------------------------- A tool for managing git lifecycle hooks with ✨ gleam! Pre commit, Pre push @@ -40,27 +37,42 @@ Usage: 2. Run `gleam run --target -m cactus` 3. Celebrate! πŸŽ‰ " +} + +fn windows_hooks() -> Bool { + platform.os() == platform.Win32 +} pub fn main() -> Result(Nil, CactusErr) { - use pwd <- result.map(util.as_fs_err(simplifile.current_directory(), ".")) - let gleam_toml = filepath.join(pwd, "gleam.toml") + use pwd <- result.map(as_fs_err(simplifile.current_directory(), ".")) + let cli_opts = cli.parse_args(shellout.arguments()) + let gleam_toml = cli.resolve_config_path(cli_opts, pwd) let hooks_dir = pwd |> filepath.join(".git") |> filepath.join("hooks") + let run_opts = cli.to_run_options(cli_opts) - let cmd = get_cmd() + let cmd = cli_opts.command let res = case cmd { - "help" | "--help" | "-h" | "-help" -> { - util.format_success(help_header) + "help" -> { + format_success(help_header) |> io.print() - util.format_info(help_body) + let version = + get_package_version(gleam_toml) + |> result.unwrap("unknown") + + format_info(help_body(version)) |> io.print() Ok(Nil) } + "clean" -> + write.clean(hooks_dir) + |> result.replace(Nil) + "windows-init" -> write.init(hooks_dir, gleam_toml, True) |> result.replace(Nil) @@ -70,17 +82,20 @@ pub fn main() -> Result(Nil, CactusErr) { |> result.replace(Nil) "" | "init" -> - write.init(hooks_dir, gleam_toml, platform.os() == platform.Win32) + write.init(hooks_dir, gleam_toml, windows_hooks()) |> result.replace(Nil) arg -> { - let _ = case util.parse_always_init(gleam_toml) { - True -> write.init(hooks_dir, gleam_toml, False) - _ -> Ok([]) - } + use _ <- result.try(case parse_always_init(gleam_toml) { + Ok(True) -> + write.init(hooks_dir, gleam_toml, windows_hooks()) + |> result.replace(Nil) + Ok(False) -> Ok(Nil) + Error(e) -> Error(e) + }) case write.is_valid_hook_name(arg) { - True -> run.run(gleam_toml, arg) + True -> run.run(gleam_toml, arg, run_opts) False -> Error(CLIErr(arg)) } |> result.replace(Nil) @@ -89,16 +104,18 @@ pub fn main() -> Result(Nil, CactusErr) { case res { Ok(_) -> Nil - Error(CLIErr(err)) -> { - util.print_warning(util.err_as_str(CLIErr(err))) + print_warning(err_as_str(CLIErr(err))) shellout.exit(1) } - Error(reason) -> { - [util.quote(cmd), "hook failed. Reason:", util.err_as_str(reason)] - |> util.join_text() - |> util.print_warning() + let message = case reason { + ActionFailedErr(_, _, _, _) -> err_as_str(reason) + _ -> + [quote(cmd), "hook failed. Reason:", err_as_str(reason)] + |> join_text() + } + print_warning(message) shellout.exit(1) } } diff --git a/src/cactus/cli.gleam b/src/cactus/cli.gleam new file mode 100644 index 0000000..434cbeb --- /dev/null +++ b/src/cactus/cli.gleam @@ -0,0 +1,84 @@ +import cactus/run +import filepath +import gleam/list +import gleam/option.{type Option, None, Some} +import gleam/result +import gleam/string +import gxyz/cli + +pub type CliOptions { + CliOptions( + verbose: Bool, + dry_run: Bool, + config_path: Option(String), + command: String, + ) +} + +pub fn default_options() -> CliOptions { + CliOptions(verbose: False, dry_run: False, config_path: None, command: "") +} + +pub fn parse_args(raw: List(String)) -> CliOptions { + let stripped = cli.strip_js_from_argv(raw) + parse_args_loop(stripped, default_options(), []) +} + +pub fn to_run_options(opts: CliOptions) -> run.RunOptions { + run.RunOptions(verbose: opts.verbose, dry_run: opts.dry_run) +} + +fn parse_args_loop( + args: List(String), + opts: CliOptions, + commands: List(String), +) -> CliOptions { + case args { + [] -> CliOptions(..opts, command: list.last(commands) |> result.unwrap("")) + ["--verbose", ..rest] -> + parse_args_loop(rest, CliOptions(..opts, verbose: True), commands) + ["--dry-run", ..rest] -> + parse_args_loop(rest, CliOptions(..opts, dry_run: True), commands) + ["--config", path, ..rest] -> + parse_args_loop( + rest, + CliOptions(..opts, config_path: Some(path)), + commands, + ) + [arg, ..] + if arg == "--help" || arg == "-h" || arg == "-help" || arg == "help" + -> CliOptions(..opts, command: "help") + [arg, ..rest] -> parse_args_loop(rest, opts, list.append(commands, [arg])) + } +} + +pub fn resolve_config_path(opts: CliOptions, pwd: String) -> String { + case opts.config_path { + Some(path) -> + case is_absolute_config_path(path) { + True -> path + False -> filepath.join(pwd, path) + } + None -> filepath.join(pwd, "gleam.toml") + } +} + +fn is_absolute_config_path(path: String) -> Bool { + case filepath.is_absolute(path) { + True -> True + False -> windows_drive_absolute(path) + } +} + +fn windows_drive_absolute(path: String) -> Bool { + case string.split_once(path, on: ":") { + Ok(#(drive, rest)) -> + string.length(drive) == 1 + && string.contains( + "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ", + drive, + ) + && { string.starts_with(rest, "/") || string.starts_with(rest, "\\") } + Error(_) -> False + } +} diff --git a/src/cactus/git.gleam b/src/cactus/git.gleam index 2b6d552..8dd7093 100644 --- a/src/cactus/git.gleam +++ b/src/cactus/git.gleam @@ -1,28 +1,131 @@ import cactus/util +import gleam/list import gleam/result import gleam/string import shellout +pub const stash_message = "cactus-pre-commit" + fn full_command(args: List(String)) -> String { string.join(["git", ..args], " ") } -fn run_command(args: List(String)) -> Result(String, util.CactusErr) { - shellout.command(run: "git", with: args, in: ".", opt: []) +pub fn run_command_in( + dir: String, + args: List(String), +) -> Result(String, util.CactusErr) { + shellout.command(run: "git", with: args, in: dir, opt: []) |> util.as_git_error(full_command(args)) } -pub fn list_files(args: List(String)) -> Result(List(String), util.CactusErr) { - shellout.command(run: "git", with: args, in: ".", opt: []) +pub fn list_files_in( + dir: String, + args: List(String), +) -> Result(List(String), util.CactusErr) { + shellout.command(run: "git", with: args, in: dir, opt: []) |> result.map(string.split(_, "\n")) + |> result.map(list.map(_, string.trim)) |> result.map(util.drop_empty) |> util.as_git_error(full_command(args)) } -pub fn stash_unstaged() -> Result(String, util.CactusErr) { - run_command(["stash", "push", "--keep-index", "--include-untracked"]) +pub fn list_files(args: List(String)) -> Result(List(String), util.CactusErr) { + list_files_in(".", args) +} + +fn stash_push_created_stash(output: String) -> Bool { + !string.contains(output, "No local changes to save") +} + +fn no_changes_to_stash(err: util.CactusErr) -> Result(Bool, util.CactusErr) { + case err { + util.GitError(command, output) -> + case stash_push_created_stash(output) { + True -> Error(util.GitError(command, output)) + False -> Ok(False) + } + + _ -> Error(err) + } +} + +fn has_stash_ref_in(dir: String) -> Bool { + case run_command_in(dir, ["rev-parse", "-q", "--verify", "refs/stash"]) { + Ok(_) -> True + Error(_) -> False + } +} + +pub fn stash_unstaged_in(dir: String) -> Result(Bool, util.CactusErr) { + case has_stash_ref_in(dir) { + True -> Ok(False) + False -> + case + run_command_in(dir, [ + "stash", "push", "--keep-index", "--include-untracked", "-m", + stash_message, + ]) + { + Ok(output) -> Ok(stash_push_created_stash(output)) + Error(err) -> no_changes_to_stash(err) + } + } +} + +pub fn stash_unstaged() -> Result(Bool, util.CactusErr) { + stash_unstaged_in(".") +} + +pub fn worktree_has_unstaged_changes_in( + dir: String, +) -> Result(Bool, util.CactusErr) { + case list_files_in(dir, ["diff", "--name-only"]) { + Ok(tracked) -> + case list_files_in(dir, ["ls-files", "--others", "--exclude-standard"]) { + Ok(untracked) -> + Ok(!list.is_empty(tracked) || !list.is_empty(untracked)) + Error(err) -> Error(err) + } + Error(err) -> Error(err) + } +} + +pub fn worktree_has_unstaged_changes() -> Result(Bool, util.CactusErr) { + worktree_has_unstaged_changes_in(".") +} + +pub fn pop_stash_required_in(dir: String) -> Result(String, util.CactusErr) { + case run_command_in(dir, ["stash", "list", "-1", "--format=%gs"]) { + Ok(message) -> + case string.contains(string.trim(message), stash_message) { + True -> + case run_command_in(dir, ["stash", "pop"]) { + Ok(output) -> Ok(output) + Error(util.GitError(command, output)) -> + Error(util.GitError( + command, + output + <> "\n\nResolve conflicts, then run `git stash list` and " + <> "`git stash drop` if the cactus-pre-commit entry remains.", + )) + Error(err) -> Error(err) + } + False -> + Error(util.GitError( + "git stash pop", + "Expected cactus-pre-commit stash at top but found: " + <> util.quote(string.trim(message)) + <> ". Run `git stash list` to recover your changes.", + )) + } + Error(_) -> + Error(util.GitError( + "git stash list", + "No cactus-pre-commit stash found to restore.", + )) + } } -pub fn pop_stash() -> Result(String, util.CactusErr) { - run_command(["stash", "pop"]) +pub fn pop_stash_required() -> Result(String, util.CactusErr) { + pop_stash_required_in(".") } diff --git a/src/cactus/modified.gleam b/src/cactus/modified.gleam index 79d688b..76383e5 100644 --- a/src/cactus/modified.gleam +++ b/src/cactus/modified.gleam @@ -1,37 +1,170 @@ import cactus/git -import cactus/util +import cactus/util.{type CactusErr, InvalidFieldErr, drop_empty, quote} import gleam/list import gleam/result.{try} import gleam/string +import gleither.{Right} -pub fn get_modified_files() -> Result(List(String), util.CactusErr) { - use modified <- try( - git.list_files(["ls-files", "--exclude-standard", "--others"]), - ) - use untracked <- try(git.list_files(["diff", "--name-only", "HEAD"])) +pub type FilesScope { + Staged + All + Unstaged +} + +pub fn default_files_scope() -> FilesScope { + All +} + +pub fn parse_files_scope(raw: String) -> Result(FilesScope, CactusErr) { + case string.lowercase(string.trim(raw)) { + "staged" -> Ok(Staged) + "all" -> Ok(All) + "unstaged" -> Ok(Unstaged) + _ -> + Error(InvalidFieldErr( + "files_scope", + Right( + "got: " + <> quote(raw) + <> " expected: one of ['staged', 'all', or 'unstaged']", + ), + )) + } +} + +pub fn files_scope_label(scope: FilesScope) -> String { + case scope { + Staged -> "staged" + All -> "all" + Unstaged -> "unstaged" + } +} + +pub fn get_files_for_scope( + scope: FilesScope, +) -> Result(List(String), CactusErr) { + get_files_for_scope_in(".", scope) +} + +pub fn get_files_for_scope_in( + dir: String, + scope: FilesScope, +) -> Result(List(String), CactusErr) { + case scope { + Staged -> git.list_files_in(dir, ["diff", "--cached", "--name-only"]) + Unstaged -> { + use tracked <- try(git.list_files_in(dir, ["diff", "--name-only"])) + use untracked <- try( + git.list_files_in(dir, ["ls-files", "--others", "--exclude-standard"]), + ) + Ok(list.unique(list.append(tracked, untracked))) + } + All -> { + use staged <- try( + git.list_files_in(dir, ["diff", "--cached", "--name-only"]), + ) + use tracked <- try(git.list_files_in(dir, ["diff", "--name-only"])) + use untracked <- try( + git.list_files_in(dir, ["ls-files", "--others", "--exclude-standard"]), + ) + Ok(list.unique(list.append(staged, list.append(tracked, untracked)))) + } + } +} + +fn normalize_path(path: String) -> String { + let path = case string.starts_with(path, "./") { + True -> string.drop_start(path, 2) + False -> path + } + path |> string.replace("\\", "/") |> string.trim +} - Ok(list.append(untracked, modified)) +pub fn filter_files_under_cwd( + files: List(String), + cwd: String, +) -> List(String) { + case normalize_path(cwd) { + "." | "" -> files + prefix -> + list.filter(files, fn(file) { + let file = normalize_path(file) + file == prefix || string.starts_with(file, prefix <> "/") + }) + } } -pub fn modified_files_match(modified_files: List(String), watched: List(String)) { - let modified_files = util.drop_empty(modified_files) - let watched = util.drop_empty(watched) +pub fn file_matches_pattern(file: String, pattern: String) -> Bool { + let file = normalize_path(file) + let pattern = normalize_path(pattern) + + case pattern { + "" -> False + _ -> + case string.contains(pattern, "*") { + True -> simple_glob_match(file, pattern) + False -> + case string.starts_with(pattern, ".") { + True -> string.ends_with(file, pattern) + False -> file == pattern + } + } + } +} - list.is_empty(modified_files) - || list.is_empty(watched) - || { - let endings = list.filter(watched, string.starts_with(_, ".")) - list.any(modified_files, matches_ending(_, endings)) +fn simple_glob_match(file: String, pattern: String) -> Bool { + case string.split_once(pattern, on: "**/") { + Ok(#(prefix, suffix)) -> + string.starts_with(file, prefix) + && wildcard_match(file_name(file), suffix) + Error(_) -> + case string.split(pattern, "/") { + [segment] -> wildcard_match(file_name(file), segment) + segments -> match_segments_loop(file, segments) + } } - || { - list.filter(watched, string.starts_with(_, "./")) - |> list.map(string.drop_start(_, 2)) - |> list.append(watched) - |> list.unique() - |> list.any(list.contains(modified_files, _)) +} + +fn file_name(path: String) -> String { + case list.last(string.split(path, "/")) { + Ok(name) -> name + Error(_) -> path } } -pub fn matches_ending(modified: String, endings: List(String)) { - list.any(endings, fn(end) { string.ends_with(modified, end) }) +fn wildcard_match(text: String, pattern: String) -> Bool { + case string.split(pattern, "*") { + [whole] -> text == whole + [prefix, suffix] -> + case prefix { + "" -> string.ends_with(text, suffix) + _ -> string.starts_with(text, prefix) && string.ends_with(text, suffix) + } + _ -> False + } +} + +fn match_segments_loop(file: String, segments: List(String)) -> Bool { + case segments { + [] -> file == "" + [segment, ..rest] -> + case string.split_once(file, "/") { + Ok(#(head, tail)) -> + wildcard_match(head, segment) && match_segments_loop(tail, rest) + Error(_) -> wildcard_match(file, segment) && rest == [] + } + } +} + +pub fn modified_files_match( + modified_files: List(String), + watched: List(String), +) { + let modified_files = drop_empty(modified_files) + let watched = drop_empty(watched) + + list.is_empty(watched) + || list.any(watched, fn(pattern) { + list.any(modified_files, fn(file) { file_matches_pattern(file, pattern) }) + }) } diff --git a/src/cactus/run.gleam b/src/cactus/run.gleam index bfe0fbc..325dcc4 100644 --- a/src/cactus/run.gleam +++ b/src/cactus/run.gleam @@ -1,22 +1,68 @@ import cactus/git -import cactus/modified +import cactus/modified.{ + type FilesScope, default_files_scope, files_scope_label, + filter_files_under_cwd, get_files_for_scope_in, modified_files_match, + parse_files_scope, +} import cactus/util.{ type CactusErr, ActionFailedErr, InvalidFieldErr, as_invalid_field_err, cactus, - join_text, parse_gleam_toml, print_progress, quote, + drop_empty, join_text, parse_gleam_toml, print_info, print_progress, + print_verbose, print_warning, quote, } +import envoy import gleam/dict.{type Dict} import gleam/io import gleam/list -import gleam/result.{try} +import gleam/option.{type Option, None, Some} +import gleam/result.{all, map, try, unwrap} import gleam/string -import gleither.{Right} -import shellout -import tom.{type Toml} +import gleither.{Left, Right} +import shellout.{SetEnvironment, command} +import tom.{type Toml, NotFound} const actions = "actions" const gleam = "gleam" +const stash_hooks = ["pre-commit", "pre-merge-commit"] + +pub type RunOptions { + RunOptions(verbose: Bool, dry_run: Bool) +} + +pub type FailMode { + Stop + Continue +} + +pub type ActionKind { + Module + SubCommand + Binary +} + +pub type Action { + Action( + command: String, + kind: ActionKind, + args: List(String), + files: List(String), + files_scope: FilesScope, + cwd: String, + env: List(#(String, String)), + skip_env: Option(String), + ) +} + +pub type HookConfig { + HookConfig( + files_scope: FilesScope, + on_failure: FailMode, + skip_env: Option(String), + actions: List(Action), + ) +} + fn do_parse_kind(kind: String) -> Result(ActionKind, CactusErr) { case kind { "module" -> Ok(Module) @@ -36,64 +82,109 @@ fn do_parse_kind(kind: String) -> Result(ActionKind, CactusErr) { } } -fn do_parse_action(t: Dict(String, Toml)) -> Result(Action, CactusErr) { +fn parse_skip_env(raw: String) -> Result(Option(String), CactusErr) { + case string.split_once(string.trim(raw), on: "=") { + Ok(#(name, value)) -> + case name, value { + "", _ -> + Error(InvalidFieldErr( + "skip_env", + Right("expected NAME=value, got: " <> quote(raw)), + )) + _, "" -> + Error(InvalidFieldErr( + "skip_env", + Right("expected NAME=value, got: " <> quote(raw)), + )) + _, _ -> Ok(Some(name <> "=" <> value)) + } + Error(_) -> + Error(InvalidFieldErr( + "skip_env", + Right("expected NAME=value, got: " <> quote(raw)), + )) + } +} + +fn parse_skip_env_field( + t: Dict(String, Toml), + key: String, +) -> Result(Option(String), CactusErr) { + case tom.get_string(t, [key]) { + Ok(raw) -> parse_skip_env(raw) + Error(NotFound(_)) -> Ok(None) + Error(err) -> Error(InvalidFieldErr(key, Left(err))) + } +} + +fn parse_files_scope_field( + t: Dict(String, Toml), + default: FilesScope, +) -> Result(FilesScope, CactusErr) { + case tom.get_string(t, ["files_scope"]) { + Ok(raw) -> parse_files_scope(raw) + Error(_) -> Ok(default) + } +} + +fn parse_env_table( + t: Dict(String, Toml), +) -> Result(List(#(String, String)), CactusErr) { + case dict.get(t, "env") { + Ok(tom.InlineTable(env)) -> + env + |> dict.to_list() + |> list.map(fn(pair) { + case pair { + #(key, tom.String(value)) -> Ok(#(key, value)) + _ -> + Error(InvalidFieldErr("env", Right("'env' values must be strings"))) + } + }) + |> all() + Ok(_) -> + Error(InvalidFieldErr("env", Right("'env' must be an inline table"))) + Error(_) -> Ok([]) + } +} + +fn do_parse_action( + t: Dict(String, Toml), + hook_default: FilesScope, +) -> Result(Action, CactusErr) { let kind = tom.get_string(t, ["kind"]) - |> result.map(string.lowercase) - |> result.unwrap("module") + |> map(string.lowercase) + |> unwrap("module") use command <- try(as_invalid_field_err(tom.get_string(t, ["command"]))) use args <- try( tom.get_array(t, ["args"]) - |> result.unwrap([]) + |> unwrap([]) |> list.map(as_string) - |> result.all(), + |> all(), ) use files <- try( tom.get_array(t, ["files"]) - |> result.unwrap([]) + |> unwrap([]) |> list.map(as_string) - |> result.all(), + |> all(), ) - use action_kind <- result.map(do_parse_kind(kind)) + use files_scope <- try(parse_files_scope_field(t, hook_default)) + use action_kind <- try(do_parse_kind(kind)) + use env <- try(parse_env_table(t)) + use skip_env <- try(parse_skip_env_field(t, "skip_env")) - Action(command: command, kind: action_kind, args: args, files: files) -} - -fn do_run(action: Action) { - use run_action <- try(case list.is_empty(util.drop_empty(action.files)) { - // if file no specific files watched we can just run - True -> Ok(True) - - // only check for modified files if it's relevant to action - _ -> { - use modified_files <- try(modified.get_modified_files()) - Ok(modified.modified_files_match(modified_files, action.files)) - } - }) - - case run_action { - True -> { - let #(bin, args) = case action.kind { - Module -> #(gleam, ["run", "-m", action.command, "--", ..action.args]) - SubCommand -> #(gleam, [action.command, ..action.args]) - Binary -> #(action.command, action.args) - } - - ["Running", quote(join_text([bin, ..args]))] - |> join_text() - |> print_progress() - case shellout.command(run: bin, with: args, in: ".", opt: []) { - Ok(res) -> { - io.print(res) - Ok(res) - } - Error(#(_, err)) -> Error(ActionFailedErr(err)) - } - } - - False -> Ok("") - } + Ok(Action( + command: command, + kind: action_kind, + args: args, + files: files, + files_scope: files_scope, + cwd: tom.get_string(t, ["cwd"]) |> unwrap("."), + env: env, + skip_env: skip_env, + )) } fn as_string(t: Toml) -> Result(String, CactusErr) { @@ -104,25 +195,61 @@ fn as_string(t: Toml) -> Result(String, CactusErr) { } } -pub type ActionKind { - Module - SubCommand - Binary +fn parse_fail_mode(raw: String) -> Result(FailMode, CactusErr) { + case string.lowercase(string.trim(raw)) { + "stop" -> Ok(Stop) + "continue" -> Ok(Continue) + _ -> + Error(InvalidFieldErr( + "on_failure", + Right("expected 'stop' or 'continue', got: " <> quote(raw)), + )) + } } -pub type Action { - Action( - command: String, - kind: ActionKind, - args: List(String), - files: List(String), +fn parse_hook_table( + action_body: Dict(String, Toml), +) -> Result(HookConfig, CactusErr) { + use hook_files_scope <- try(parse_files_scope_field( + action_body, + default_files_scope(), + )) + use on_failure <- try(case tom.get_string(action_body, ["on_failure"]) { + Ok(raw) -> parse_fail_mode(raw) + Error(_) -> Ok(Stop) + }) + use skip_env <- try(parse_skip_env_field(action_body, "skip_env")) + + use raw_actions <- try( + as_invalid_field_err(tom.get_array(action_body, [actions])), ) + + use parsed_actions <- try( + raw_actions + |> list.map(fn(raw) { + case raw { + tom.InlineTable(t) -> do_parse_action(t, hook_files_scope) + _ -> + Error(InvalidFieldErr( + actions, + Right("'actions' element was not an InlineTable"), + )) + } + }) + |> all(), + ) + + Ok(HookConfig( + files_scope: hook_files_scope, + on_failure: on_failure, + skip_env: skip_env, + actions: parsed_actions, + )) } pub fn parse_action(raw: Toml) -> Result(Action, CactusErr) { case raw { - tom.InlineTable(t) -> do_parse_action(t) - + tom.InlineTable(t) -> do_parse_action(t, default_files_scope()) _ -> Error(InvalidFieldErr( actions, @@ -131,6 +258,17 @@ pub fn parse_action(raw: Toml) -> Result(Action, CactusErr) { } } +pub fn get_hook_config( + path: String, + hook: String, +) -> Result(HookConfig, CactusErr) { + use manifest <- try(parse_gleam_toml(path)) + use action_body <- try( + as_invalid_field_err(tom.get_table(manifest, [cactus, hook])), + ) + parse_hook_table(action_body) +} + pub fn get_actions( path: String, action: String, @@ -142,26 +280,246 @@ pub fn get_actions( as_invalid_field_err(tom.get_array(action_body, [actions])) } -pub fn run(path: String, action: String) -> Result(List(String), CactusErr) { - let stash_res = case action { - "pre-commit" -> git.stash_unstaged() |> result.replace(True) +fn should_skip(skip_env: Option(String)) -> Bool { + case skip_env { + Some(raw) -> + case string.split_once(raw, on: "=") { + Ok(#(name, value)) -> + case envoy.get(name) { + Ok(found) -> found == value + Error(_) -> False + } + Error(_) -> False + } + None -> False + } +} - _ -> Ok(False) +fn action_invocation(action: Action) -> #(String, List(String)) { + case action.kind { + Module -> #(gleam, ["run", "-m", action.command, "--", ..action.args]) + SubCommand -> #(gleam, [action.command, ..action.args]) + Binary -> #(action.command, action.args) } +} - let action_res = { - use actions <- try(get_actions(path, action)) - actions - |> list.map(parse_action) - |> list.map(result.try(_, do_run)) - |> result.all() +fn action_label(action: Action) -> String { + let #(bin, args) = action_invocation(action) + join_text([bin, ..args]) +} + +fn do_run( + action: Action, + index: Int, + total: Int, + hook_skip_env: Option(String), + opts: RunOptions, +) -> Result(String, CactusErr) { + let skip_env = option.or(action.skip_env, hook_skip_env) + let label = action_label(action) + + case should_skip(skip_env) { + True -> { + print_verbose( + opts.verbose, + "Skipping " <> quote(label) <> " (skip_env matched)", + ) + Ok("") + } + False -> { + use run_action <- try(case list.is_empty(drop_empty(action.files)) { + True -> Ok(True) + False -> { + use modified_files <- try(get_files_for_scope_in( + ".", + action.files_scope, + )) + let modified_files = + filter_files_under_cwd(modified_files, action.cwd) + print_verbose( + opts.verbose, + "Checking files (" + <> files_scope_label(action.files_scope) + <> "): " + <> quote(label), + ) + Ok(modified_files_match(modified_files, action.files)) + } + }) + + case run_action { + True -> { + case opts.dry_run { + True -> { + print_info( + "[dry-run] Would run " + <> quote(label) + <> " in " + <> quote(action.cwd), + ) + Ok("") + } + False -> { + let #(bin, args) = action_invocation(action) + print_progress("Running " <> quote(label)) + case + command(run: bin, with: args, in: action.cwd, opt: [ + SetEnvironment(action.env), + ]) + { + Ok(res) -> { + io.print(res) + Ok(res) + } + Error(#(_, err)) -> + Error(ActionFailedErr( + index: index, + total: total, + command: label, + output: err, + )) + } + } + } + } + False -> { + print_verbose( + opts.verbose, + "Skipping " <> quote(label) <> " (no matching files)", + ) + Ok("") + } + } + } + } +} + +fn run_hook_actions_with_fail_mode( + config: HookConfig, + opts: RunOptions, +) -> Result(List(String), CactusErr) { + case should_skip(config.skip_env) { + True -> { + print_verbose(opts.verbose, "Skipping hook (skip_env matched)") + Ok([]) + } + False -> { + let total = list.length(config.actions) + run_hook_actions_with_fail_mode_loop( + config.actions, + config.skip_env, + config.on_failure, + opts, + total, + 0, + [], + None, + ) + } + } +} + +fn run_hook_actions_with_fail_mode_loop( + remaining: List(Action), + hook_skip_env: Option(String), + on_failure: FailMode, + opts: RunOptions, + total: Int, + index: Int, + acc: List(String), + first_err: Option(CactusErr), +) -> Result(List(String), CactusErr) { + case remaining { + [] -> + case first_err { + Some(err) -> Error(err) + None -> Ok(list.reverse(acc)) + } + [action, ..rest] -> { + let next_index = index + 1 + case do_run(action, next_index, total, hook_skip_env, opts) { + Ok(output) -> + run_hook_actions_with_fail_mode_loop( + rest, + hook_skip_env, + on_failure, + opts, + total, + next_index, + [output, ..acc], + first_err, + ) + Error(err) -> + case on_failure { + Stop -> Error(err) + Continue -> + run_hook_actions_with_fail_mode_loop( + rest, + hook_skip_env, + on_failure, + opts, + total, + next_index, + acc, + option.or(first_err, Some(err)), + ) + } + } + } } +} + +fn run_actions( + path: String, + hook: String, + opts: RunOptions, +) -> Result(List(String), CactusErr) { + use config <- try(get_hook_config(path, hook)) + run_hook_actions_with_fail_mode(config, opts) +} - case action, stash_res { - "pre-commit", Ok(True) -> - git.pop_stash() - |> result.try(fn(_) { action_res }) +fn run_with_stash( + path: String, + hook: String, + opts: RunOptions, +) -> Result(List(String), CactusErr) { + print_verbose(opts.verbose, "Stashing unstaged changes for " <> hook) + + use stashed <- try(git.stash_unstaged()) + case stashed { + False -> + case git.worktree_has_unstaged_changes() { + Ok(True) -> + print_warning( + "Skipped stashing unstaged changes: an existing git stash was found. " + <> "Commit or stash manually first so hook actions do not see dirty " + <> "working-tree state.", + ) + _ -> Nil + } + _ -> Nil + } + let action_res = run_actions(path, hook, opts) - _, _ -> action_res + case stashed { + True -> { + print_verbose(opts.verbose, "Restoring stashed changes") + case git.pop_stash_required(), action_res { + Ok(_), _ -> action_res + Error(pop_err), _ -> Error(pop_err) + } + } + False -> action_res + } +} + +pub fn run( + path: String, + hook: String, + opts: RunOptions, +) -> Result(List(String), CactusErr) { + case list.contains(stash_hooks, hook) { + True -> run_with_stash(path, hook, opts) + False -> run_actions(path, hook, opts) } } diff --git a/src/cactus/util.gleam b/src/cactus/util.gleam index 5c41574..9b38aab 100644 --- a/src/cactus/util.gleam +++ b/src/cactus/util.gleam @@ -1,6 +1,7 @@ import gleam/dict.{type Dict} +import gleam/int import gleam/io -import gleam/result.{replace_error} +import gleam/result import gleam/string import gleither.{type Either, Left, Right} import gxyz/list.{reject} @@ -13,7 +14,7 @@ pub const cactus = "cactus" pub type CactusErr { InvalidFieldErr(field: String, err: Either(GetError, String)) InvalidTomlErr - ActionFailedErr(output: String) + ActionFailedErr(index: Int, total: Int, command: String, output: String) FSErr(path: String, err: FileError) CLIErr(arg: String) GitError(command: String, err: String) @@ -21,7 +22,7 @@ pub type CactusErr { } pub fn as_err(res: Result(a, b), err: CactusErr) -> Result(a, CactusErr) { - replace_error(res, err) + result.replace_error(res, err) } pub fn as_invalid_field_err(res: Result(a, GetError)) -> Result(a, CactusErr) { @@ -73,7 +74,15 @@ pub fn err_as_str(err: CactusErr) -> String { InvalidTomlErr -> "Invalid Toml Error" - ActionFailedErr(output) -> "Action Failed Error:\n" <> output + ActionFailedErr(index, total, command, output) -> + join_text([ + "Action", + int.to_string(index) <> "/" <> int.to_string(total), + "failed:", + quote(command), + ]) + <> "\n" + <> output FSErr(path, err) -> join_text(["FS Error at", path, "with", describe_error(err)]) @@ -95,22 +104,30 @@ pub fn quote(str: String) -> String { "'" <> str <> "'" } +pub fn normalize_newlines(text: String) -> String { + string.replace(text, "\r\n", "\n") +} + pub fn parse_gleam_toml(path: String) -> Result(Dict(String, Toml), CactusErr) { use body <- result.try(as_fs_err(simplifile.read(path), path)) body - |> string.replace("\r\n", "\n") + |> normalize_newlines |> tom.parse() |> as_err(InvalidTomlErr) } -pub fn parse_always_init(path: String) { - parse_gleam_toml(path) - |> result.try(fn(t) { - t - |> tom.get_bool(["cactus", "always_init"]) - |> as_invalid_field_err - }) - |> result.unwrap(False) +pub fn parse_always_init(path: String) -> Result(Bool, CactusErr) { + use t <- result.try(parse_gleam_toml(path)) + case tom.get_bool(t, ["cactus", "always_init"]) { + Ok(value) -> Ok(value) + Error(NotFound(_)) -> Ok(False) + Error(err) -> Error(InvalidFieldErr("always_init", Left(err))) + } +} + +pub fn get_package_version(path: String) -> Result(String, CactusErr) { + use manifest <- result.try(parse_gleam_toml(path)) + tom.get_string(manifest, ["version"]) |> as_invalid_field_err } pub fn join_text(text: List(String)) -> String { @@ -144,3 +161,10 @@ pub fn print_info(msg: String) { format_info(msg <> "\n") |> io.println() } + +pub fn print_verbose(enabled: Bool, msg: String) { + case enabled { + True -> print_info(msg) + False -> Nil + } +} diff --git a/src/cactus/write.gleam b/src/cactus/write.gleam index 6313e57..8e3787b 100644 --- a/src/cactus/write.gleam +++ b/src/cactus/write.gleam @@ -7,17 +7,37 @@ import gleam/dict import gleam/list import gleam/result.{try} import gleam/set +import gleam/string @target(javascript) import platform import simplifile import tom const valid_hooks = [ - "applypatch-msg", "commit-msg", "fsmonitor-watchman", "post-update", - "pre-applypatch", "pre-commit", "pre-merge-commit", "prepare-commit-msg", + "applypatch-msg", "commit-msg", "fsmonitor-watchman", "post-checkout", + "post-commit", "post-merge", "post-rewrite", "post-update", "pre-applypatch", + "pre-auto-gc", "pre-commit", "pre-merge-commit", "prepare-commit-msg", "pre-push", "pre-rebase", "pre-receive", "push-to-checkout", "update", "test", ] +const cactus_marker = " -m cactus -- " + +fn ensure_directory(path: String) -> Result(Nil, CactusErr) { + case simplifile.create_directory(path) { + Ok(_) -> Ok(Nil) + Error(simplifile.Eexist) -> Ok(Nil) + Error(err) -> as_fs_err(Error(err), path) + } +} + +fn ensure_file(path: String) -> Result(Nil, CactusErr) { + case simplifile.create_file(path) { + Ok(_) -> Ok(Nil) + Error(simplifile.Eexist) -> Ok(Nil) + Error(err) -> as_fs_err(Error(err), path) + } +} + fn gleam_name(windows: Bool) -> String { case windows { True -> "gleam.exe" @@ -39,20 +59,25 @@ pub fn get_hook_template(windows: Bool) -> String { <> gleam_name(windows) <> " run --target javascript --runtime " <> runtime - <> " -m cactus -- " + <> cactus_marker } @target(erlang) pub fn get_hook_template(windows: Bool) -> String { "#!/bin/sh \n\n" <> gleam_name(windows) - <> " run --target erlang -m cactus -- " + <> " run --target erlang" + <> cactus_marker } pub fn is_valid_hook_name(name: String) -> Bool { list.contains(valid_hooks, name) } +pub fn is_cactus_hook(content: String) -> Bool { + string.contains(content, cactus_marker) +} + pub fn create_script( hooks_dir: String, command: String, @@ -67,8 +92,8 @@ pub fn create_script( let all = set.from_list([simplifile.Read, simplifile.Write, simplifile.Execute]) - let _ = simplifile.create_directory(hooks_dir) - let _ = simplifile.create_file(path) + use _ <- try(ensure_directory(hooks_dir)) + use _ <- try(ensure_file(path)) use _ <- try(as_fs_err( simplifile.write(path, get_hook_template(windows) <> command), path, @@ -101,3 +126,28 @@ pub fn init( } |> result.flatten() } + +pub fn clean(hooks_dir: String) -> Result(List(String), CactusErr) { + case simplifile.read_directory(hooks_dir) { + Ok(entries) -> + entries + |> list.filter(fn(name) { !list.contains([".", ".."], name) }) + |> list.map(clean_hook(hooks_dir, _)) + |> result.all() + |> result.map(fn(names) { list.filter(names, fn(name) { name != "" }) }) + Error(_) -> Ok([]) + } +} + +fn clean_hook(hooks_dir: String, name: String) -> Result(String, CactusErr) { + let path = filepath.join(hooks_dir, name) + use content <- try(as_fs_err(simplifile.read(path), path)) + case is_cactus_hook(content) { + True -> { + print_progress("Removing hook: " <> quote(name)) + use _ <- try(as_fs_err(simplifile.delete(path), path)) + Ok(name) + } + False -> Ok("") + } +} diff --git a/test/cli_test.gleam b/test/cli_test.gleam new file mode 100644 index 0000000..eaff10d --- /dev/null +++ b/test/cli_test.gleam @@ -0,0 +1,110 @@ +import cactus/cli +import cactus/run +import gleam/option +import gleeunit/should + +pub fn parse_args_test() { + cli.parse_args(["--verbose", "init"]) + |> should.equal(cli.CliOptions( + verbose: True, + dry_run: False, + config_path: option.None, + command: "init", + )) + + cli.parse_args(["--dry-run", "pre-commit"]) + |> should.equal(cli.CliOptions( + verbose: False, + dry_run: True, + config_path: option.None, + command: "pre-commit", + )) + + cli.parse_args(["--config", "other.toml", "init"]) + |> should.equal(cli.CliOptions( + verbose: False, + dry_run: False, + config_path: option.Some("other.toml"), + command: "init", + )) +} + +pub fn to_run_options_test() { + cli.to_run_options(cli.CliOptions( + verbose: True, + dry_run: True, + config_path: option.None, + command: "init", + )) + |> should.equal(run.RunOptions(verbose: True, dry_run: True)) +} + +pub fn resolve_config_path_test() { + cli.resolve_config_path( + cli.CliOptions( + verbose: False, + dry_run: False, + config_path: option.None, + command: "", + ), + "/tmp/project", + ) + |> should.equal("/tmp/project/gleam.toml") + + cli.resolve_config_path( + cli.CliOptions( + verbose: False, + dry_run: False, + config_path: option.Some("cfg/gleam.toml"), + command: "", + ), + "/tmp/project", + ) + |> should.equal("/tmp/project/cfg/gleam.toml") +} + +pub fn help_aliases_test() { + cli.parse_args(["--help"]) + |> should.equal(cli.CliOptions( + verbose: False, + dry_run: False, + config_path: option.None, + command: "help", + )) + + cli.parse_args(["-h", "init"]) + |> should.equal(cli.CliOptions( + verbose: False, + dry_run: False, + config_path: option.None, + command: "help", + )) +} + +pub fn resolve_config_absolute_path_test() { + cli.resolve_config_path( + cli.CliOptions( + verbose: False, + dry_run: False, + config_path: option.Some("/etc/gleam.toml"), + command: "", + ), + "/tmp/project", + ) + |> should.equal("/etc/gleam.toml") +} + +pub fn resolve_config_windows_drive_path_test() { + cli.resolve_config_path( + cli.CliOptions( + verbose: False, + dry_run: False, + config_path: option.Some( + "D:/a/cactus/test/testdata/gleam/exec_stash.toml", + ), + command: "", + ), + "D:\\a\\cactus\\test\\testdata\\git_work\\pre_commit_stash", + ) + |> should.equal("D:/a/cactus/test/testdata/gleam/exec_stash.toml") +} diff --git a/test/git_test.gleam b/test/git_test.gleam new file mode 100644 index 0000000..c886b00 --- /dev/null +++ b/test/git_test.gleam @@ -0,0 +1,149 @@ +import cactus/git +import cactus/util +import filepath +import gleam/list +import gleam/string +import gleeunit/should +import shellout +import simplifile +import support/git_repo + +pub fn stash_unstaged_in_clean_repo_test() { + git_repo.with_temp_repo("clean_repo", fn(dir) { + git_repo.init_repo(dir) + git_repo.commit_file(dir, "file.txt", "tracked\n") + + git.stash_unstaged_in(dir) + |> should.equal(Ok(False)) + }) +} + +pub fn stash_unstaged_in_keeps_existing_stash_test() { + git_repo.with_temp_repo("existing_stash", fn(dir) { + git_repo.init_repo(dir) + git_repo.commit_file(dir, "file.txt", "tracked\n") + + let path = filepath.join(dir, "file.txt") + let assert Ok(_) = simplifile.write(path, "tracked\nexisting stash\n") + let assert Ok(_) = + shellout.command( + run: "git", + with: ["stash", "push", "-q"], + in: dir, + opt: [], + ) + let assert Ok(_) = + shellout.command( + run: "git", + with: ["restore", "file.txt"], + in: dir, + opt: [], + ) + + let assert Ok(_) = simplifile.write(path, "tracked\nunstaged\n") + + git.stash_unstaged_in(dir) + |> should.equal(Ok(False)) + }) +} + +pub fn stash_unstaged_in_stashes_unstaged_changes_test() { + git_repo.with_temp_repo("unstaged_changes", fn(dir) { + git_repo.init_repo(dir) + git_repo.commit_file(dir, "file.txt", "tracked\n") + + let path = filepath.join(dir, "file.txt") + let assert Ok(_) = simplifile.write(path, "tracked\nunstaged\n") + + git.stash_unstaged_in(dir) + |> should.equal(Ok(True)) + + let assert Ok(contents) = simplifile.read(path) + util.normalize_newlines(contents) |> should.equal("tracked\n") + + let assert Ok(_) = git.pop_stash_required_in(dir) + let assert Ok(contents) = simplifile.read(path) + util.normalize_newlines(contents) |> should.equal("tracked\nunstaged\n") + }) +} + +pub fn stash_unstaged_in_stashes_untracked_files_test() { + git_repo.with_temp_repo("untracked_files", fn(dir) { + git_repo.init_repo(dir) + git_repo.commit_file(dir, "file.txt", "tracked\n") + + let path = filepath.join(dir, "new.txt") + let assert Ok(_) = simplifile.write(path, "untracked\n") + + git.stash_unstaged_in(dir) + |> should.equal(Ok(True)) + + case simplifile.file_info(path) { + Ok(_) -> should.fail() + Error(_) -> Nil + } + + let assert Ok(_) = git.pop_stash_required_in(dir) + simplifile.file_info(path) |> should.be_ok() + Nil + }) +} + +pub fn pop_stash_required_wrong_top_test() { + git_repo.with_temp_repo("wrong_stash_top", fn(dir) { + git_repo.init_repo(dir) + git_repo.commit_file(dir, "file.txt", "tracked\n") + + let path = filepath.join(dir, "file.txt") + let assert Ok(_) = simplifile.write(path, "tracked\nother\n") + let assert Ok(_) = + shellout.command( + run: "git", + with: ["stash", "push", "-m", "other-stash"], + in: dir, + opt: [], + ) + + git.pop_stash_required_in(dir) + |> should.be_error() + Nil + }) +} + +pub fn stash_skipped_dirty_worktree_test() { + git_repo.with_temp_repo("stash_skipped_dirty", fn(dir) { + git_repo.init_repo(dir) + git_repo.commit_file(dir, "file.txt", "tracked\n") + + let path = filepath.join(dir, "file.txt") + let assert Ok(_) = simplifile.write(path, "tracked\nstash me\n") + let assert Ok(_) = + shellout.command( + run: "git", + with: ["stash", "push", "-m", "existing-stash"], + in: dir, + opt: [], + ) + let assert Ok(_) = simplifile.write(path, "tracked\nunstaged\n") + + git.stash_unstaged_in(dir) + |> should.equal(Ok(False)) + + git.worktree_has_unstaged_changes_in(dir) + |> should.equal(Ok(True)) + }) +} + +pub fn list_files_strips_cr_test() { + git_repo.with_temp_repo("list_files_trim", fn(dir) { + git_repo.init_repo(dir) + git_repo.commit_file(dir, "file.txt", "tracked\n") + + git.list_files_in(dir, ["ls-files"]) + |> should.be_ok() + |> fn(files) { + list.all(files, fn(file) { !string.contains(file, "\r") }) + |> should.be_true() + } + }) +} diff --git a/test/modified_git_test.gleam b/test/modified_git_test.gleam new file mode 100644 index 0000000..92298e3 --- /dev/null +++ b/test/modified_git_test.gleam @@ -0,0 +1,70 @@ +import cactus/modified.{All, Staged, Unstaged, get_files_for_scope_in} +import filepath +import gleam/list +import gleeunit/should +import shellout +import simplifile +import support/git_repo + +pub fn staged_files_test() { + git_repo.with_temp_repo("staged_scope", fn(dir) { + git_repo.init_repo(dir) + git_repo.commit_file(dir, "tracked.txt", "v1\n") + + let path = filepath.join(dir, "tracked.txt") + let assert Ok(_) = simplifile.write(path, "v2\n") + let assert Ok(_) = + shellout.command( + run: "git", + with: ["add", "tracked.txt"], + in: dir, + opt: [], + ) + + get_files_for_scope_in(dir, Staged) + |> should.equal(Ok(["tracked.txt"])) + }) +} + +pub fn unstaged_files_test() { + git_repo.with_temp_repo("unstaged_scope", fn(dir) { + git_repo.init_repo(dir) + git_repo.commit_file(dir, "tracked.txt", "v1\n") + + let path = filepath.join(dir, "tracked.txt") + let assert Ok(_) = simplifile.write(path, "v2\n") + let assert Ok(_) = simplifile.write(filepath.join(dir, "new.txt"), "new\n") + + get_files_for_scope_in(dir, Unstaged) + |> should.be_ok() + |> should.equal(["tracked.txt", "new.txt"]) + }) +} + +pub fn all_files_test() { + git_repo.with_temp_repo("all_scope", fn(dir) { + git_repo.init_repo(dir) + git_repo.commit_file(dir, "tracked.txt", "v1\n") + + let path = filepath.join(dir, "tracked.txt") + let assert Ok(_) = simplifile.write(path, "v2\n") + let assert Ok(_) = + shellout.command( + run: "git", + with: ["add", "tracked.txt"], + in: dir, + opt: [], + ) + let assert Ok(_) = simplifile.write(filepath.join(dir, "new.txt"), "new\n") + + get_files_for_scope_in(dir, All) + |> should.be_ok() + |> list.contains("tracked.txt") + |> should.be_true() + + get_files_for_scope_in(dir, All) + |> should.be_ok() + |> list.contains("new.txt") + |> should.be_true() + }) +} diff --git a/test/modified_test.gleam b/test/modified_test.gleam index 22ddeed..2337423 100644 --- a/test/modified_test.gleam +++ b/test/modified_test.gleam @@ -1,43 +1,119 @@ -import cactus/modified.{matches_ending, modified_files_match} - -pub fn matches_ending_test() { - assert !matches_ending("foo", []) +import cactus/modified.{ + All, Staged, Unstaged, file_matches_pattern, filter_files_under_cwd, + modified_files_match, parse_files_scope, +} +import gleeunit/should - assert !matches_ending("foo", [".foo"]) +pub fn extension_match_test() { + file_matches_pattern("src/foo.gleam", ".gleam") + |> should.be_true() - assert matches_ending(".foo", [".foo"]) + file_matches_pattern("foo", ".foo") + |> should.be_false() - assert matches_ending(".foo", [".foo", ".bar", ".baz"]) + file_matches_pattern(".foo", ".foo") + |> should.be_true() } pub fn modified_files_match_test() { - assert modified_files_match(["foo"], ["./foo"]) + modified_files_match(["foo"], ["./foo"]) + |> should.be_true() + + modified_files_match(["foo"], ["foo"]) + |> should.be_true() + + modified_files_match(["foo"], []) + |> should.be_true() + + modified_files_match(["foo"], ["bar"]) + |> should.be_false() + + modified_files_match([""], []) + |> should.be_true() - assert modified_files_match(["foo"], ["foo"]) + modified_files_match([], [""]) + |> should.be_true() - assert modified_files_match(["foo"], []) + modified_files_match([], [".foo", ".bar"]) + |> should.be_false() - assert !modified_files_match(["foo"], ["bar"]) + modified_files_match([], []) + |> should.be_true() - assert modified_files_match([""], []) + modified_files_match([""], [""]) + |> should.be_true() - assert modified_files_match([], [""]) + modified_files_match(["foo"], [""]) + |> should.be_true() - assert modified_files_match([], [".foo", ".bar"]) + modified_files_match(["foo"], [".test"]) + |> should.be_false() - assert modified_files_match([], []) + modified_files_match(["foo.test"], ["bar.test", ".test"]) + |> should.be_true() - assert modified_files_match([""], [""]) + modified_files_match(["foo.test"], ["./bar.test", ".test"]) + |> should.be_true() - assert modified_files_match(["foo"], [""]) + modified_files_match(["foo.test"], ["./bar.test", ""]) + |> should.be_false() - assert !modified_files_match(["foo"], [".test"]) + modified_files_match(["./bar.test"], [".test", "bar.test"]) + |> should.be_true() +} + +pub fn glob_match_src_root_test() { + file_matches_pattern("src/foo.gleam", "src/**/*.gleam") + |> should.be_true() +} + +pub fn glob_match_src_nested_test() { + file_matches_pattern("src/nested/foo.gleam", "src/**/*.gleam") + |> should.be_true() +} + +pub fn glob_match_src_negative_test() { + file_matches_pattern("test/foo.gleam", "src/**/*.gleam") + |> should.be_false() +} + +pub fn glob_match_extension_test() { + file_matches_pattern("src/foo.gleam", "*.gleam") + |> should.be_true() +} + +pub fn glob_unsupported_pattern_test() { + file_matches_pattern("foo.gleam", "*.*.gleam") + |> should.be_false() +} + +pub fn filter_files_under_cwd_test() { + filter_files_under_cwd( + ["pkg/foo.gleam", "other/bar.gleam", "pkg/readme.md"], + "pkg", + ) + |> should.equal(["pkg/foo.gleam", "pkg/readme.md"]) + + filter_files_under_cwd(["src/foo.gleam"], ".") + |> should.equal(["src/foo.gleam"]) + + filter_files_under_cwd(["src/foo.gleam"], "./") + |> should.equal(["src/foo.gleam"]) +} - assert modified_files_match(["foo.test"], ["bar.test", ".test"]) +pub fn parse_files_scope_test() { + parse_files_scope("staged") + |> should.be_ok() + |> should.equal(Staged) - assert modified_files_match(["foo.test"], ["./bar.test", ".test"]) + parse_files_scope("ALL") + |> should.be_ok() + |> should.equal(All) - assert !modified_files_match(["foo.test"], ["./bar.test", ""]) + parse_files_scope("unstaged") + |> should.be_ok() + |> should.equal(Unstaged) - assert modified_files_match(["./bar.test"], [".test", "bar.test"]) + parse_files_scope("nope") + |> should.be_error() } diff --git a/test/run_cwd_test.gleam b/test/run_cwd_test.gleam new file mode 100644 index 0000000..99a889d --- /dev/null +++ b/test/run_cwd_test.gleam @@ -0,0 +1,97 @@ +import filepath +import gleeunit/should +import shellout +import simplifile +import support/git_repo + +const cwd_config = "test/testdata/gleam/exec_cwd_filter.toml" + +const cwd_marker = "pkg/.cwd-filter-ran" + +fn project_root() -> String { + simplifile.current_directory() + |> should.be_ok() +} + +fn config_path(relative: String) -> String { + filepath.join(project_root(), relative) +} + +pub fn cwd_scoped_file_filter_test() { + let _ = simplifile.delete("pkg/.cwd-filter-ran") + + git_repo.with_temp_repo("cwd_filter", fn(dir) { + git_repo.init_repo(dir) + let outside_file = filepath.join(dir, "outside.txt") + let assert Ok(_) = simplifile.write(outside_file, "outside\n") + let assert Ok(_) = + shellout.command( + run: "git", + with: ["add", "outside.txt"], + in: dir, + opt: [], + ) + let assert Ok(_) = + shellout.command( + run: "git", + with: ["commit", "-q", "-m", "outside"], + in: dir, + opt: [], + ) + + let pkg_dir = filepath.join(dir, "pkg") + let assert Ok(_) = simplifile.create_directory(pkg_dir) + let pkg_file = filepath.join(pkg_dir, "inside.txt") + let assert Ok(_) = simplifile.write(pkg_file, "inside\n") + + shellout.command( + run: "gleam", + with: [ + "run", + "-m", + "cactus", + "--", + "--config", + config_path(cwd_config), + "pre-commit", + ], + in: dir, + opt: [], + ) + |> should.be_ok() + + case simplifile.file_info(filepath.join(dir, cwd_marker)) { + Ok(_) -> should.fail() + Error(_) -> Nil + } + + let assert Ok(_) = + shellout.command( + run: "git", + with: ["add", "pkg/inside.txt"], + in: dir, + opt: [], + ) + + shellout.command( + run: "gleam", + with: [ + "run", + "-m", + "cactus", + "--", + "--config", + config_path(cwd_config), + "pre-commit", + ], + in: dir, + opt: [], + ) + |> should.be_ok() + + let assert Ok(_) = simplifile.file_info(filepath.join(dir, cwd_marker)) + Nil + }) + + let _ = simplifile.delete("pkg/.cwd-filter-ran") +} diff --git a/test/run_exec_test.gleam b/test/run_exec_test.gleam new file mode 100644 index 0000000..523ef67 --- /dev/null +++ b/test/run_exec_test.gleam @@ -0,0 +1,128 @@ +import cactus/run +import cactus/write +import gleam/option.{Some} +import gleeunit/should +import shellout.{SetEnvironment, command} +import simplifile + +const exec_config = "test/testdata/gleam/exec.toml" + +const exec_skip_config = "test/testdata/gleam/exec_skip.toml" + +const exec_skip_env_config = "test/testdata/gleam/exec_skip_env.toml" + +const exec_env_config = "test/testdata/gleam/exec_env.toml" + +const exec_continue_config = "test/testdata/gleam/exec_continue.toml" + +const exec_filter_config = "test/testdata/gleam/exec_filter.toml" + +const invalid_skip_config = "test/testdata/gleam/invalid_skip.toml" + +const continue_marker = ".continue-ran" + +const skip_ci_marker = ".skip-ci-ran" + +const skip_env_marker = ".skip-env-ran" + +fn run_hook_with_env( + config: String, + hook: String, + env: List(#(String, String)), +) { + command( + run: "gleam", + with: ["run", "-m", "cactus", "--", "--config", config, hook], + in: ".", + opt: [SetEnvironment(env)], + ) + |> should.be_ok() +} + +pub fn dry_run_test() { + run.run(exec_config, "test", run.RunOptions(verbose: False, dry_run: True)) + |> should.be_ok() +} + +pub fn hook_config_test() { + run.get_hook_config(exec_filter_config, "pre-commit") + |> should.be_ok() +} + +pub fn skip_env_config_test() { + let assert Ok(config) = run.get_hook_config(exec_skip_config, "test") + case config.skip_env { + Some("CI=true") -> Nil + _ -> should.fail() + } +} + +pub fn invalid_skip_env_test() { + run.get_hook_config(invalid_skip_config, "test") + |> should.be_error() +} + +pub fn skip_env_ci_runtime_test() { + let _ = simplifile.delete(skip_ci_marker) + + run_hook_with_env(exec_skip_config, "test", [#("CI", "true")]) + + case simplifile.file_info(skip_ci_marker) { + Ok(_) -> should.fail() + Error(_) -> Nil + } +} + +pub fn skip_env_runtime_test() { + let _ = simplifile.delete(skip_env_marker) + + run_hook_with_env(exec_skip_env_config, "test", [#("SKIP_HOOKS", "1")]) + + case simplifile.file_info(skip_env_marker) { + Ok(_) -> should.fail() + Error(_) -> Nil + } +} + +pub fn env_runtime_test() { + run.run( + exec_env_config, + "test", + run.RunOptions(verbose: False, dry_run: False), + ) + |> should.be_ok() +} + +pub fn on_failure_continue_test() { + let _ = simplifile.delete(continue_marker) + + run.run( + exec_continue_config, + "test", + run.RunOptions(verbose: False, dry_run: False), + ) + |> should.be_error() + + simplifile.file_info(continue_marker) + |> should.be_ok() + + let _ = simplifile.delete(continue_marker) +} + +pub fn is_valid_hook_name_test() { + write.is_valid_hook_name("post-commit") + |> should.be_true() + + write.is_valid_hook_name("not-a-hook") + |> should.be_false() +} + +pub fn is_cactus_hook_test() { + write.is_cactus_hook( + "#!/bin/sh\n\ngleam run --target erlang -m cactus -- pre-commit", + ) + |> should.be_true() + + write.is_cactus_hook("#!/bin/sh\n\necho hello") + |> should.be_false() +} diff --git a/test/run_filter_test.gleam b/test/run_filter_test.gleam new file mode 100644 index 0000000..a28627a --- /dev/null +++ b/test/run_filter_test.gleam @@ -0,0 +1,51 @@ +import filepath +import gleeunit/should +import shellout.{SetEnvironment, command} +import simplifile +import support/git_repo + +const filter_config = "test/testdata/gleam/exec_filter_run.toml" + +const filter_marker = ".filter-ran" + +fn project_root() -> String { + simplifile.current_directory() + |> should.be_ok() +} + +fn config_path(relative: String) -> String { + filepath.join(project_root(), relative) +} + +fn run_hook_in_repo( + dir: String, + config: String, + hook: String, + env: List(#(String, String)), +) { + command( + run: "gleam", + with: ["run", "-m", "cactus", "--", "--config", config, hook], + in: dir, + opt: [SetEnvironment(env)], + ) + |> should.be_ok() +} + +pub fn filtered_action_skipped_test() { + let _ = simplifile.delete(filter_marker) + + git_repo.with_temp_repo("filter_run", fn(dir) { + git_repo.init_repo(dir) + git_repo.commit_file(dir, "readme.md", "hello\n") + + run_hook_in_repo(dir, config_path(filter_config), "pre-commit", []) + + case simplifile.file_info(filepath.join(dir, filter_marker)) { + Ok(_) -> should.fail() + Error(_) -> Nil + } + }) + + let _ = simplifile.delete(filter_marker) +} diff --git a/test/run_stash_test.gleam b/test/run_stash_test.gleam new file mode 100644 index 0000000..0c1dbdd --- /dev/null +++ b/test/run_stash_test.gleam @@ -0,0 +1,106 @@ +import cactus/git +import filepath +import gleeunit/should +import shellout +import simplifile +import support/git_repo + +const stash_config = "test/testdata/gleam/exec_stash.toml" + +const stash_marker = ".stash-ran" + +const pre_merge_marker = ".pre-merge-ran" + +fn project_root() -> String { + simplifile.current_directory() + |> should.be_ok() +} + +fn config_path(relative: String) -> String { + filepath.join(project_root(), relative) +} + +fn run_hook_in_repo(dir: String, hook: String) { + shellout.command( + run: "gleam", + with: [ + "run", + "-m", + "cactus", + "--", + "--config", + config_path(stash_config), + hook, + ], + in: dir, + opt: [], + ) + |> should.be_ok() +} + +pub fn pre_commit_stash_restore_test() { + let _ = simplifile.delete(stash_marker) + + git_repo.with_temp_repo("pre_commit_stash", fn(dir) { + git_repo.init_repo(dir) + git_repo.commit_file(dir, "tracked.txt", "v1\n") + + let path = filepath.join(dir, "tracked.txt") + let assert Ok(_) = simplifile.write(path, "v1\nunstaged\n") + + run_hook_in_repo(dir, "pre-commit") + + simplifile.file_info(filepath.join(dir, stash_marker)) + |> should.be_ok() + + simplifile.read(path) + |> should.be_ok() + |> should.equal("v1\nunstaged\n") + }) + + let _ = simplifile.delete(stash_marker) +} + +pub fn pre_merge_commit_stash_restore_test() { + let _ = simplifile.delete(pre_merge_marker) + + git_repo.with_temp_repo("pre_merge_stash", fn(dir) { + git_repo.init_repo(dir) + git_repo.commit_file(dir, "tracked.txt", "v1\n") + + let path = filepath.join(dir, "tracked.txt") + let assert Ok(_) = simplifile.write(path, "v1\nunstaged\n") + + run_hook_in_repo(dir, "pre-merge-commit") + + simplifile.file_info(filepath.join(dir, pre_merge_marker)) + |> should.be_ok() + + simplifile.read(path) + |> should.be_ok() + |> should.equal("v1\nunstaged\n") + }) + + let _ = simplifile.delete(pre_merge_marker) +} + +pub fn pop_stash_required_wrong_top_integration_test() { + git_repo.with_temp_repo("stash_pop_wrong", fn(dir) { + git_repo.init_repo(dir) + git_repo.commit_file(dir, "tracked.txt", "v1\n") + + let path = filepath.join(dir, "tracked.txt") + let assert Ok(_) = simplifile.write(path, "v1\nother\n") + let assert Ok(_) = + shellout.command( + run: "git", + with: ["stash", "push", "-m", "other-stash"], + in: dir, + opt: [], + ) + + git.pop_stash_required_in(dir) + |> should.be_error() + Nil + }) +} diff --git a/test/support/git_repo.gleam b/test/support/git_repo.gleam new file mode 100644 index 0000000..dbc3588 --- /dev/null +++ b/test/support/git_repo.gleam @@ -0,0 +1,59 @@ +import filepath +import shellout +import simplifile + +const git_work_dir = "test/testdata/git_work" + +pub fn with_temp_repo(name: String, callback: fn(String) -> Nil) -> Nil { + let dir = filepath.join(git_work_dir, name) + let _ = simplifile.delete_all([dir]) + let _ = simplifile.create_directory(git_work_dir) + let assert Ok(_) = simplifile.create_directory(dir) + + callback(dir) + + let _ = simplifile.delete_all([dir]) + Nil +} + +pub fn init_repo(dir: String) -> Nil { + let assert Ok(_) = + shellout.command(run: "git", with: ["init", "-q"], in: dir, opt: []) + let assert Ok(_) = + shellout.command( + run: "git", + with: ["config", "user.email", "cactus@test"], + in: dir, + opt: [], + ) + let assert Ok(_) = + shellout.command( + run: "git", + with: ["config", "user.name", "cactus"], + in: dir, + opt: [], + ) + let assert Ok(_) = + shellout.command( + run: "git", + with: ["config", "core.autocrlf", "false"], + in: dir, + opt: [], + ) + Nil +} + +pub fn commit_file(dir: String, name: String, content: String) -> Nil { + let path = filepath.join(dir, name) + let assert Ok(_) = simplifile.write(path, content) + let assert Ok(_) = + shellout.command(run: "git", with: ["add", name], in: dir, opt: []) + let assert Ok(_) = + shellout.command( + run: "git", + with: ["commit", "-q", "-m", "init"], + in: dir, + opt: [], + ) + Nil +} diff --git a/test/testdata/gleam/always_init_string.toml b/test/testdata/gleam/always_init_string.toml new file mode 100644 index 0000000..5d55f6c --- /dev/null +++ b/test/testdata/gleam/always_init_string.toml @@ -0,0 +1,2 @@ +[cactus] +always_init = "true" diff --git a/test/testdata/gleam/empty.toml b/test/testdata/gleam/empty.toml index e69de29..13bf395 100644 --- a/test/testdata/gleam/empty.toml +++ b/test/testdata/gleam/empty.toml @@ -0,0 +1 @@ +[cactus] diff --git a/test/testdata/gleam/exec.toml b/test/testdata/gleam/exec.toml new file mode 100644 index 0000000..bba0001 --- /dev/null +++ b/test/testdata/gleam/exec.toml @@ -0,0 +1,4 @@ +[cactus.test] +actions = [ + { command = "touch", kind = "binary", args = [".dry-run-should-not-exist"] }, +] diff --git a/test/testdata/gleam/exec_continue.toml b/test/testdata/gleam/exec_continue.toml new file mode 100644 index 0000000..70d7ed5 --- /dev/null +++ b/test/testdata/gleam/exec_continue.toml @@ -0,0 +1,6 @@ +[cactus.test] +on_failure = "continue" +actions = [ + { command = "false", kind = "binary", args = [] }, + { command = "touch", kind = "binary", args = [".continue-ran"] }, +] diff --git a/test/testdata/gleam/exec_cwd_filter.toml b/test/testdata/gleam/exec_cwd_filter.toml new file mode 100644 index 0000000..3c6df3d --- /dev/null +++ b/test/testdata/gleam/exec_cwd_filter.toml @@ -0,0 +1,11 @@ +[cactus.pre-commit] +files_scope = "staged" +actions = [ + { + command = "touch", + kind = "binary", + args = [".cwd-filter-ran"], + cwd = "pkg", + files = [".txt"], + }, +] diff --git a/test/testdata/gleam/exec_env.toml b/test/testdata/gleam/exec_env.toml new file mode 100644 index 0000000..ef859e8 --- /dev/null +++ b/test/testdata/gleam/exec_env.toml @@ -0,0 +1,9 @@ +[cactus.test] +actions = [ + { + command = "sh", + kind = "binary", + args = ["-c", "test -n \"$CACTUS_TEST_VAR\""], + env = { CACTUS_TEST_VAR = "set" }, + }, +] diff --git a/test/testdata/gleam/exec_filter.toml b/test/testdata/gleam/exec_filter.toml new file mode 100644 index 0000000..5fd7509 --- /dev/null +++ b/test/testdata/gleam/exec_filter.toml @@ -0,0 +1,6 @@ +[cactus.pre-commit] +files_scope = "staged" +actions = [ + { command = "echo", kind = "binary", args = ["ran"], files = [".marker"] }, + { command = "false", kind = "binary", args = [] }, +] diff --git a/test/testdata/gleam/exec_filter_run.toml b/test/testdata/gleam/exec_filter_run.toml new file mode 100644 index 0000000..59e1542 --- /dev/null +++ b/test/testdata/gleam/exec_filter_run.toml @@ -0,0 +1,10 @@ +[cactus.pre-commit] +files_scope = "staged" +actions = [ + { + command = "touch", + kind = "binary", + args = [".filter-ran"], + files = [".marker"], + }, +] diff --git a/test/testdata/gleam/exec_skip.toml b/test/testdata/gleam/exec_skip.toml new file mode 100644 index 0000000..35c161a --- /dev/null +++ b/test/testdata/gleam/exec_skip.toml @@ -0,0 +1,5 @@ +[cactus.test] +skip_env = "CI=true" +actions = [ + { command = "touch", kind = "binary", args = [".skip-ci-ran"] }, +] diff --git a/test/testdata/gleam/exec_skip_env.toml b/test/testdata/gleam/exec_skip_env.toml new file mode 100644 index 0000000..f66ea20 --- /dev/null +++ b/test/testdata/gleam/exec_skip_env.toml @@ -0,0 +1,9 @@ +[cactus.test] +actions = [ + { + command = "touch", + kind = "binary", + args = [".skip-env-ran"], + skip_env = "SKIP_HOOKS=1", + }, +] diff --git a/test/testdata/gleam/exec_stash.toml b/test/testdata/gleam/exec_stash.toml new file mode 100644 index 0000000..e0bc331 --- /dev/null +++ b/test/testdata/gleam/exec_stash.toml @@ -0,0 +1,9 @@ +[cactus.pre-commit] +actions = [ + { command = "touch", kind = "binary", args = [".stash-ran"] }, +] + +[cactus.pre-merge-commit] +actions = [ + { command = "touch", kind = "binary", args = [".pre-merge-ran"] }, +] diff --git a/test/testdata/gleam/fake.toml b/test/testdata/gleam/fake.toml new file mode 100644 index 0000000..e6c352c --- /dev/null +++ b/test/testdata/gleam/fake.toml @@ -0,0 +1,2 @@ +# Intentionally invalid TOML for parse error tests +not valid toml [[[ diff --git a/test/testdata/gleam/foo.toml b/test/testdata/gleam/foo.toml new file mode 100644 index 0000000..9623614 --- /dev/null +++ b/test/testdata/gleam/foo.toml @@ -0,0 +1 @@ +not valid {{{ toml diff --git a/test/testdata/gleam/invalid_skip.toml b/test/testdata/gleam/invalid_skip.toml new file mode 100644 index 0000000..0019a74 --- /dev/null +++ b/test/testdata/gleam/invalid_skip.toml @@ -0,0 +1,5 @@ +[cactus.test] +skip_env = "not-valid" +actions = [ + { command = "touch", kind = "binary", args = [".invalid-skip-ran"] }, +] diff --git a/test/util_err_test.gleam b/test/util_err_test.gleam new file mode 100644 index 0000000..90947ff --- /dev/null +++ b/test/util_err_test.gleam @@ -0,0 +1,31 @@ +import cactus/util.{ + ActionFailedErr, CLIErr, FSErr, GitError, InvalidFieldErr, InvalidTomlErr, + err_as_str, quote, +} +import gleeunit/should +import gleither.{Left, Right} +import simplifile +import tom.{NotFound} + +pub fn err_as_str_test() { + err_as_str(InvalidTomlErr) + |> should.equal("Invalid Toml Error") + + err_as_str(InvalidFieldErr("", Left(NotFound(["cactus", "actions"])))) + |> should.equal("Missing field in config: " <> quote("cactus.actions")) + + err_as_str(InvalidFieldErr("kind", Right("got: 'foo' expected: module"))) + |> should.equal("Invalid field in config: kind got: 'foo' expected: module") + + err_as_str(ActionFailedErr(2, 5, "format --check", "bad fmt")) + |> should.equal("Action 2/5 failed: 'format --check'\nbad fmt") + + err_as_str(CLIErr("nope")) + |> should.equal("CLI Error: invalid arg 'nope'") + + err_as_str(GitError("git stash pop", "conflict")) + |> should.equal("Error while running 'git stash pop' : 'conflict'") + + err_as_str(FSErr("path", simplifile.Eacces)) + |> should.equal("FS Error at path with Permission denied") +} diff --git a/test/util_test.gleam b/test/util_test.gleam index d930079..bd52769 100644 --- a/test/util_test.gleam +++ b/test/util_test.gleam @@ -16,15 +16,22 @@ pub fn parse_gleam_toml_test() { } pub fn parse_always_init_test() { - assert !util.parse_always_init("test/testdata/gleam/basic.toml") + util.parse_always_init("test/testdata/gleam/basic.toml") + |> should.equal(Ok(False)) - assert !util.parse_always_init("test/testdata/gleam/basic.toml") + util.parse_always_init("test/testdata/gleam/too_many.toml") + |> should.equal(Ok(False)) - assert !util.parse_always_init("test/testdata/gleam/too_many.toml") + util.parse_always_init("test/testdata/gleam/foo.toml") + |> should.be_error() - assert !util.parse_always_init("test/testdata/gleam/foo.toml") + util.parse_always_init("test/testdata/gleam/always.toml") + |> should.equal(Ok(True)) +} - assert util.parse_always_init("test/testdata/gleam/always.toml") +pub fn parse_always_init_invalid_test() { + util.parse_always_init("test/testdata/gleam/always_init_string.toml") + |> should.be_error() } pub fn drop_empty_test() { diff --git a/test/write_test.gleam b/test/write_test.gleam index 8a47166..4755f34 100644 --- a/test/write_test.gleam +++ b/test/write_test.gleam @@ -27,6 +27,40 @@ pub fn create_script_test() { assert actual == write.get_hook_template(False) <> "test" } +pub fn clean_test() { + let assert Ok(_) = simplifile.delete_all([hook_dir]) + let assert Ok(_) = write.create_script(hook_dir, "test", False) + + write.clean(hook_dir) + |> should.be_ok() + + case simplifile.read(filepath.join(hook_dir, "test")) { + Ok(_) -> should.fail() + Error(_) -> Nil + } +} + +pub fn clean_preserves_user_hooks_test() { + let user_hook = filepath.join(hook_dir, "user-hook") + let assert Ok(_) = simplifile.delete_all([hook_dir]) + let assert Ok(_) = simplifile.create_directory(hook_dir) + let assert Ok(_) = + simplifile.write(user_hook, "#!/bin/sh\n\necho user hook\n") + let assert Ok(_) = write.create_script(hook_dir, "test", False) + + write.clean(hook_dir) + |> should.be_ok() + + simplifile.read(user_hook) + |> should.be_ok() + |> should.equal("#!/bin/sh\n\necho user hook\n") + + case simplifile.read(filepath.join(hook_dir, "test")) { + Ok(_) -> should.fail() + Error(_) -> Nil + } +} + @target(javascript) pub fn get_hook_template_test() { let runtime = case platform.runtime() {