├── test ├── testdata │ └── gleam │ │ ├── empty.toml │ │ ├── junk.toml │ │ ├── always.toml │ │ ├── basic.toml │ │ ├── dos.toml │ │ ├── invalid.toml │ │ └── too_many.toml ├── cactus_test.gleam ├── util_test.gleam ├── modified_test.gleam ├── write_test.gleam └── run_test.gleam ├── .tool-versions ├── images ├── demo.gif └── demo.tape ├── yarn.lock ├── scripts ├── format.sh ├── update.sh ├── test.sh ├── target_test.sh └── publish.sh ├── .gitignore ├── package.json ├── .github └── workflows │ ├── deps.yml │ └── ci.yml ├── src ├── cactus │ ├── git.gleam │ ├── modified.gleam │ ├── write.gleam │ ├── util.gleam │ └── run.gleam └── cactus.gleam ├── LICENSE ├── gleam.toml ├── README.md └── manifest.toml /test/testdata/gleam/empty.toml: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /test/testdata/gleam/junk.toml: -------------------------------------------------------------------------------- 1 | [javascript] 2 | runtime = [] -------------------------------------------------------------------------------- /test/testdata/gleam/always.toml: -------------------------------------------------------------------------------- 1 | [cactus] 2 | always_init = true -------------------------------------------------------------------------------- /.tool-versions: -------------------------------------------------------------------------------- 1 | gleam 1.12.0 2 | erlang 28.0.2 3 | nodejs 22.17.1 4 | deno 2.4.2 5 | -------------------------------------------------------------------------------- /images/demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bwireman/cactus/HEAD/images/demo.gif -------------------------------------------------------------------------------- /test/cactus_test.gleam: -------------------------------------------------------------------------------- 1 | import gleeunit 2 | 3 | pub fn main() { 4 | gleeunit.main() 5 | } 6 | -------------------------------------------------------------------------------- /yarn.lock: -------------------------------------------------------------------------------- 1 | # THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. 2 | # yarn lockfile v1 3 | 4 | 5 | -------------------------------------------------------------------------------- /scripts/format.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -e 3 | cd "$(dirname "$0")/.." 4 | 5 | gleam fix 6 | gleam format 7 | deno fmt -------------------------------------------------------------------------------- /scripts/update.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -e 3 | cd "$(dirname "$0")/.." 4 | 5 | gleam update 6 | gleam run -m go_over -- --outdated -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.beam 2 | *.ez 3 | /build 4 | erl_crash.dump 5 | .go-over 6 | node_modules/ 7 | .vscode 8 | key._ 9 | test/testdata/scripts 10 | .test-run -------------------------------------------------------------------------------- /test/testdata/gleam/basic.toml: -------------------------------------------------------------------------------- 1 | [cactus] 2 | 3 | [cactus.pre-push] 4 | actions = [ 5 | { command = "A", kind = "sub_command" }, 6 | { command = "B", args = ["--outdated"] }, 7 | { command = "C", kind = "binary" }, 8 | ] 9 | 10 | [javascript] 11 | x = true -------------------------------------------------------------------------------- /test/testdata/gleam/dos.toml: -------------------------------------------------------------------------------- 1 | [cactus] 2 | 3 | [cactus.pre-push] 4 | actions = [ 5 | { command = "A", kind = "sub_command" }, 6 | { command = "B", args = ["--outdated"] }, 7 | { command = "C", kind = "binary" }, 8 | ] 9 | 10 | [javascript] 11 | x = true -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "cactus", 3 | "version": "0.1.0", 4 | "main": "index.js", 5 | "repository": "git@github.com:bwireman/cactus.git", 6 | "author": "bwireman ", 7 | "license": "MIT", 8 | "private": true, 9 | "dependencies": {} 10 | } 11 | -------------------------------------------------------------------------------- /test/testdata/gleam/invalid.toml: -------------------------------------------------------------------------------- 1 | [cactus.no-actions] 2 | [cactus.actions-wrong-type] 3 | actions = true 4 | 5 | [cactus.actions-element-wrong-type] 6 | actions = [1] 7 | 8 | [cactus.no-command] 9 | actions = [{}] 10 | 11 | [cactus.kind-wrong-type] 12 | actions = [{command = "echo", kind = "foo"}] 13 | 14 | -------------------------------------------------------------------------------- /scripts/test.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -e 3 | cd "$(dirname "$0")/.." 4 | 5 | GREEN='\033[0;32m' 6 | NC='\033[0m' 7 | 8 | gleam check 9 | gleam build 10 | gleam format 11 | 12 | echo -e "${GREEN}==> erlang${NC}" 13 | ./scripts/target_test.sh erlang 14 | 15 | echo -e "${GREEN}==> nodejs${NC}" 16 | ./scripts/target_test.sh javascript nodejs 17 | 18 | echo -e "${GREEN}==> deno${NC}" 19 | ./scripts/target_test.sh javascript deno 20 | 21 | echo -e "${GREEN}==> bun${NC}" 22 | ./scripts/target_test.sh javascript bun 23 | 24 | gleam run -------------------------------------------------------------------------------- /test/testdata/gleam/too_many.toml: -------------------------------------------------------------------------------- 1 | [cactus.applypatch-msg] 2 | actions = [] 3 | [cactus.commit-msg] 4 | actions = [] 5 | [cactus.fsmonitor-watchman] 6 | actions = [] 7 | [cactus.post-update] 8 | actions = [] 9 | [cactus.pre-applypatch] 10 | actions = [] 11 | [cactus.pre-commit] 12 | actions = [] 13 | [cactus.pre-merge-commit] 14 | actions = [] 15 | [cactus.prepare-commit-msg] 16 | actions = [] 17 | [cactus.pre-push] 18 | actions = [] 19 | [cactus.pre-rebase] 20 | actions = [] 21 | [cactus.pre-receive] 22 | actions = [] 23 | [cactus.push-to-checkout] 24 | actions = [] 25 | [cactus.update] 26 | actions = [] -------------------------------------------------------------------------------- /.github/workflows/deps.yml: -------------------------------------------------------------------------------- 1 | name: Dependency Check 2 | 3 | on: 4 | schedule: 5 | - cron: "0 9 * * 6" 6 | push: 7 | branches: 8 | - main 9 | pull_request: 10 | 11 | env: 12 | otp: "28.0" 13 | gleam: "1.12.0" 14 | rebar: "3" 15 | 16 | jobs: 17 | check-deps: 18 | runs-on: ubuntu-latest 19 | steps: 20 | - uses: actions/checkout@v4 21 | - uses: erlef/setup-beam@v1 22 | with: 23 | otp-version: ${{ env.otp }} 24 | gleam-version: ${{ env.gleam }} 25 | rebar3-version: ${{ env.rebar }} 26 | - run: gleam build 27 | - run: gleam run -m go_over -- --outdated 28 | - uses: jayqi/failed-build-issue-action@v1 29 | if: failure() 30 | with: 31 | github-token: ${{ secrets.GITHUB_TOKEN }} 32 | -------------------------------------------------------------------------------- /images/demo.tape: -------------------------------------------------------------------------------- 1 | Output images/demo.gif 2 | Set FontSize 14 3 | Set PlaybackSpeed 1.25 4 | Set TypingSpeed 75ms 5 | Set FontFamily 'JetBrains Mono' 6 | Set Margin 15 7 | Set MarginFill "#ffaff3" 8 | Set BorderRadius 12 9 | 10 | #setup 11 | Hide 12 | Type `rm -rf ~/.go-over/mirego-elixir-security-advisories ~/.go-over/deps/s* && git branch -D demo && git branch demo --set-upstream-to origin demo && git checkout demo` 13 | Enter 14 | Type clear 15 | Enter 16 | Show 17 | 18 | Type `gleam run -m cactus -- -h` 19 | Enter 20 | Sleep 8s 21 | 22 | Type clear 23 | Enter 24 | 25 | Type `gleam run -m cactus` 26 | Enter 27 | Sleep 2s 28 | Type `git push --dry-run` 29 | Enter 30 | Sleep 12s 31 | 32 | #teardown 33 | Hide 34 | Type `git checkout -` 35 | Type `git branch -D demo` 36 | Type clear 37 | Enter 38 | Show 39 | -------------------------------------------------------------------------------- /src/cactus/git.gleam: -------------------------------------------------------------------------------- 1 | import cactus/util 2 | import gleam/result 3 | import gleam/string 4 | import shellout 5 | 6 | fn full_command(args: List(String)) -> String { 7 | string.join(["git", ..args], " ") 8 | } 9 | 10 | fn run_command(args: List(String)) -> Result(String, util.CactusErr) { 11 | shellout.command(run: "git", with: args, in: ".", opt: []) 12 | |> util.as_git_error(full_command(args)) 13 | } 14 | 15 | pub fn list_files(args: List(String)) -> Result(List(String), util.CactusErr) { 16 | shellout.command(run: "git", with: args, in: ".", opt: []) 17 | |> result.map(string.split(_, "\n")) 18 | |> result.map(util.drop_empty) 19 | |> util.as_git_error(full_command(args)) 20 | } 21 | 22 | pub fn stash_unstaged() -> Result(String, util.CactusErr) { 23 | run_command(["stash", "push", "--keep-index", "--include-untracked"]) 24 | } 25 | 26 | pub fn pop_stash() -> Result(String, util.CactusErr) { 27 | run_command(["stash", "pop"]) 28 | } 29 | -------------------------------------------------------------------------------- /scripts/target_test.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -e 3 | cd "$(dirname "$0")/.." 4 | 5 | function clean() { 6 | rm -rf .test-run 7 | rm -rf .another.test-run 8 | rm -rf .git/hooks/test 9 | } 10 | 11 | if [ -z "$1" ]; then 12 | echo "Must set target" 13 | echo "Usage: $0 " 14 | exit 1 15 | fi 16 | 17 | TARGET="$1" 18 | RUNTIME="$2" 19 | if [ "$TARGET" = "erlang" ]; then 20 | CMD='--target erlang' 21 | else 22 | if [ -z "$2" ]; then 23 | echo "Must set runtime" 24 | echo "Usage: $0 javascript " 25 | exit 1 26 | fi 27 | CMD="--target javascript --runtime $RUNTIME" 28 | fi 29 | 30 | clean 31 | # shellcheck disable=SC2086 32 | gleam test $CMD 33 | # shellcheck disable=SC2086 34 | gleam run $CMD 35 | 36 | test -f ".git/hooks/test" || (echo "test: not found" && exit 1) 37 | # shellcheck disable=SC2086 38 | gleam run $CMD -- test 39 | 40 | test -f ".another.test-run" || (echo ".another.test-run: not found" && exit 1) 41 | test -f ".test-run" || (echo ".test-run: not found" && exit 1) 42 | clean 43 | -------------------------------------------------------------------------------- /scripts/publish.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -e 3 | 4 | cd "$(dirname "$0")/.." 5 | 6 | if [ -z "$1" ]; then 7 | echo "Must set version for release" 8 | echo "Usage:" "$0" "" 9 | exit 1 10 | fi 11 | VER="v$1" 12 | 13 | BRANCH="$(git rev-parse --abbrev-ref HEAD)" 14 | if [ "$BRANCH" != "main" ]; then 15 | echo "Branch must be 'main'" 16 | exit 1 17 | fi 18 | 19 | gleam format 20 | ./scripts/update.sh 21 | ./scripts/test.sh 22 | 23 | if [ -n "$(git status --porcelain)" ]; then 24 | echo "Working dir mush be clean" 25 | exit 1 26 | fi 27 | 28 | function publish { 29 | gleam clean 30 | echo "Tagging" "$VER" 31 | git tag "$VER" 32 | git push origin "$VER" 33 | echo "Publishing to Hex" "$VER" 34 | HEX_API_KEY=$(cat key._) gleam publish 35 | echo "🚀" 36 | } 37 | 38 | echo "Version set to:" "$VER" 39 | while true; do 40 | read -rp "Do you wish to publish? [Yn] " yn 41 | case $yn in 42 | [Yy]* ) publish; break;; 43 | [Nn]* ) echo "canceling..." ; exit;; 44 | * ) publish; break;; 45 | esac 46 | done 47 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Benjamin Wireman 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /test/util_test.gleam: -------------------------------------------------------------------------------- 1 | import cactus/util 2 | import gleeunit/should 3 | 4 | pub fn parse_gleam_toml_test() { 5 | util.parse_gleam_toml("test/testdata/gleam/basic.toml") 6 | |> should.be_ok() 7 | 8 | util.parse_gleam_toml("test/testdata/gleam/empty.toml") 9 | |> should.be_ok() 10 | 11 | util.parse_gleam_toml("test/testdata/gleam/too_many.toml") 12 | |> should.be_ok() 13 | 14 | util.parse_gleam_toml("test/testdata/gleam/foo.toml") 15 | |> should.be_error() 16 | } 17 | 18 | pub fn parse_always_init_test() { 19 | assert !util.parse_always_init("test/testdata/gleam/basic.toml") 20 | 21 | assert !util.parse_always_init("test/testdata/gleam/basic.toml") 22 | 23 | assert !util.parse_always_init("test/testdata/gleam/too_many.toml") 24 | 25 | assert !util.parse_always_init("test/testdata/gleam/foo.toml") 26 | 27 | assert util.parse_always_init("test/testdata/gleam/always.toml") 28 | } 29 | 30 | pub fn drop_empty_test() { 31 | util.drop_empty([]) 32 | |> should.equal([]) 33 | 34 | util.drop_empty([""]) 35 | |> should.equal([]) 36 | 37 | util.drop_empty(["foo"]) 38 | |> should.equal(["foo"]) 39 | 40 | util.drop_empty(["", "", "foo", ""]) 41 | |> should.equal(["foo"]) 42 | } 43 | -------------------------------------------------------------------------------- /src/cactus/modified.gleam: -------------------------------------------------------------------------------- 1 | import cactus/git 2 | import cactus/util 3 | import gleam/list 4 | import gleam/result.{try} 5 | import gleam/string 6 | 7 | pub fn get_modified_files() -> Result(List(String), util.CactusErr) { 8 | use modified <- try( 9 | git.list_files(["ls-files", "--exclude-standard", "--others"]), 10 | ) 11 | use untracked <- try(git.list_files(["diff", "--name-only", "HEAD"])) 12 | 13 | Ok(list.append(untracked, modified)) 14 | } 15 | 16 | pub fn modified_files_match(modified_files: List(String), watched: List(String)) { 17 | let modified_files = util.drop_empty(modified_files) 18 | let watched = util.drop_empty(watched) 19 | 20 | list.is_empty(modified_files) 21 | || list.is_empty(watched) 22 | || { 23 | let endings = list.filter(watched, string.starts_with(_, ".")) 24 | list.any(modified_files, matches_ending(_, endings)) 25 | } 26 | || { 27 | list.filter(watched, string.starts_with(_, "./")) 28 | |> list.map(string.drop_start(_, 2)) 29 | |> list.append(watched) 30 | |> list.unique() 31 | |> list.any(list.contains(modified_files, _)) 32 | } 33 | } 34 | 35 | pub fn matches_ending(modified: String, endings: List(String)) { 36 | list.any(endings, fn(end) { string.ends_with(modified, end) }) 37 | } 38 | -------------------------------------------------------------------------------- /test/modified_test.gleam: -------------------------------------------------------------------------------- 1 | import cactus/modified.{matches_ending, modified_files_match} 2 | 3 | pub fn matches_ending_test() { 4 | assert !matches_ending("foo", []) 5 | 6 | assert !matches_ending("foo", [".foo"]) 7 | 8 | assert matches_ending(".foo", [".foo"]) 9 | 10 | assert matches_ending(".foo", [".foo", ".bar", ".baz"]) 11 | } 12 | 13 | pub fn modified_files_match_test() { 14 | assert modified_files_match(["foo"], ["./foo"]) 15 | 16 | assert modified_files_match(["foo"], ["foo"]) 17 | 18 | assert modified_files_match(["foo"], []) 19 | 20 | assert !modified_files_match(["foo"], ["bar"]) 21 | 22 | assert modified_files_match([""], []) 23 | 24 | assert modified_files_match([], [""]) 25 | 26 | assert modified_files_match([], [".foo", ".bar"]) 27 | 28 | assert modified_files_match([], []) 29 | 30 | assert modified_files_match([""], [""]) 31 | 32 | assert modified_files_match(["foo"], [""]) 33 | 34 | assert !modified_files_match(["foo"], [".test"]) 35 | 36 | assert modified_files_match(["foo.test"], ["bar.test", ".test"]) 37 | 38 | assert modified_files_match(["foo.test"], ["./bar.test", ".test"]) 39 | 40 | assert !modified_files_match(["foo.test"], ["./bar.test", ""]) 41 | 42 | assert modified_files_match(["./bar.test"], [".test", "bar.test"]) 43 | } 44 | -------------------------------------------------------------------------------- /gleam.toml: -------------------------------------------------------------------------------- 1 | name = "cactus" 2 | version = "1.3.5" 3 | licences = ["MIT"] 4 | repository = { type = "github", user = "bwireman", repo = "cactus" } 5 | description = "A tool for managing git lifecycle hooks with ✨ gleam! Pre commit, Pre push and more!" 6 | links = [] 7 | internal_modules = ["cactus/*"] 8 | target = "javascript" 9 | 10 | [javascript] 11 | typescript_declarations = false 12 | runtime = "nodejs" 13 | 14 | [javascript.deno] 15 | allow_all = true 16 | 17 | [go-over] 18 | cache = true 19 | global = true 20 | outdated = true 21 | allowed_licenses = ["MIT", "Apache-2.0", "BSD 2-Clause", "WTFPL"] 22 | 23 | [go-over.ignore] 24 | packages = [] 25 | 26 | [cactus] 27 | always_init = true 28 | 29 | [cactus.pre-commit] 30 | actions = [ 31 | { command = "./scripts/format.sh", kind = "binary", files = [ 32 | ".gleam", 33 | ] }, 34 | { command = "./scripts/test.sh", kind = "binary" }, 35 | ] 36 | 37 | [cactus.pre-push] 38 | actions = [ 39 | { command = "go_over", kind = "module", args = [ 40 | "--outdated", 41 | ] }, 42 | ] 43 | 44 | [cactus.test] 45 | actions = [ 46 | { command = "touch", kind = "binary", args = [ 47 | ".another.test-run", 48 | ] }, 49 | { command = "touch", kind = "binary", args = [ 50 | ".test-run", 51 | ], files = [ 52 | ".another.test-run", 53 | ] }, 54 | ] 55 | 56 | [dependencies] 57 | gleam_stdlib = ">= 0.34.0 and < 2.0.0" 58 | tom = ">= 2.0.0 and < 3.0.0" 59 | shellout = ">= 1.6.0 and < 2.0.0" 60 | simplifile = ">= 2.0.1 and < 3.0.0" 61 | filepath = ">= 1.0.0 and < 2.0.0" 62 | gleither = ">= 2.0.0 and < 3.0.0" 63 | gxyz = ">= 0.3.0 and < 1.0.0" 64 | platform = ">= 1.0.0 and < 2.0.0" 65 | 66 | [dev-dependencies] 67 | gleeunit = ">= 1.0.0 and < 2.0.0" 68 | go_over = ">= 3.0.0 and < 4.0.0" 69 | -------------------------------------------------------------------------------- /test/write_test.gleam: -------------------------------------------------------------------------------- 1 | import cactus/write 2 | import filepath 3 | import gleam/list 4 | import gleeunit/should 5 | @target(javascript) 6 | import platform 7 | import simplifile 8 | 9 | const hook_dir = "test/testdata/scripts" 10 | 11 | pub fn init_test() { 12 | let assert Ok(_) = simplifile.delete_all([hook_dir]) 13 | 14 | write.init(hook_dir, "test/testdata/gleam/too_many.toml", False) 15 | |> should.be_ok() 16 | |> list.length() 17 | |> should.equal(13) 18 | } 19 | 20 | pub fn create_script_test() { 21 | let assert Ok(_) = 22 | simplifile.delete_all([filepath.join(hook_dir, "test"), hook_dir]) 23 | 24 | let assert Ok(_) = write.create_script("test/testdata/scripts", "test", False) 25 | 26 | let assert Ok(actual) = simplifile.read("test/testdata/scripts/test") 27 | assert actual == write.get_hook_template(False) <> "test" 28 | } 29 | 30 | @target(javascript) 31 | pub fn get_hook_template_test() { 32 | let runtime = case platform.runtime() { 33 | platform.Node -> "node" 34 | platform.Bun -> "bun" 35 | platform.Deno -> "deno" 36 | _ -> panic as "invalid runtime" 37 | } 38 | 39 | write.get_hook_template(False) 40 | |> should.equal( 41 | "#!/bin/sh \n\ngleam run --target javascript --runtime " 42 | <> runtime 43 | <> " -m cactus -- ", 44 | ) 45 | 46 | write.get_hook_template(True) 47 | |> should.equal( 48 | "#!/bin/sh \n\ngleam.exe run --target javascript --runtime " 49 | <> runtime 50 | <> " -m cactus -- ", 51 | ) 52 | } 53 | 54 | @target(erlang) 55 | pub fn get_hook_template_test() { 56 | write.get_hook_template(False) 57 | |> should.equal("#!/bin/sh \n\ngleam run --target erlang -m cactus -- ") 58 | 59 | write.get_hook_template(True) 60 | |> should.equal("#!/bin/sh \n\ngleam.exe run --target erlang -m cactus -- ") 61 | } 62 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # 🌵 Cactus 2 | 3 | [![Package Version](https://img.shields.io/hexpm/v/cactus)](https://hex.pm/packages/cactus) 4 | [![Hex Docs](https://img.shields.io/badge/hex-docs-ffaff3)](https://hexdocs.pm/cactus/) 5 | [![mit](https://img.shields.io/github/license/bwireman/cactus?color=brightgreen)](https://github.com/bwireman/cactus/blob/main/LICENSE) 6 | [![gleam js](https://img.shields.io/badge/%20gleam%20%E2%9C%A8-js%20%F0%9F%8C%B8-yellow)](https://gleam.run/news/v0.16-gleam-compiles-to-javascript/) 7 | [![gleam erlang](https://img.shields.io/badge/erlang%20%E2%98%8E%EF%B8%8F-red?style=flat&label=gleam%20%E2%9C%A8)](https://gleam.run) 8 | 9 | A tool for managing git lifecycle hooks with ✨ gleam! Pre commit, Pre push and 10 | more! 11 | 12 | # 🔽 Install 13 | 14 | ```sh 15 | gleam add --dev cactus 16 | ``` 17 | 18 | #### 🌸 Javascript 19 | 20 | Bun, Deno & Nodejs are _all_ supported! 21 | 22 | ### 🎥 Obligatory VHS 23 | 24 | ![demo](https://raw.githubusercontent.com/bwireman/cactus/main/images/demo.gif) 25 | 26 | # ▶️ Usage 27 | 28 | **_FIRST_** configure hooks and then run 29 | 30 | ```sh 31 | # initialize configured hooks 32 | # specify the target depending on how you want the hooks to run 33 | gleam run -m cactus 34 | ``` 35 | 36 | ### Config 37 | 38 | Settings that can be added to your project's `gleam.toml` 39 | 40 | ```toml 41 | [cactus] 42 | # init hooks on every run (default: false) 43 | always_init = false 44 | 45 | # hook name (all git hooks are supported) 46 | [cactus.pre-commit] 47 | # list of actions for the hook 48 | actions = [ 49 | # command: name of the command or binary to be run: required 50 | # kind: is it a gleam subcommand, a binary or a module: ["sub_command", "binary", "module"], default: module 51 | # args: additional args to be passed to the command, default: [] 52 | # files: file paths & endings that you want to trigger the action, default: [] (meaning always trigger) 53 | 54 | # check formatting 55 | { command = "format", kind = "sub_command", args = ["--check"], files = [".gleam", "src/f/oo.gleam"] }, 56 | # run tests 57 | { command = "./scripts/test.sh", kind = "binary" }, 58 | # check dependencies 🕵️‍♂️ 59 | # self plug of https://github.com/bwireman/go-over 60 | { command = "go_over", args=["--outdated"] } 61 | ] 62 | ``` 63 | -------------------------------------------------------------------------------- /test/run_test.gleam: -------------------------------------------------------------------------------- 1 | import cactus/run 2 | import gleam/list 3 | import gleeunit/should 4 | 5 | fn parse(file: String, action: String) { 6 | run.get_actions(file, action) 7 | |> should.be_ok() 8 | |> list.map(run.parse_action) 9 | } 10 | 11 | pub fn parse_action_test() { 12 | // not present 13 | let assert Error(_) = 14 | run.get_actions("test/testdata/gleam/basic.toml", "pre-commit") 15 | 16 | // not present 17 | let assert Error(_) = 18 | run.get_actions("test/testdata/gleam/dos.toml", "pre-commit") 19 | 20 | let assert [a, b, c] = 21 | parse("test/testdata/gleam/basic.toml", "pre-push") 22 | |> list.map(should.be_ok) 23 | should.equal(a.command, "A") 24 | should.equal(a.kind, run.SubCommand) 25 | should.equal(a.args, []) 26 | 27 | should.equal(b.command, "B") 28 | should.equal(b.kind, run.Module) 29 | should.equal(b.args, ["--outdated"]) 30 | 31 | should.equal(c.command, "C") 32 | should.equal(c.kind, run.Binary) 33 | should.equal(c.args, []) 34 | 35 | let assert [dos_a, dos_b, dos_c] = 36 | parse("test/testdata/gleam/dos.toml", "pre-push") 37 | |> list.map(should.be_ok) 38 | should.equal(dos_a.command, "A") 39 | should.equal(dos_a.kind, run.SubCommand) 40 | should.equal(dos_a.args, []) 41 | 42 | should.equal(dos_b.command, "B") 43 | should.equal(dos_b.kind, run.Module) 44 | should.equal(dos_b.args, ["--outdated"]) 45 | 46 | should.equal(dos_c.command, "C") 47 | should.equal(dos_c.kind, run.Binary) 48 | should.equal(dos_c.args, []) 49 | 50 | let assert Error(_) = run.get_actions("test/testdata/gleam/empty.toml", "") 51 | 52 | let assert Error(_) = 53 | run.get_actions("test/testdata/gleam/invalid.toml", "no-actions") 54 | 55 | let assert Error(_) = 56 | run.get_actions("test/testdata/gleam/invalid.toml", "actions-wrong-type") 57 | 58 | parse("test/testdata/gleam/invalid.toml", "actions-element-wrong-type") 59 | |> list.map(should.be_error) 60 | 61 | parse("test/testdata/gleam/invalid.toml", "no-command") 62 | |> list.map(should.be_error) 63 | 64 | parse("test/testdata/gleam/invalid.toml", "kind-wrong-type") 65 | |> list.map(should.be_error) 66 | 67 | let assert Ok([]) = 68 | run.get_actions("test/testdata/gleam/too_many.toml", "pre-merge-commit") 69 | 70 | let assert Error(_) = run.get_actions("test/testdata/gleam/fake.toml", "") 71 | } 72 | -------------------------------------------------------------------------------- /src/cactus.gleam: -------------------------------------------------------------------------------- 1 | import cactus/run 2 | import cactus/util.{type CactusErr, CLIErr} 3 | import cactus/write 4 | import filepath 5 | import gleam/io 6 | import gleam/list 7 | import gleam/result 8 | import gxyz/cli 9 | import platform 10 | import shellout 11 | import simplifile 12 | 13 | fn get_cmd() -> String { 14 | shellout.arguments() 15 | |> cli.strip_js_from_argv() 16 | |> list.last() 17 | |> result.unwrap("") 18 | } 19 | 20 | const help_header = " _ 21 | | | _ 22 | _ | | | | 23 | | || |_| | _ 24 | | || |_,_| ___ __ _ ___| |_ _ _ ___ 25 | \\_| | / __/ _` |/ __| __| | | / __| 26 | | | | (_| (_| | (__| |_| |_| \\__ \\ 27 | |_| \\___\\__,_|\\___|\\__|\\__,_|___/ 28 | " 29 | 30 | const help_body = " 31 | version: 1.3.4 32 | -------------------------------------------- 33 | A tool for managing git lifecycle hooks with 34 | ✨ gleam! Pre commit, Pre push 35 | and more! 36 | 37 | Usage: 38 | 1. Configure your desired hooks in your project's `gleam.toml` 39 | - More info: https://github.com/bwireman/cactus?tab=readme-ov-file#config 40 | 2. Run `gleam run --target -m cactus` 41 | 3. Celebrate! 🎉 42 | " 43 | 44 | pub fn main() -> Result(Nil, CactusErr) { 45 | use pwd <- result.map(util.as_fs_err(simplifile.current_directory(), ".")) 46 | let gleam_toml = filepath.join(pwd, "gleam.toml") 47 | let hooks_dir = 48 | pwd 49 | |> filepath.join(".git") 50 | |> filepath.join("hooks") 51 | 52 | let cmd = get_cmd() 53 | let res = case cmd { 54 | "help" | "--help" | "-h" | "-help" -> { 55 | util.format_success(help_header) 56 | |> io.print() 57 | 58 | util.format_info(help_body) 59 | |> io.print() 60 | 61 | Ok(Nil) 62 | } 63 | 64 | "windows-init" -> 65 | write.init(hooks_dir, gleam_toml, True) 66 | |> result.replace(Nil) 67 | 68 | "unix-init" -> 69 | write.init(hooks_dir, gleam_toml, False) 70 | |> result.replace(Nil) 71 | 72 | "" | "init" -> 73 | write.init(hooks_dir, gleam_toml, platform.os() == platform.Win32) 74 | |> result.replace(Nil) 75 | 76 | arg -> { 77 | let _ = case util.parse_always_init(gleam_toml) { 78 | True -> write.init(hooks_dir, gleam_toml, False) 79 | _ -> Ok([]) 80 | } 81 | 82 | case write.is_valid_hook_name(arg) { 83 | True -> run.run(gleam_toml, arg) 84 | False -> Error(CLIErr(arg)) 85 | } 86 | |> result.replace(Nil) 87 | } 88 | } 89 | 90 | case res { 91 | Ok(_) -> Nil 92 | 93 | Error(CLIErr(err)) -> { 94 | util.print_warning(util.err_as_str(CLIErr(err))) 95 | shellout.exit(1) 96 | } 97 | 98 | Error(reason) -> { 99 | [util.quote(cmd), "hook failed. Reason:", util.err_as_str(reason)] 100 | |> util.join_text() 101 | |> util.print_warning() 102 | shellout.exit(1) 103 | } 104 | } 105 | } 106 | -------------------------------------------------------------------------------- /src/cactus/write.gleam: -------------------------------------------------------------------------------- 1 | import cactus/util.{ 2 | type CactusErr, as_fs_err, as_invalid_field_err, cactus, parse_gleam_toml, 3 | print_progress, quote, 4 | } 5 | import filepath 6 | import gleam/dict 7 | import gleam/list 8 | import gleam/result.{try} 9 | import gleam/set 10 | @target(javascript) 11 | import platform 12 | import simplifile 13 | import tom 14 | 15 | const valid_hooks = [ 16 | "applypatch-msg", "commit-msg", "fsmonitor-watchman", "post-update", 17 | "pre-applypatch", "pre-commit", "pre-merge-commit", "prepare-commit-msg", 18 | "pre-push", "pre-rebase", "pre-receive", "push-to-checkout", "update", "test", 19 | ] 20 | 21 | fn gleam_name(windows: Bool) -> String { 22 | case windows { 23 | True -> "gleam.exe" 24 | False -> "gleam" 25 | } 26 | } 27 | 28 | @target(javascript) 29 | pub fn get_hook_template(windows: Bool) -> String { 30 | let runtime = case platform.runtime() { 31 | platform.Node -> "node" 32 | platform.Bun -> "bun" 33 | platform.Deno -> "deno" 34 | _ -> 35 | panic as "Invalid runtime, please create an issue in https://github.com/bwireman/cactus if you see this" 36 | } 37 | 38 | "#!/bin/sh \n\n" 39 | <> gleam_name(windows) 40 | <> " run --target javascript --runtime " 41 | <> runtime 42 | <> " -m cactus -- " 43 | } 44 | 45 | @target(erlang) 46 | pub fn get_hook_template(windows: Bool) -> String { 47 | "#!/bin/sh \n\n" 48 | <> gleam_name(windows) 49 | <> " run --target erlang -m cactus -- " 50 | } 51 | 52 | pub fn is_valid_hook_name(name: String) -> Bool { 53 | list.contains(valid_hooks, name) 54 | } 55 | 56 | pub fn create_script( 57 | hooks_dir: String, 58 | command: String, 59 | windows: Bool, 60 | ) -> Result(String, CactusErr) { 61 | case command == "test" { 62 | False -> print_progress("Initializing hook: " <> quote(command)) 63 | _ -> Nil 64 | } 65 | 66 | let path = filepath.join(hooks_dir, command) 67 | let all = 68 | set.from_list([simplifile.Read, simplifile.Write, simplifile.Execute]) 69 | 70 | let _ = simplifile.create_directory(hooks_dir) 71 | let _ = simplifile.create_file(path) 72 | use _ <- try(as_fs_err( 73 | simplifile.write(path, get_hook_template(windows) <> command), 74 | path, 75 | )) 76 | 77 | simplifile.set_permissions( 78 | path, 79 | simplifile.FilePermissions(user: all, group: all, other: all), 80 | ) 81 | |> result.replace(command) 82 | |> as_fs_err(path) 83 | } 84 | 85 | pub fn init( 86 | hooks_dir: String, 87 | path: String, 88 | windows: Bool, 89 | ) -> Result(List(String), CactusErr) { 90 | { 91 | use manifest <- try(parse_gleam_toml(path)) 92 | use action_body <- result.map( 93 | as_invalid_field_err(tom.get_table(manifest, [cactus])), 94 | ) 95 | 96 | action_body 97 | |> dict.keys() 98 | |> list.filter(is_valid_hook_name) 99 | |> list.map(create_script(hooks_dir, _, windows)) 100 | |> result.all() 101 | } 102 | |> result.flatten() 103 | } 104 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | 9 | env: 10 | otp: "28.0" 11 | gleam: "1.12.0" 12 | rebar: "3" 13 | nodelts: "22.x" 14 | 15 | jobs: 16 | build: 17 | runs-on: ubuntu-latest 18 | steps: 19 | - uses: actions/checkout@v4 20 | - uses: erlef/setup-beam@v1 21 | with: 22 | otp-version: ${{ env.otp }} 23 | gleam-version: ${{ env.gleam }} 24 | rebar3-version: ${{ env.rebar }} 25 | - run: gleam format --check src test 26 | - run: gleam check 27 | - run: gleam build 28 | - run: ./scripts/update.sh 29 | 30 | erlang: 31 | runs-on: ubuntu-latest 32 | steps: 33 | - uses: actions/checkout@v4 34 | - uses: erlef/setup-beam@v1 35 | with: 36 | otp-version: ${{ env.otp }} 37 | gleam-version: ${{ env.gleam }} 38 | rebar3-version: ${{ env.rebar }} 39 | - run: ./scripts/target_test.sh erlang 40 | 41 | windows-erlang: 42 | runs-on: windows-latest 43 | steps: 44 | - uses: actions/checkout@v4 45 | - uses: erlef/setup-beam@v1 46 | with: 47 | otp-version: ${{ env.otp }} 48 | gleam-version: ${{ env.gleam }} 49 | rebar3-version: ${{ env.rebar }} 50 | - run: git.exe config --global user.email "cactus-windows-erlang-test@example.com" 51 | - run: git.exe config --global user.name "cactus-windows erlang-test" 52 | - run: gleam.exe run --target erlang 53 | - run: gleam.exe test --target erlang 54 | - run: git.exe checkout -b test-erlang-windows 55 | - run: git.exe push --dry-run --set-upstream origin test-erlang-windows 56 | 57 | windows-node: 58 | runs-on: windows-latest 59 | steps: 60 | - uses: actions/checkout@v4 61 | - uses: erlef/setup-beam@v1 62 | with: 63 | otp-version: ${{ env.otp }} 64 | gleam-version: ${{ env.gleam }} 65 | rebar3-version: ${{ env.rebar }} 66 | - uses: actions/setup-node@v4 67 | with: 68 | node-version: ${{ env.nodelts }} 69 | cache: "npm" 70 | - run: yarn install 71 | - run: git.exe config --global user.email "cactus-windows-node-test@example.com" 72 | - run: git.exe config --global user.name "cactus-windows node-test" 73 | - run: gleam.exe run --target javascript --runtime nodejs 74 | - run: gleam.exe test --target javascript --runtime nodejs 75 | - run: git.exe checkout -b test-node-windows 76 | - run: git.exe push --dry-run --set-upstream origin test-node-windows 77 | 78 | node: 79 | runs-on: ubuntu-latest 80 | strategy: 81 | matrix: 82 | node-version: [22.x, 24.x] 83 | steps: 84 | - uses: actions/checkout@v4 85 | - uses: erlef/setup-beam@v1 86 | with: 87 | otp-version: ${{ env.otp }} 88 | gleam-version: ${{ env.gleam }} 89 | rebar3-version: ${{ env.rebar }} 90 | - name: Use Node.js ${{ matrix.node-version }} 91 | uses: actions/setup-node@v4 92 | with: 93 | node-version: ${{ matrix.node-version }} 94 | cache: "npm" 95 | - run: yarn install 96 | - run: ./scripts/target_test.sh javascript nodejs 97 | 98 | bun: 99 | runs-on: ubuntu-latest 100 | steps: 101 | - uses: actions/checkout@v4 102 | - uses: erlef/setup-beam@v1 103 | with: 104 | otp-version: ${{ env.otp }} 105 | gleam-version: ${{ env.gleam }} 106 | rebar3-version: ${{ env.rebar }} 107 | - uses: oven-sh/setup-bun@v2 108 | - run: bun install 109 | - run: ./scripts/target_test.sh javascript bun 110 | 111 | deno: 112 | runs-on: ubuntu-latest 113 | steps: 114 | - uses: actions/checkout@v4 115 | - uses: erlef/setup-beam@v1 116 | with: 117 | otp-version: ${{ env.otp }} 118 | gleam-version: ${{ env.gleam }} 119 | rebar3-version: ${{ env.rebar }} 120 | - uses: denoland/setup-deno@v2 121 | with: 122 | deno-version: v2.x # Run with latest stable Deno. 123 | - run: ./scripts/target_test.sh javascript deno 124 | -------------------------------------------------------------------------------- /src/cactus/util.gleam: -------------------------------------------------------------------------------- 1 | import gleam/dict.{type Dict} 2 | import gleam/io 3 | import gleam/result.{replace_error} 4 | import gleam/string 5 | import gleither.{type Either, Left, Right} 6 | import gxyz/list.{reject} 7 | import shellout 8 | import simplifile.{type FileError, describe_error} 9 | import tom.{type GetError, type Toml, NotFound, WrongType} 10 | 11 | pub const cactus = "cactus" 12 | 13 | pub type CactusErr { 14 | InvalidFieldErr(field: String, err: Either(GetError, String)) 15 | InvalidTomlErr 16 | ActionFailedErr(output: String) 17 | FSErr(path: String, err: FileError) 18 | CLIErr(arg: String) 19 | GitError(command: String, err: String) 20 | NoErr 21 | } 22 | 23 | pub fn as_err(res: Result(a, b), err: CactusErr) -> Result(a, CactusErr) { 24 | replace_error(res, err) 25 | } 26 | 27 | pub fn as_invalid_field_err(res: Result(a, GetError)) -> Result(a, CactusErr) { 28 | case res { 29 | Ok(_) -> as_err(res, NoErr) 30 | Error(get_error) -> as_err(res, InvalidFieldErr("", Left(get_error))) 31 | } 32 | } 33 | 34 | pub fn as_git_error( 35 | res: Result(a, #(Int, String)), 36 | command: String, 37 | ) -> Result(a, CactusErr) { 38 | case res { 39 | Ok(_) -> as_err(res, NoErr) 40 | Error(#(_, output)) -> as_err(res, GitError(command, output)) 41 | } 42 | } 43 | 44 | pub fn as_fs_err( 45 | res: Result(a, FileError), 46 | path: String, 47 | ) -> Result(a, CactusErr) { 48 | case res { 49 | Ok(_) -> as_err(res, NoErr) 50 | Error(file_error) -> as_err(res, FSErr(path, file_error)) 51 | } 52 | } 53 | 54 | pub fn err_as_str(err: CactusErr) -> String { 55 | case err { 56 | InvalidFieldErr(_, Left(NotFound(keys))) -> 57 | "Missing field in config: " <> quote(string.join(keys, ".")) 58 | 59 | InvalidFieldErr(_, Left(WrongType(keys, expected, got))) -> 60 | join_text([ 61 | "Invalid field in config:", 62 | quote(string.join(keys, ".")), 63 | "expected:", 64 | quote(expected), 65 | "got:", 66 | quote(got), 67 | ]) 68 | 69 | InvalidFieldErr("", Right(err)) -> "Invalid field in config: " <> err 70 | 71 | InvalidFieldErr(field, Right(err)) -> 72 | join_text(["Invalid field in config:", field, err]) 73 | 74 | InvalidTomlErr -> "Invalid Toml Error" 75 | 76 | ActionFailedErr(output) -> "Action Failed Error:\n" <> output 77 | 78 | FSErr(path, err) -> 79 | join_text(["FS Error at", path, "with", describe_error(err)]) 80 | 81 | CLIErr(arg) -> "CLI Error: invalid arg " <> quote(arg) 82 | 83 | GitError(command, err) -> 84 | "Error while running " <> quote(command) <> " : " <> quote(err) 85 | 86 | NoErr -> panic as "how?" 87 | } 88 | } 89 | 90 | pub fn drop_empty(l: List(String)) -> List(String) { 91 | reject(l, string.is_empty) 92 | } 93 | 94 | pub fn quote(str: String) -> String { 95 | "'" <> str <> "'" 96 | } 97 | 98 | pub fn parse_gleam_toml(path: String) -> Result(Dict(String, Toml), CactusErr) { 99 | use body <- result.try(as_fs_err(simplifile.read(path), path)) 100 | body 101 | |> string.replace("\r\n", "\n") 102 | |> tom.parse() 103 | |> as_err(InvalidTomlErr) 104 | } 105 | 106 | pub fn parse_always_init(path: String) { 107 | parse_gleam_toml(path) 108 | |> result.try(fn(t) { 109 | t 110 | |> tom.get_bool(["cactus", "always_init"]) 111 | |> as_invalid_field_err 112 | }) 113 | |> result.unwrap(False) 114 | } 115 | 116 | pub fn join_text(text: List(String)) -> String { 117 | string.join(text, " ") 118 | } 119 | 120 | pub fn print_progress(msg: String) { 121 | shellout.style(msg, with: shellout.color(["brightmagenta"]), custom: []) 122 | |> io.println() 123 | } 124 | 125 | pub fn print_warning(msg: String) { 126 | shellout.style(msg <> "\n", with: shellout.color(["red"]), custom: []) 127 | |> io.println() 128 | } 129 | 130 | pub fn format_success(msg: String) -> String { 131 | shellout.style(msg, with: shellout.color(["brightgreen"]), custom: []) 132 | } 133 | 134 | pub fn print_success(msg: String) { 135 | format_success(msg <> "\n") 136 | |> io.println() 137 | } 138 | 139 | pub fn format_info(msg: String) { 140 | shellout.style(msg, with: shellout.color(["yellow"]), custom: []) 141 | } 142 | 143 | pub fn print_info(msg: String) { 144 | format_info(msg <> "\n") 145 | |> io.println() 146 | } 147 | -------------------------------------------------------------------------------- /src/cactus/run.gleam: -------------------------------------------------------------------------------- 1 | import cactus/git 2 | import cactus/modified 3 | import cactus/util.{ 4 | type CactusErr, ActionFailedErr, InvalidFieldErr, as_invalid_field_err, cactus, 5 | join_text, parse_gleam_toml, print_progress, quote, 6 | } 7 | import gleam/dict.{type Dict} 8 | import gleam/io 9 | import gleam/list 10 | import gleam/result.{try} 11 | import gleam/string 12 | import gleither.{Right} 13 | import shellout 14 | import tom.{type Toml} 15 | 16 | const actions = "actions" 17 | 18 | const gleam = "gleam" 19 | 20 | fn do_parse_kind(kind: String) -> Result(ActionKind, CactusErr) { 21 | case kind { 22 | "module" -> Ok(Module) 23 | "sub_command" -> Ok(SubCommand) 24 | "binary" -> Ok(Binary) 25 | _ -> 26 | Error(InvalidFieldErr( 27 | "kind", 28 | Right( 29 | join_text([ 30 | "got:", 31 | quote(kind), 32 | "expected: one of ['sub_command', 'binary', or 'module']", 33 | ]), 34 | ), 35 | )) 36 | } 37 | } 38 | 39 | fn do_parse_action(t: Dict(String, Toml)) -> Result(Action, CactusErr) { 40 | let kind = 41 | tom.get_string(t, ["kind"]) 42 | |> result.map(string.lowercase) 43 | |> result.unwrap("module") 44 | 45 | use command <- try(as_invalid_field_err(tom.get_string(t, ["command"]))) 46 | use args <- try( 47 | tom.get_array(t, ["args"]) 48 | |> result.unwrap([]) 49 | |> list.map(as_string) 50 | |> result.all(), 51 | ) 52 | use files <- try( 53 | tom.get_array(t, ["files"]) 54 | |> result.unwrap([]) 55 | |> list.map(as_string) 56 | |> result.all(), 57 | ) 58 | use action_kind <- result.map(do_parse_kind(kind)) 59 | 60 | Action(command: command, kind: action_kind, args: args, files: files) 61 | } 62 | 63 | fn do_run(action: Action) { 64 | use run_action <- try(case list.is_empty(util.drop_empty(action.files)) { 65 | // if file no specific files watched we can just run 66 | True -> Ok(True) 67 | 68 | // only check for modified files if it's relevant to action 69 | _ -> { 70 | use modified_files <- try(modified.get_modified_files()) 71 | Ok(modified.modified_files_match(modified_files, action.files)) 72 | } 73 | }) 74 | 75 | case run_action { 76 | True -> { 77 | let #(bin, args) = case action.kind { 78 | Module -> #(gleam, ["run", "-m", action.command, "--", ..action.args]) 79 | SubCommand -> #(gleam, [action.command, ..action.args]) 80 | Binary -> #(action.command, action.args) 81 | } 82 | 83 | ["Running", quote(join_text([bin, ..args]))] 84 | |> join_text() 85 | |> print_progress() 86 | case shellout.command(run: bin, with: args, in: ".", opt: []) { 87 | Ok(res) -> { 88 | io.print(res) 89 | Ok(res) 90 | } 91 | Error(#(_, err)) -> Error(ActionFailedErr(err)) 92 | } 93 | } 94 | 95 | False -> Ok("") 96 | } 97 | } 98 | 99 | fn as_string(t: Toml) -> Result(String, CactusErr) { 100 | case t { 101 | tom.String(v) -> Ok(v) 102 | _ -> 103 | Error(InvalidFieldErr("args", Right("'args' was not a list of strings"))) 104 | } 105 | } 106 | 107 | pub type ActionKind { 108 | Module 109 | SubCommand 110 | Binary 111 | } 112 | 113 | pub type Action { 114 | Action( 115 | command: String, 116 | kind: ActionKind, 117 | args: List(String), 118 | files: List(String), 119 | ) 120 | } 121 | 122 | pub fn parse_action(raw: Toml) -> Result(Action, CactusErr) { 123 | case raw { 124 | tom.InlineTable(t) -> do_parse_action(t) 125 | 126 | _ -> 127 | Error(InvalidFieldErr( 128 | actions, 129 | Right("'actions' element was not an InlineTable"), 130 | )) 131 | } 132 | } 133 | 134 | pub fn get_actions( 135 | path: String, 136 | action: String, 137 | ) -> Result(List(Toml), CactusErr) { 138 | use manifest <- try(parse_gleam_toml(path)) 139 | use action_body <- try( 140 | as_invalid_field_err(tom.get_table(manifest, [cactus, action])), 141 | ) 142 | as_invalid_field_err(tom.get_array(action_body, [actions])) 143 | } 144 | 145 | pub fn run(path: String, action: String) -> Result(List(String), CactusErr) { 146 | let stash_res = case action { 147 | "pre-commit" -> git.stash_unstaged() |> result.replace(True) 148 | 149 | _ -> Ok(False) 150 | } 151 | 152 | let action_res = { 153 | use actions <- try(get_actions(path, action)) 154 | actions 155 | |> list.map(parse_action) 156 | |> list.map(result.try(_, do_run)) 157 | |> result.all() 158 | } 159 | 160 | case action, stash_res { 161 | "pre-commit", Ok(True) -> 162 | git.pop_stash() 163 | |> result.try(fn(_) { action_res }) 164 | 165 | _, _ -> action_res 166 | } 167 | } 168 | -------------------------------------------------------------------------------- /manifest.toml: -------------------------------------------------------------------------------- 1 | # This file was generated by Gleam 2 | # You typically do not need to edit this file 3 | 4 | packages = [ 5 | { name = "clip", version = "1.0.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "clip", source = "hex", outer_checksum = "669BEA23D067F89B274A0E869533D2EC7C0DB1B5E650A03439C4558749945E94" }, 6 | { name = "delay", version = "1.2.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "delay", source = "hex", outer_checksum = "7B5E8E358C075569323AE65EEB2AEDF1F93D21602A022EE104EA177E29694A28" }, 7 | { name = "directories", version = "1.2.0", build_tools = ["gleam"], requirements = ["envoy", "gleam_stdlib", "platform", "simplifile"], otp_app = "directories", source = "hex", outer_checksum = "D13090CFCDF6759B87217E8DDD73A75903A700148A82C1D33799F333E249BF9E" }, 8 | { name = "envoy", version = "1.0.2", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "envoy", source = "hex", outer_checksum = "95FD059345AA982E89A0B6E2A3BF1CF43E17A7048DCD85B5B65D3B9E4E39D359" }, 9 | { name = "filepath", version = "1.1.2", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "filepath", source = "hex", outer_checksum = "B06A9AF0BF10E51401D64B98E4B627F1D2E48C154967DA7AF4D0914780A6D40A" }, 10 | { name = "gleam_community_ansi", version = "1.4.3", build_tools = ["gleam"], requirements = ["gleam_community_colour", "gleam_regexp", "gleam_stdlib"], otp_app = "gleam_community_ansi", source = "hex", outer_checksum = "8A62AE9CC6EA65BEA630D95016D6C07E4F9973565FA3D0DE68DC4200D8E0DD27" }, 11 | { name = "gleam_community_colour", version = "2.0.2", build_tools = ["gleam"], requirements = ["gleam_json", "gleam_stdlib"], otp_app = "gleam_community_colour", source = "hex", outer_checksum = "E34DD2C896AC3792151EDA939DA435FF3B69922F33415ED3C4406C932FBE9634" }, 12 | { name = "gleam_erlang", version = "1.3.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_erlang", source = "hex", outer_checksum = "1124AD3AA21143E5AF0FC5CF3D9529F6DB8CA03E43A55711B60B6B7B3874375C" }, 13 | { name = "gleam_hexpm", version = "3.0.1", build_tools = ["gleam"], requirements = ["gleam_stdlib", "gleam_time"], otp_app = "gleam_hexpm", source = "hex", outer_checksum = "AAA7813FFD1F32B12C9C0BA5C0BA451324DAC16B7D76E0540EFA526B5208CDAB" }, 14 | { name = "gleam_http", version = "4.1.1", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_http", source = "hex", outer_checksum = "DD0271B32C356FB684EC7E9F48B1E835D0480168848581F68983C0CC371405D4" }, 15 | { 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" }, 16 | { name = "gleam_json", version = "3.0.2", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_json", source = "hex", outer_checksum = "874FA3C3BB6E22DD2BB111966BD40B3759E9094E05257899A7C08F5DE77EC049" }, 17 | { name = "gleam_regexp", version = "1.1.1", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_regexp", source = "hex", outer_checksum = "9C215C6CA84A5B35BB934A9B61A9A306EC743153BE2B0425A0D032E477B062A9" }, 18 | { name = "gleam_stdlib", version = "0.62.1", build_tools = ["gleam"], requirements = [], otp_app = "gleam_stdlib", source = "hex", outer_checksum = "0080706D3A5A9A36C40C68481D1D231D243AF602E6D2A2BE67BA8F8F4DFF45EC" }, 19 | { name = "gleam_time", version = "1.4.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_time", source = "hex", outer_checksum = "DCDDC040CE97DA3D2A925CDBBA08D8A78681139745754A83998641C8A3F6587E" }, 20 | { name = "gleamsver", version = "1.0.1", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleamsver", source = "hex", outer_checksum = "EA74FDC66BF15CB2CF4F8FF9B6FA01D511712EE2B1F4BE0371076ED3F685EEAE" }, 21 | { name = "glearray", version = "2.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "glearray", source = "hex", outer_checksum = "5E272F7CB278CC05A929C58DEB58F5D5AC6DB5B879A681E71138658D0061C38A" }, 22 | { name = "gleave", version = "1.0.0", build_tools = ["gleam"], requirements = [], otp_app = "gleave", source = "hex", outer_checksum = "EBEB0DF9C764A6CB22623FF6F03A0BC978D75225303F3BBDEEB705A2DD700D0D" }, 23 | { name = "gleeunit", version = "1.6.1", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleeunit", source = "hex", outer_checksum = "FDC68A8C492B1E9B429249062CD9BAC9B5538C6FBF584817205D0998C42E1DAC" }, 24 | { name = "gleither", version = "2.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleither", source = "hex", outer_checksum = "86606790899097D8588C0F70C654D9626C634AF14E18F618F4C351FEC6FDA773" }, 25 | { name = "go_over", version = "3.2.0", 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 = "28717E3F77CF85AF3ED4A8206ECAEDCBDBE008296E05B21BD93A40EA494B359B" }, 26 | { name = "gxyz", version = "0.6.1", build_tools = ["gleam"], requirements = ["gleam_stdlib", "gleave"], otp_app = "gxyz", source = "hex", outer_checksum = "44FD007EF457D51B9BB951C1FA447EE202A5A0D18F1C958E26CF8FE367AACA9E" }, 27 | { name = "platform", version = "1.0.0", build_tools = ["gleam"], requirements = [], otp_app = "platform", source = "hex", outer_checksum = "8339420A95AD89AAC0F82F4C3DB8DD401041742D6C3F46132A8739F6AEB75391" }, 28 | { name = "repeatedly", version = "2.1.2", build_tools = ["gleam"], requirements = [], otp_app = "repeatedly", source = "hex", outer_checksum = "93AE1938DDE0DC0F7034F32C1BF0D4E89ACEBA82198A1FE21F604E849DA5F589" }, 29 | { name = "shellout", version = "1.7.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "shellout", source = "hex", outer_checksum = "1BDC03438FEB97A6AF3E396F4ABEB32BECF20DF2452EC9A8C0ACEB7BDDF70B14" }, 30 | { name = "simplifile", version = "2.3.0", build_tools = ["gleam"], requirements = ["filepath", "gleam_stdlib"], otp_app = "simplifile", source = "hex", outer_checksum = "0A868DAC6063D9E983477981839810DC2E553285AB4588B87E3E9C96A7FB4CB4" }, 31 | { 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" }, 32 | { name = "tom", version = "2.0.0", build_tools = ["gleam"], requirements = ["gleam_stdlib", "gleam_time"], otp_app = "tom", source = "hex", outer_checksum = "74D0C5A3761F7A7D06994755D4D5AD854122EF8E9F9F76A3E7547606D8C77091" }, 33 | { name = "yamerl", version = "0.10.0", build_tools = ["rebar3"], requirements = [], otp_app = "yamerl", source = "hex", outer_checksum = "346ADB2963F1051DC837A2364E4ACF6EB7D80097C0F53CBDC3046EC8EC4B4E6E" }, 34 | ] 35 | 36 | [requirements] 37 | filepath = { version = ">= 1.0.0 and < 2.0.0" } 38 | gleam_stdlib = { version = ">= 0.34.0 and < 2.0.0" } 39 | gleeunit = { version = ">= 1.0.0 and < 2.0.0" } 40 | gleither = { version = ">= 2.0.0 and < 3.0.0" } 41 | go_over = { version = ">= 3.0.0 and < 4.0.0" } 42 | gxyz = { version = ">= 0.3.0 and < 1.0.0" } 43 | platform = { version = ">= 1.0.0 and < 2.0.0" } 44 | shellout = { version = ">= 1.6.0 and < 2.0.0" } 45 | simplifile = { version = ">= 2.0.1 and < 3.0.0" } 46 | tom = { version = ">= 2.0.0 and < 3.0.0" } 47 | --------------------------------------------------------------------------------