diff --git a/README.md b/README.md index 5b8652c..440c440 100644 --- a/README.md +++ b/README.md @@ -7,43 +7,65 @@ Read the full documentation at [freightapp.co](https://freightapp.co). -Freight streamlines Git workflows by rewiring every Git hook in your repository to a single **Conductor** binary. All logic is defined in a declarative **Railcar** manifest (`railcar.json`), ensuring your hooks are portable, fast, and easy to manage. +Freight streamlines Git workflows by rewiring every Git hook in your repository to a single **Conductor** binary. All +logic is defined in a declarative **Railcar** manifest (`railcar.json`), ensuring your hooks are portable, fast, and +easy to manage. ## Why Freight? ### πŸš€ Zero Runtime Dependencies -Unlike Husky (which requires Node.js) or pre-commit (which requires Python), Freight is a single, static Go binary. Your developers don't need to install a specific runtime just to run Git hooks. + +Unlike Husky (which requires Node.js) or pre-commit (which requires Python), Freight is a single, static Go binary. Your +developers don't need to install a specific runtime just to run Git hooks. ### πŸ“¦ Unified Configuration -Manage every hookβ€”from `pre-commit` to `post-merge`β€”in one `railcar.json` manifest. No more messy `.git/hooks` directory filled with ad-hoc scripts. + +Manage every hookβ€”from `pre-commit` to `post-merge`β€”in one `railcar.json` manifest. No more messy `.git/hooks` directory +filled with ad-hoc scripts. ### πŸ› οΈ Built for Portability + Freight's 'Conductor/Railcar' architecture ensures that your hooks work identically across Windows, macOS, and Linux. ### πŸ₯Š Freight vs. Husky -| Feature | Freight | Husky | -|---------|---------|-------| -| **Runtime** | None (Static Binary) | Node.js | -| **Setup** | `freight init` | `npm install` | -| **Config** | Single JSON file | Multiple files/package.json | -| **Portability** | High (Binary included) | Moderate (Requires Node) | + +| Feature | Freight | Husky | +|-----------------|------------------------|-----------------------------| +| **Runtime** | None (Static Binary) | Node.js | +| **Setup** | `freight init` | `npm install` | +| **Config** | Single JSON file | Multiple files/package.json | +| **Portability** | High (Binary included) | Moderate (Requires Node) | --- ## Quick Start ### 1. Install + - **Homebrew (macOS):** `brew install --cask devbytes-cloud/tap/freight` -- **Precompiled Binaries:** `[GitHub Releases](https://github.com/devbytes-cloud/freight/releases)` +- **Precompiled Binaries:** [GitHub Releases](https://github.com/devbytes-cloud/freight/releases) ### 2. Setup + Run the following command in your Git repository: + ```bash freight init ``` + This installs the **Conductor** binary and creates a starter **Railcar** manifest (`railcar.json`). +By default, Freight installs all supported Git hooks. You can use the `--allow` (or `-a`) flag to specify only the hooks +you want: + +```bash +freight init --allow pre-commit,commit-msg +``` + +Valid hooks are: `pre-commit`, `prepare-commit-msg`, `commit-msg`, `post-commit`, and `post-checkout`. + ### 3. Verify + Add a script to your `railcar.json` and watch it run on your next commit! --- @@ -51,7 +73,9 @@ Add a script to your `railcar.json` and watch it run on your next commit! ## Architecture: Conductor & Railcar Freight operates on a simple, powerful metaphor: -- **The Conductor:** A tiny, high-performance binary placed at your repo root. It is the single entry point for all Git hooks. + +- **The Conductor:** A tiny, high-performance binary placed at your repo root. It is the single entry point for all Git + hooks. - **The Railcar:** A `railcar.json` manifest that defines exactly what the Conductor should execute for each hook. When a Git hook fires, the Conductor extracts the logic from the Railcar and executes it with precision. diff --git a/docs/readme.md b/docs/readme.md deleted file mode 100644 index 2853221..0000000 --- a/docs/readme.md +++ /dev/null @@ -1,240 +0,0 @@ -# Freight - -## Project Overview - -Freight is a Go-based CLI tool that streamlines Git workflows by rewiring every Git hook in your repository to call a -single binary named `conductor` (placed at the repo root).`conductor` reads a JSON configuration file (`railcar.json`) -that lists the shell commands you want to run for each -hook. All logic therefore lives in one easy-to-version file instead of a dozen ad-hoc hook scripts. - ---- - -## Features - -* **One-step hook bootstrap** – `freight init` installs `conductor`, generates `railcar.json`, and rewrites every Git - hook in one go. -* **Declarative configuration** – add, remove, or reorder hook commands by editing JSON. -* **Cross-platform binaries** – pre-built for Linux, macOS, and Windows. -* **Positional-argument support** – hooks such as `commit-msg` or `pre-push` automatically pass their arguments to your - commands (via `${HOOK_INPUT}`). -* **Zero vendor lock-in** – all generated files live inside your repo; deleting them restores the default Git behaviour. - ---- - -## Installation - -### From Source - -``` -git clone https://github.com/devbytes-cloud/freight.git -cd freight -go mod tidy -make build-all -``` - ---- - -## Quick Start - -``` -# Inside any existing Git repository -freight init # installs conductor + railcar.json + rewired hooks -git add . && git commit -m "test" # your new hooks will now fire -``` - -Need to overwrite an existing `railcar.json`? -`freight init --config-force` - ---- - -## Command-line Reference - -| Command | Description | -|----------------|---------------------------------------------| -| `freight init` | Bootstrap Freight in the current repository | -| `freight help` | Show global or command-specific help | - -Global flags: - -* `-c, --config-force` – overwrite an existing `railcar.json` -* `-h, --help` – display help - ---- - -## How It Works (under the hood) - -1. **Bootstrap (`freight init`)** - * Places a self-contained `conductor` binary at your repo root. - * Generates a starter `railcar.json`. - * Replaces every file in `.git/hooks/` with a tiny wrapper script that simply executes `conductor` with the hook - name and original arguments. - -2. **Hook trigger** – Git fires `pre-commit`, `commit-msg`, etc. - * The wrapper calls `conductor`. - * `conductor` loads `railcar.json`, finds the matching section, and runs each configured action. - * Any non-zero exit in a pre-hook aborts the Git operation. - ---- - -## `railcar.json` Syntax - -Hierarchical structure - -* **config** – top level -* **\** – e.g. `commit-operations`, `checkout-operations` -* **\** – e.g. `pre-commit`, `commit-msg`, `post-checkout` -* **actions array** – each item needs: - * `name` – label for readability - * `command` – shell snippet to run - -Example starter file: - -``` -{ - "config": { - "commit-operations": { - "pre-commit": [ - { "name": "echo", "command": "echo conductor is running!" } - ], - "prepare-commit-msg": [], - "commit-msg": [], - "post-commit": [] - }, - "checkout-operations": { - "post-checkout": [] - } - } -} -``` - -### Referencing Hook Arguments - -Hooks that receive parameters expose them in two interchangeable ways: - -| Placeholder | Meaning (example: `commit-msg`) | -|-----------------|---------------------------------| -| `${HOOK_INPUT}` | Alias for the parameter (`$1`) | - -Use whichever style you prefer: - -``` -{ - "commit-msg": [ - { "name": "validate", "command": "grep -E '^(feat|fix): ' ${HOOK_INPUT}" }, - { "name": "print", "command": "echo 'MSG file β†’ ${HOOK_INPUT}'" } - ] -} -``` - -### Real-world Examples - -* Run tests before committing - -``` -{ - "pre-commit": [ - { "name": "tests", "command": "go test ./..." } - ] - } -``` - -* Enforce Conventional Commits format - -``` -{ - "commit-msg": [ - { "name": "conventional", "command": "npx commitlint --edit $1" } - ] - } -``` - -* Verify tags before pushing - -``` -{ - "pre-push": [ - { "name": "verify-tags", "command": "./scripts/check_tags.sh $@" } - ] - } -``` - -Notes - -* **Order** – actions execute sequentially in array order. -* **Shell chaining** – combine commands (`go vet ./... && go test ./...`). -* **Environment vars** – standard shell expansion works (`FOO=bar ./script.sh`). -* **Idempotency** – `freight init` never overwrites `railcar.json` unless `--config-force` is supplied. - ---- - -## Supported Git Hooks & Execution Order - -``` -Commit : pre-commit β†’ prepare-commit-msg β†’ commit-msg β†’ post-commit -Merge : pre-merge-commit β†’ post-merge -Rebase : pre-rebase β†’ post-rewrite -Push : pre-push β†’ update β†’ post-update β†’ post-receive -Checkout : pre-checkout β†’ post-checkout -ApplyPatch : applypatch-msg β†’ pre-applypatch β†’ post-applypatch -``` - ---- - -## Troubleshooting - -| Issue | Fix | -|------------------------------|-----------------------------------------------------------------------------------| -| Permission denied on hooks | `chmod +x ./conductor` (and ensure hooks are executable) | -| Hook seems to do nothing | Check `.git/hooks/` – it should contain the wrapper that calls `conductor`. | -| Command not found | Ensure the command exists in `$PATH` or use an absolute path in `railcar.json`. | -| Need to debug a failing hook | Run the failing hook script manually or add `set -x` inside your action command. | - ---- - -## Contributing - -``` -git clone https://github.com/yourusername/freight.git -git checkout -b my-feature -# make changes -git commit -s -m "feat: awesome contribution" -git push origin my-feature -``` - -Open a Pull Requestβ€”thank you! - -### Build & Release - -### 1. Release & Distribution (GoReleaser + ) `go:embed` - -- The repository contains a that builds **platform-specific `conductor` binaries** for Linux, macOS and Windows (amd64, - arm, arm64). `.goreleaser.yaml` -- These binaries are dropped into `assets/dist/` and then **embedded directly into the main `freight` executable** via - Go’s mechanism (). `//go:embed``assets/embed.go` -- At runtime, `freight init` extracts the correct pre-built `conductor` for the user’s OS/CPU. -- CGO is disabled () so binaries are fully statically linked and portable. `CGO_ENABLED=0` - -### 2. Hook-Generation Template - -- Git hooks are produced from a **single script template** (`internal/githooks/gitHookTemplate`) so every generated hook - is tiny, consistent, and easy to audit. -- Unit tests in verify that every hook file is generated with the expected path and template. `githooks_test.go` - -A short note in the README can highlight the attention to reliability and test coverage. - -### 3. Testing - -- The project ships with a Go test suite () that covers hook generation, path handling, and validation helpers. - `go test ./...` -- Mentioning this encourages contributors to run tests before submitting pull requests. - -### 4. Make Targets - -- If you already have a , consider listing other useful targets (`make test`, `make lint`, etc.) so newcomers can find - them quickly. `make build-all` - ---- - -## License - -BSD-style. See `LICENSE` for full text. \ No newline at end of file diff --git a/internal/commands/root.go b/internal/commands/root.go index 81ba164..662cae3 100644 --- a/internal/commands/root.go +++ b/internal/commands/root.go @@ -5,6 +5,7 @@ import ( "errors" "fmt" "os" + "strings" "github.com/devbytes-cloud/freight/internal/blueprint" "github.com/devbytes-cloud/freight/internal/config" @@ -15,6 +16,14 @@ import ( "github.com/spf13/cobra" ) +var allowHooks = map[string]struct{}{ + "pre-commit": {}, + "prepare-commit-msg": {}, + "commit-msg": {}, + "post-commit": {}, + "post-checkout": {}, +} + // Execute runs the root command and handles any errors that occur during execution. func Execute() { if err := NewRootCmd().Execute(); err != nil { @@ -39,7 +48,20 @@ func NewRootCmd() *cobra.Command { if err := validate.GitDirs(); err != nil { cmd.PrintErrln(err) } - if err := setupHooks(); err != nil { + + userAllow, err := cmd.Flags().GetStringSlice("allow") + if err != nil { + cmd.PrintErrln(err) + os.Exit(1) + } + + validatedAllow, err := validateAllowHooks(userAllow) + if err != nil { + cmd.PrintErrln(err) + os.Exit(1) + } + + if err := setupHooks(validatedAllow); err != nil { cmd.PrintErrln(err) os.Exit(1) } @@ -59,6 +81,7 @@ func NewRootCmd() *cobra.Command { } initCmd.Flags().BoolP("config-force", "c", false, "If you wish to force write the config") + initCmd.Flags().StringSliceP("allow", "a", []string{}, "Specific Git hooks to install (default: all). Valid options: pre-commit, prepare-commit-msg, commit-msg, post-commit, post-checkout") rootCmd.AddCommand(initCmd) rootCmd.AddCommand(versionCommand()) @@ -66,27 +89,34 @@ func NewRootCmd() *cobra.Command { } // setupHooks initializes and writes the Git hooks. -func setupHooks() error { +func setupHooks(allowedHooks map[string]struct{}) error { pterm.DefaultSection.Println("Generating .git/hooks") + pterm.Debug.Printfln("Allowed hooks: %v", allowedHooks) pterm.Info.Println("Writing Commit Hooks") gitHooks := githooks.NewGitHooks() for _, v := range gitHooks.Commit { - if err := writeConfig(&v); err != nil { - pterm.Error.Println("βœ– Hook write failed for: ", v.Name, err.Error()) - return err + if _, ok := allowedHooks[v.Name]; ok { + if err := writeConfig(&v); err != nil { + pterm.Error.Println("βœ– Hook write failed for: ", v.Name, err.Error()) + return err + } + pterm.Success.Println("βœ” Hook written:", v.Name) + } else { + pterm.Warning.Println("Skipping hook:", v.Name, "not allowed") } - pterm.Success.Println("βœ” Hook written:", v.Name) - } - pterm.Info.Println("Writing Checkout Hooks") for _, v := range gitHooks.Checkout { - if err := writeConfig(&v); err != nil { - pterm.Error.Println("βœ– Hook write failed for: ", v.Name, err.Error()) - return err + if _, ok := allowedHooks[v.Name]; ok { + if err := writeConfig(&v); err != nil { + pterm.Error.Println("βœ– Hook write failed for: ", v.Name, err.Error()) + return err + } + pterm.Success.Println("βœ” Hook written:", v.Name) + } else { + pterm.Warning.Println("Skipping hook:", v.Name, "not allowed") } - pterm.Success.Println("βœ” Hook written:", v.Name) } return nil @@ -142,3 +172,26 @@ func installBinary() error { pterm.Success.Println("βœ” Installed conductor successfully") return nil } + +// validateAllowHooks validates the provided allow hooks and returns a map of valid hooks. +func validateAllowHooks(allow []string) (map[string]struct{}, error) { + if len(allow) == 0 { + pterm.Debug.Println("No hooks provided, using default allowed hooks") + return allowHooks, nil + } + + inputHooks := map[string]struct{}{} + var invalidHooks []string + for _, v := range allow { + if _, ok := allowHooks[v]; !ok { + invalidHooks = append(invalidHooks, v) + } + inputHooks[v] = struct{}{} + } + + if len(invalidHooks) > 0 { + return nil, fmt.Errorf("invalid hook types: %s", strings.Join(invalidHooks, ", ")) + } + + return inputHooks, nil +} diff --git a/internal/commands/root_test.go b/internal/commands/root_test.go new file mode 100644 index 0000000..800282b --- /dev/null +++ b/internal/commands/root_test.go @@ -0,0 +1,97 @@ +package commands + +import ( + "fmt" + "testing" + + "github.com/stretchr/testify/assert" +) + +type hookStructure struct { + inputHooks []string + expectedHooks map[string]struct{} + expectedErr bool + expectedErrMgs error +} + +func TestValidateAllowHooks(t *testing.T) { + testData := map[string]hookStructure{ + "no input hooks": { + expectedErr: false, + inputHooks: []string{}, + expectedHooks: map[string]struct{}{ + "pre-commit": {}, + "prepare-commit-msg": {}, + "commit-msg": {}, + "post-commit": {}, + "post-checkout": {}, + }, + }, + "only pre-commit hook": { + expectedErr: false, + inputHooks: []string{"pre-commit"}, + expectedHooks: map[string]struct{}{ + "pre-commit": {}, + }, + }, + "invalid hook name hook": { + expectedErr: true, + inputHooks: []string{"invalid hook name"}, + expectedErrMgs: fmt.Errorf("invalid hook types: invalid hook name"), + expectedHooks: nil, + }, + "multiple valid hooks": { + expectedErr: false, + inputHooks: []string{"pre-commit", "commit-msg", "post-checkout"}, + expectedHooks: map[string]struct{}{ + "pre-commit": {}, + "commit-msg": {}, + "post-checkout": {}, + }, + }, + "multiple invalid hooks": { + expectedErr: true, + inputHooks: []string{"invalid1", "invalid2"}, + expectedErrMgs: fmt.Errorf("invalid hook types: invalid1, invalid2"), + expectedHooks: nil, + }, + "mix of valid and invalid hooks": { + expectedErr: true, + inputHooks: []string{"pre-commit", "invalid-hook"}, + expectedErrMgs: fmt.Errorf("invalid hook types: invalid-hook"), + expectedHooks: nil, + }, + "all valid hooks explicitly provided": { + expectedErr: false, + inputHooks: []string{"pre-commit", "prepare-commit-msg", "commit-msg", "post-commit", "post-checkout"}, + expectedHooks: map[string]struct{}{ + "pre-commit": {}, + "prepare-commit-msg": {}, + "commit-msg": {}, + "post-commit": {}, + "post-checkout": {}, + }, + }, + "duplicate valid hooks": { + expectedErr: false, + inputHooks: []string{"pre-commit", "pre-commit"}, + expectedHooks: map[string]struct{}{ + "pre-commit": {}, + }, + }, + } + + for name, test := range testData { + t.Run(name, func(t *testing.T) { + resp, err := validateAllowHooks(test.inputHooks) + + if test.expectedErr { + assert.Error(t, err) + assert.EqualError(t, err, test.expectedErrMgs.Error()) + } else { + assert.NoError(t, err) + assert.Equal(t, test.expectedHooks, resp) + } + }) + } +} diff --git a/website/docs/cli/init.md b/website/docs/cli/init.md index 41daced..86187c4 100644 --- a/website/docs/cli/init.md +++ b/website/docs/cli/init.md @@ -14,13 +14,27 @@ The `init` command sets up Freight in your local repository. It ensures all nece ## Flags - `-c, --config-force`: Overwrite an existing `railcar.json` file if it already exists. +- `-a, --allow`: Specific Git hooks to install (default: all). Valid options: `pre-commit`, `prepare-commit-msg`, `commit-msg`, `post-commit`, `post-checkout`. + + When this flag is used, Freight will **only** rewire the hooks you explicitly specify. Any existing hooks in your `.git/hooks` directory that are NOT in the allow list will remain untouched. This is useful if you want to use Freight alongside other hook managers or if you only want to manage a subset of hooks with Freight. ## Examples +Basic initialization: ```bash freight init ``` +Initialize with specific hooks (comma-separated): +```bash +freight init --allow pre-commit,commit-msg +``` + +Initialize with specific hooks (multiple flags): +```bash +freight init -a pre-commit -a post-checkout +``` + **Output:** ```text βœ” Extracting conductor binary... diff --git a/website/docs/installation.md b/website/docs/installation.md index 4fd56db..09d3094 100644 --- a/website/docs/installation.md +++ b/website/docs/installation.md @@ -40,6 +40,13 @@ This command performs the following actions: - Generates a starter **Railcar** manifest (`railcar.json`). - Rewires your `.git/hooks` to point to the Conductor. +By default, Freight installs all supported Git hooks. You can use the `--allow` flag to specify only the hooks you want: +```bash +freight init --allow pre-commit,commit-msg +``` + +This is particularly useful for **incremental adoption**. If you already have a complex set of hooks and only want to move `pre-commit` to Freight for now, you can do so without affecting your other hooks. + :::tip Pro-Tip For total team portability, **commit the `conductor` binary** directly to your repository. This ensures that every team member (and your CI/CD pipeline) can execute hooks immediately without needing to install the `freight` CLI tool themselves. :::