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
+
+
+
+
[](https://hex.pm/packages/cactus)
[](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() {