Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

hawser

Pin a stack of Git repos to a lockfile — so a teammate, a CI runner, or an auditor checks out the identical tree, everywhere. One binary. In Rust.

haw composes many independent Git repos into one reproducible stack. A declarative manifest (haw.toml) describes stacks and the repos they compose; a committed lockfile (haw.lock) pins every repo to an exact revision — so any teammate or CI machine rebuilds the byte-identical tree. No submodules, no detached HEADs, no Python runtime — one static binary.

haw init haw.toml   # declare the repos
haw sync            # clone every repo, write haw.lock (exact SHAs)
haw verify          # CI gate: exit 3 if the tree drifts from the lock

This is the reference documentation for hawser 0.1.7. New to the project? Start with the Learn course for a guided, hands-on path; use the pages below as reference.

Beyond the core

Reproducible compose (above) is the foundation. Built on it, four more capabilities — one binary:

  • Compose, at scale. Stacks and overlays compose repos into named variants. Shallow (--depth) and partial (--filter=blob:none) clone, plus a shared object store via git alternates (--shared) — no symlinks, so it runs on Windows.
  • Orchestrate. Run build, test, or any command across the whole fleet in parallel (-j N); haw grep fans out across every repo; haw verify is a drift gate that exits 3 for CI.
  • Collaborate. One feature = one branch across N repos, with cross-linked PR/MRs on GitHub, GitLab, and Bitbucket, an aggregated review + CI status, and land to merge in dependency order. Plus a parallel collaborative merge (planresolvecleanup).
  • Operate. A k9s-style TUI cockpit (bare haw) — a live fleet grid you drill into for a repo’s diff, a PR’s checks, or a CI run’s live progress, then act from the keyboard. Fuzzy filter, marks + bulk actions, a problems-only view, drift highlights, a command bar, and six themes.
  • Govern. SBOM (CycloneDX + SPDX), SLSA/in-toto provenance, cosign/minisign signatures, lifecycle hooks, a secret/hygiene gate, and evidence bundles for qualification.

Extend it

haw <name> runs haw-<name> from your PATH — extend the CLI without forking. Plugins are subprocesses speaking a JSON contract (haw.plugin/1 in, haw.plugin.report/1 out), so they can be written in Rust, Python, Go, or shell. Scaffold, discover, and install them:

haw plugins new my-check --lang python   # runnable skeleton
haw plugins list --remote                # discover community plugins from the index
haw plugins install aspice               # install a first-party or community plugin

Published JSON Schemas and thin bindings (Python, Go) make it trivial; see the curated AWESOME-HAW-PLUGINS list.

Distribute & install

haw ships as a single static binary. Install via crates.io, Homebrew, Scoop, AUR, Nix, .deb/.rpm, Docker, or a signed prebuilt archive — every release carries a .sha256 checksum and a keyless cosign signature. Fleet artifacts publish to private registries (Nexus, Artifactory, GitLab, Bitbucket) via haw publish.

cargo install hawser         # from crates.io (canonical)
brew install nastwinns/tap/hawser   # macOS / Linux (Homebrew)
curl -L … | tar xz           # prebuilt static musl binary

Security

haw runs code declared in the manifest and binaries on your PATH — treat both as trusted inputs. The crate is #![forbid(unsafe_code)], HTTPS is rustls-only, actions are SHA-pinned, releases are cosign-signed, and cargo audit/cargo deny gate every push. Read the full trust model.

Try it in your browser

The landing page renders the live cockpit TUI in WebAssembly — no install required: https://nastwinns.github.io/hawser/

Where to go next

  • Learn — the guided, hands-on course from zero to a governed fleet.
  • Install — the full channel matrix + signature verification + air-gap.
  • Distribution — mirror releases to Nexus/Artifactory/GitLab/Bitbucket.
  • Domains — how the loop maps onto embedded, microservices, ML, infra, mobile.
  • Architecture — crate layout, concurrency model, forge abstraction.
  • CLI design & TUI keymap — the full verb lexicon and cockpit keymap.
  • Extending — plugins, hooks, auth, and CI/CD integration.
  • Plugins — writing and submitting haw-<name> plugins.
  • Compliance — tool qualification, SBOM/CRA, signing, GDPR.
  • Security — the trust model: what haw executes, plugin trust, tokens.

Installing hawser

hawser ships as a single binary named haw. This page is the full install matrix: every channel, how to verify the signed release, the air-gap workflow, and building from source. For the short version, see the README Install section.

The current release is v0.1.7, published with signed, reproducible archives for every supported platform.

Channel matrix

ChannelPlatformCommand / sourcePrerequisites
crates.ioany (Rust)cargo install hawserRust 1.90+ toolchain
HomebrewmacOS + Linuxbrew install nastwinns/tap/hawserHomebrew
ScoopWindowsscoop bucket add nastwinns https://github.com/Nastwinns/scoop-bucket then scoop install hawserScoop
Static musl binaryLinux x86_64download haw-0.1.7-x86_64-unknown-linux-musl.tar.gz (see below)none (zero-dependency)
Prebuilt archiveLinux gnu (x86_64/aarch64), Linux musl (x86_64), macOS (x86_64/aarch64), Windows (x86_64)GitHub Releasenone (optional: cosign, sha256sum to verify)
Private registriesanyNexus / Artifactory / GitLab / Bitbucket mirror — see DISTRIBUTION.mdregistry credentials
Dockerany (with Docker)docker build -t haw .Docker + the repo
From sourceany (Rust)cargo install --git … or cargo build --releaseRust 1.90+ toolchain

All channels install the same haw binary. cargo install hawser is the canonical Rust install.

Package managers

crates.io (Rust)

cargo install hawser

Builds from source against your local toolchain and drops haw into ~/.cargo/bin. Requires a Rust 1.90+ toolchain.

Homebrew (macOS + Linux)

brew install nastwinns/tap/hawser

The tap lives at Nastwinns/homebrew-tap. Homebrew pulls the prebuilt archive for your platform, so no compiler is needed.

Scoop (Windows)

scoop bucket add nastwinns https://github.com/Nastwinns/scoop-bucket
scoop install hawser

AUR (Arch Linux)

A hawser-bin package (prebuilt from the GitHub Release) — see packaging/aur/PKGBUILD:

yay -S hawser-bin            # or: paru -S hawser-bin

Nix (flake)

Run without installing, or add to a profile — the flake builds haw from source and wraps it with git:

nix run github:Nastwinns/hawser              # run once
nix profile install github:Nastwinns/hawser  # install

Debian / RPM

Each GitHub Release ships a .deb and .rpm for x86_64 Linux (gnu):

# Debian/Ubuntu
curl -sSLO https://github.com/Nastwinns/hawser/releases/latest/download/hawser_0.1.7-1_amd64.deb
sudo dpkg -i hawser_0.1.7-1_amd64.deb
# Fedora/RHEL
sudo rpm -i https://github.com/Nastwinns/hawser/releases/latest/download/hawser-0.1.7-1.x86_64.rpm

Static musl binary (Linux, zero-dependency, air-gap friendly)

The recommended universal Linux install. The musl build is fully static — no glibc, no runtime — so it runs identically on any Linux host, drops into minimal containers, and installs cleanly on air-gapped machines as a single file.

curl -sSL https://github.com/Nastwinns/hawser/releases/download/v0.1.7/haw-0.1.7-x86_64-unknown-linux-musl.tar.gz \
  | tar xz && sudo install haw /usr/local/bin/

For air-gapped hosts, download the archive (plus its .sha256, .sig, and .pem) on a connected machine, verify it (below), copy all four files across, then install.

Prebuilt archives (signed)

Every platform ships an archive on the GitHub Release:

  • haw-0.1.7-x86_64-unknown-linux-gnu.tar.gz
  • haw-0.1.7-aarch64-unknown-linux-gnu.tar.gz
  • haw-0.1.7-x86_64-unknown-linux-musl.tar.gz (static)
  • haw-0.1.7-x86_64-apple-darwin.tar.gz
  • haw-0.1.7-aarch64-apple-darwin.tar.gz
  • haw-0.1.7-x86_64-pc-windows-msvc.zip

Each archive is accompanied by:

  • <archive>.sha256 — a SHA-256 checksum
  • <archive>.sig and <archive>.pem — a cosign keyless signature and its certificate

The release is reproducible and signed. Verifying is optional but recommended, and it is the whole point on locked-down or air-gapped hosts.

Verify the checksum

sha256sum -c haw-0.1.7-x86_64-unknown-linux-musl.tar.gz.sha256

Expect … OK. (On macOS, shasum -a 256 -c is the equivalent.)

Verify the cosign signature

Keyless verification checks the signature against the Sigstore transparency log. You need cosign installed:

cosign verify-blob \
  --certificate haw-0.1.7-x86_64-unknown-linux-musl.tar.gz.pem \
  --signature   haw-0.1.7-x86_64-unknown-linux-musl.tar.gz.sig \
  --certificate-identity-regexp 'https://github.com/Nastwinns/hawser' \
  --certificate-oidc-issuer https://token.actions.githubusercontent.com \
  haw-0.1.7-x86_64-unknown-linux-musl.tar.gz

Expect Verified OK. Once verified, unpack and install:

tar xzf haw-0.1.7-x86_64-unknown-linux-musl.tar.gz
sudo install haw /usr/local/bin/

Air-gap workflow

  1. On a connected machine, download the archive and its .sha256, .sig, and .pem companions.
  2. Verify the checksum and the cosign signature (above) — this establishes trust while you still have network access to the transparency log.
  3. Copy all four files to the air-gapped host.
  4. Verify the checksum again offline (sha256sum -c …), unpack, and install.

The static musl binary has no runtime dependencies, so nothing else needs to cross the air gap.

Private registries (Nexus / Artifactory / GitLab / Bitbucket)

Organizations that mirror releases to an internal registry can pull the exact same signed archives (plus .sha256, .sig, .pem, and the .deb/.rpm) from Nexus, Artifactory, GitLab, or Bitbucket. Each tagged release publishes the GitHub Release first, then mirrors the artifacts to whichever of these registries are configured.

See DISTRIBUTION.md for the exact upload paths, the secret matrix to enable each registry, and the per-registry download/install commands. Example (Nexus):

curl -u "$NEXUS_USER:$NEXUS_PASS" -O \
  "$NEXUS_URL/repository/raw-hosted/haw/0.1.7/haw-0.1.7-x86_64-unknown-linux-musl.tar.gz"

Verify the checksum and cosign signature exactly as for the GitHub Release (above).

Docker

An image builds directly from the repository Dockerfile:

docker build -t haw .
docker run --rm haw --version

Requires Docker and a checkout of the repo.

From source

Requires a Rust 1.90+ toolchain.

Install the latest main straight from Git:

cargo install --git https://github.com/Nastwinns/hawser hawser

Or clone and build a release binary:

git clone https://github.com/Nastwinns/hawser
cd hawser
cargo build --release
# binary at target/release/haw

Verify the install

Whichever channel you used:

haw --version

Back to the README.

0. What is hawser?

hawser — the beam that binds the repos

Welcome! If you’ve ever worked on a product that lives in more than one Git repository, this course is for you. By the end of it you’ll be composing repos, running work across a whole fleet, opening cross-repo pull requests, and driving it all from a keyboard cockpit — comfortably.

Let’s start with the why. Because once the problem clicks, every command in haw suddenly makes sense.

Version control across many repositories

Many repos, one coordinated whole — that’s the puzzle hawser solves.

🎯 In this chapter, you’ll learn to…

  • Recognize the three taxes of a multi-repo product: version coordination, cross-repo PRs, and reproducibility.
  • Explain hawser in one line — a package.json + lockfile, but for a fleet of Git repos.
  • Hold the whole mental model in your head: manifest → lockfile → stacks.
  • Tell when hawser is the right tool for your world.

🧩 The problem: one product, many repos

Picture a real product. It’s rarely a single repository anymore. It’s a shared library, three or four services, an SDK, some infrastructure — each in its own Git repo, each with its own branches, its own CI, its own history.

A product split across many repositories

One product, scattered across a shelf of separate repos.

That split is good engineering. But it comes with a tax:

  • “Which versions go together?” Service A works with version 2.1 of the shared lib — but which commit is 2.1, exactly? And does your teammate have the same one?
  • “One feature, five pull requests.” A single change touches four repos. You branch each one by hand, open four PRs, and pray you merge them in the right order.
  • “It works on my machine.” Nobody can reproduce the exact set of commits that were live last March, because that set was never written down anywhere.
This is fine — a cartoon dog sipping coffee as the room burns

Managing ten repos and their versions by hand. This is fine. 🔥

Here’s the thing: a single repo already solved these problems years ago. Your package.json (or Cargo.toml) declares what you depend on, and a lockfile (package-lock.json, Cargo.lock) pins the exact resolved versions so everyone — you, your teammate, the CI runner — rebuilds an identical tree.

The mental model in one line: hawser is a package.json + lockfile, but for a fleet of Git repos instead of a fleet of npm packages.

⚓ What hawser does

hawser composes a software stack from many Git repos, pins it to a lockfile, and lets you drive every cross-repo build, PR, review, and CI run from one place.

It’s not a Git wrapper and it doesn’t reimplement Git. It’s the layer above Git — the part your package.json and your project board and your PR dashboard were quietly doing for a single repo, now done for many.

The command-line tool is called haw. It’s a single binary, written in Rust, with no runtime to install.

🧠 The mental model: manifest → lockfile → stacks

Three concepts carry the whole system. Learn these now and everything else follows.

  1. The manifest — haw.toml. This is your intent. You declare which repos exist, where they live, and which revision you want. Think of it as the dependencies block.

  2. The lockfile — haw.lock. This is the resolved reality. When you sync, haw pins every repo to an exact commit SHA and writes it here. You commit this file. Now a teammate — or CI, or an auditor — rebuilds the identical tree, byte for byte.

  3. Stacks. A stack is a named composition of repos. One manifest can define several stacks that share the same repos without copying them. (A stack in haw is just “these repos, together, under this name.”)

haw.toml   (intent)  ─────►  haw sync  ─────►  haw.lock   (pinned SHAs, committed)
   │                                               │
   └── declares repos + stacks                     └── the reproducible baseline

On disk there are no submodules, no symlinks, and no detached HEADs — each repo is a plain, complete Git clone. haw just keeps them in sync and coordinated.

🎯 When to reach for hawser

hawser is domain-agnostic — a repo is a repo, a build is whatever shell command you declare. It shines whenever a product is spread across repos:

If your world looks like……hawser gives you
Backend microservices — a feature spanning N services + a shared proto/libone branch + linked PRs across exactly the repos it touches
ML / data platforms — model + pipeline + serving infraone pinned, reproducible baseline of all three
Platform / infra — Terraform modules + Helm chartsa versioned, drift-checked deployed baseline
Mobile — an app + its in-house SDKapp and SDK changed and released in lockstep
Embedded / automotive — shared HAL/BSP reused across many ECUsaudit-grade, reproducible baselines + compliance evidence

The loop — compose → pin → change → build/test → govern — is the same in every one. Only the repos and the declared build commands differ. See Domains for how each maps on.

🚀 What you’ll be able to do by the end of this course

  • Write a haw.toml, sync it, and read the lockfile with confidence.
  • Run builds, tests, commands, and searches across an entire fleet in parallel.
  • Ship a feature across N repos as one changeset — branch, PR, land in order.
  • Live in the TUI cockpit and merge, approve, and inspect without leaving the terminal.
  • Extend haw with a plugin you wrote yourself.
  • Wire it into CI with reproducibility, signing, and audit evidence.

That’s a real, productive skill set — and we’ll build it one small step at a time.

And here’s where we’re going — the keyboard cockpit you’ll be living in by Chapter 4:

The haw cockpit: read the fleet, drill into any repo or PR, and act — without leaving the terminal.

🙌 Your turn

Before we touch a single command, do the thought experiment. Picture the last product you worked on that lived in more than one repo. Jot down:

  • How many repos was it, really?
  • The last time one feature forced you to open PRs in several of them at once — how did you keep track?
  • Could you reproduce, today, the exact set of commits that were live three months ago? Hold those answers. By Chapter 3 you’ll have a one-command answer to each.

✅ Recap

  • Splitting a product across many Git repos is normal, but it costs you version coordination, cross-repo PRs, and reproducibility.
  • hawser is a manifest + lockfile for a fleet of repos — like package.json + package-lock.json, but for whole repositories.
  • Three concepts: manifest (haw.toml, intent) → lockfile (haw.lock, pinned reality) → stacks (named compositions of repos).
  • The CLI is haw: one Rust binary, no runtime.
  • It fits any domain where a product spans repos.

👉 Next

Let’s get the tool onto your machine and open the cockpit for the first time → 1. Install and first run.

1. Install and first run

In this chapter you’ll get the haw binary onto your machine, confirm it works, turn on tab completion — and then take your very first haw run, watching the keyboard cockpit open. It’s short: we want you at a prompt fast, and we want you to see where the course is heading.

Setting up the haw tool on your machine

A one-time setup, then you’re at the prompt for the rest of the course.

🎯 In this chapter, you’ll learn to…

  • Install haw through the channel that fits your machine — Cargo, Homebrew, Scoop, or the static musl binary.
  • Confirm the binary is on your PATH and prints haw 0.1.7.
  • Turn on shell tab-completion so the shell fills in subcommands and flags as you learn.
  • Take a first haw run and watch the cockpit open — a taste of where you’re headed.

The tool ships as a single binary named haw. There’s no runtime, no interpreter, nothing to keep updated alongside it. The current release is v0.1.7.

📦 1. Install it

Pick the line that matches your setup — all of them install the same haw binary.

cargo install hawser                              # Rust / crates.io (canonical)
brew install nastwinns/tap/hawser                 # macOS + Linux (Homebrew)

On Windows, use Scoop:

scoop bucket add nastwinns https://github.com/Nastwinns/scoop-bucket
scoop install hawser

On a Linux server, container, or air-gapped host, the static musl binary is the easiest choice — it’s fully static (no glibc, no runtime), so one file just runs:

curl -sSL https://github.com/Nastwinns/hawser/releases/download/v0.1.7/haw-0.1.7-x86_64-unknown-linux-musl.tar.gz \
  | tar xz && sudo install haw /usr/local/bin/

Tip: cargo install hawser is the canonical Rust install. It builds from source and drops haw into ~/.cargo/bin — make sure that directory is on your PATH.

For every other channel (.deb/.rpm, AUR, Nix, Docker), plus signature verification and the full air-gap workflow, see Installing hawser.

✅ 2. Verify it works

Whatever channel you used, confirm the binary is on your PATH:

haw --version

You should see the version print:

haw 0.1.7

If you get “command not found”, the install directory isn’t on your PATH yet — for cargo, that’s ~/.cargo/bin. Fix that and re-run.

Now peek at the full command surface — you don’t need to read it all, just get a feel:

haw --help

You’ll see the subcommands we’ll cover: sync, status, tree, run, build, test, change, plugins, and more. Every one is a single guessable word.

A quick taste of haw at the command line — every subcommand is one guessable word.

⌨️ 3. Turn on shell completions

This is a small quality-of-life win that pays off all course long: press Tab and the shell fills in subcommands and flags for you.

haw completions <shell> prints a completion script to stdout. Redirect it to the right place for your shell:

haw completions zsh  > ~/.zfunc/_haw                 # zsh
haw completions bash > /etc/bash_completion.d/haw    # bash
haw completions fish > ~/.config/fish/completions/haw.fish   # fish

Tip: For zsh, make sure ~/.zfunc is on your $fpath (add fpath=(~/.zfunc $fpath) before compinit in your ~/.zshrc), then restart your shell. Now haw sy<Tab> completes to haw sync.

🚁 4. Your first run — open the cockpit

Here’s the reward. haw isn’t only a batch of subcommands: run it with no subcommand at all and it opens a full-screen, keyboard-driven cockpit for your fleet.

haw

You don’t have a workspace yet, so there’s nothing real to show. Good news: there’s a built-in demo controller, populated with canned repos, PRs, and CI runs, so every view has something in it — no network, no setup:

haw dash --demo

A live fleet grid fills the terminal. Move with / (or k/j, Vim-style), press Enter to drill into a repo, Esc to come back, ? for the help overlay, and q to quit.

The cockpit you just opened: the fleet grid, drill-downs, and keyboard actions — all in the terminal.

Just a taste for now. Poke around, then quit with q. We’ll give the cockpit a proper guided tour in Chapter 4 — first you need a real fleet to point it at, which is what the next three chapters build.

✅ Recap

  • haw is a single binary — install it with cargo, brew, scoop, or the static musl archive.
  • haw --version should print haw 0.1.7; haw --help lists every command.
  • haw completions <shell> gives you tab completion — set it up now, thank yourself later.
  • Bare haw (or haw dash --demo) opens the cockpit — your first glimpse of mission control, explored in depth in Chapter 4.
  • The full install matrix covers signed releases and air-gapped hosts.

🙌 Your turn

Two-minute checkpoint — prove your setup before moving on:

  • Run haw –version and confirm you see haw 0.1.7. If it says “command not found”, the install dir isn’t on your PATH yet — fix that first.
  • Set up completions for your shell, restart it, then type haw sy and press Tab. It should complete to haw sync.
  • Run haw dash –demo, move around with /, press Enter to drill into a repo and Esc to back out, then q to quit. You just met the cockpit.

👉 Next

Now let’s give haw something real to manage. First stop: the manifest — where you declare your fleet → 2. The manifest.

2. The manifest

Everything haw does starts from one file: haw.toml, the manifest. It’s your intent — which repos exist, where they live, which revision you want, and how they compose into stacks. In this chapter you’ll write a real one, top to bottom, and build the mental model. We won’t sync yet — that’s the next chapter. Here we get the declaration right first.

Composing a stack from building blocks

A stack is just building blocks — repos snapped together under one name, declared in the manifest.

🎯 In this chapter, you’ll learn to…

  • Declare a remote once and reuse it across repos.
  • Add repos, each with a rev (branch, tag, or SHA) and free-form groups.
  • Compose repos into named stacks — shared, never copied.
  • Reach for overlays when one repo needs per-variant overrides.
  • Read a whole haw.toml and know exactly what it will do.

🛠️ 1. Create a workspace

Make an empty directory and drop in a manifest. We’ll use small, real public repos from GitHub’s octocat account — the same ones the shipped examples/quickstart uses — so that next chapter you can actually haw sync this over HTTPS with no authentication.

mkdir my-first-stack && cd my-first-stack

Now create haw.toml with this content:

[remote.gh]
url = "https://github.com/octocat"
forge = "github"

[repo.hello-world]
remote = "gh"
repo = "Hello-World.git"
rev = "master"
groups = ["core"]

[repo.spoon-knife]
remote = "gh"
repo = "Spoon-Knife.git"
rev = "main"
groups = ["web"]

# `site` composes the two repos into one named stack.
[stack.site]
repos = ["hello-world", "spoon-knife"]
description = "A shared core repo plus the fork-demo front end."

Read that top to bottom, because it is the mental model. Let’s take it one block at a time.

🌐 2. The remote — name a base URL once

[remote.gh]
url = "https://github.com/octocat"
forge = "github"

A [remote.NAME] names a base URL once so you don’t repeat it on every repo. forge tells haw which API to speak later (for PRs, CI, and so on) — github, gitlab, or bitbucket. You can declare several remotes; a fleet can span forges, and later a changeset can open a PR on one and an MR on another from a single command.

📦 3. The repos — one declaration each

[repo.hello-world]
remote = "gh"
repo = "Hello-World.git"
rev = "master"
groups = ["core"]

Each [repo.NAME] declares one repository:

  • remote + repo — which remote it lives on and its path there. Combined, they resolve to https://github.com/octocat/Hello-World.git.
  • revthe revision you want: a branch (master), a tag (v6.1.2), or an exact SHA. haw auto-detects which kind it is. This is your intent — a moving branch until the lockfile freezes it (next chapter).
  • groups — free-form labels you’ll filter commands by later (e.g. haw status --group core). A repo can be in several groups.

rev is intent, not a pin. Writing rev = "master" means “track master.” Nothing is frozen until you sync and a lockfile records the exact commit — that’s the whole reproducibility story, and it’s the entire point of Chapter 3.

🧱 4. Stacks — compose repos under a name

[stack.site]
repos = ["hello-world", "spoon-knife"]
description = "A shared core repo plus the fork-demo front end."

A stack is a named composition of repos. It doesn’t copy anything — it just says “these repos, together, under this name.” One manifest can define several stacks that share the same repos:

[stack.site]
repos = ["hello-world", "spoon-knife"]

[stack.core-only]
repos = ["hello-world"]

hello-world appears in both stacks, but there’s only ever one clone of it on disk. Stacks are how you carve a big fleet into the working sets you actually build and test together — you’ll haw switch <stack> between them, and scope commands to one with --stack.

🧬 5. Overlays — per-variant overrides (when you need them)

Most repos need nothing more than the fields above. But sometimes one repo should look slightly different in a particular composition — a different branch for a release variant, say. That’s an overlay: a named set of per-repo overrides applied at lock time.

[repo.app-mqtt]
remote = "gh"
repo = "app-mqtt.git"
rev = "main"

# Override app-mqtt's rev only when the `release` overlay is active.
[overlay.release.app-mqtt]
rev = "release/2.x"

With the release overlay active, app-mqtt locks to release/2.x instead of main; everything else stays put. Reach for overlays only when a repo genuinely needs to vary by variant — for the rest of this course we won’t need one. See CLI design for the full overlay semantics.

Tip: You don’t have to hand-write every repo. haw repo add and haw stack add edit the manifest for you, and haw import converts an existing west.yml or Google-repo default.xml straight into a haw.toml.

🗺️ 6. The whole picture

Here’s the manifest as a shape — intent flowing down into stacks:

haw.toml
├─ [remote.gh]        base URL + forge, named once
├─ [repo.hello-world] remote + repo + rev + groups   ┐
├─ [repo.spoon-knife] remote + repo + rev + groups   ├─ the repos (your intent)
│                                                    ┘
└─ [stack.site]       repos = [hello-world, spoon-knife]   ← a named composition

That’s the complete mental model of the manifest: remotes name where, repos declare what and which rev, groups slice them, stacks compose them, and overlays tweak them per variant. Nothing here has touched the network or the disk yet — it’s pure declaration.

You just declared a fleet. A remote, two repos, and a stack — that’s a real, syncable haw.toml. It scales the same way to a hundred repos and a dozen stacks.

🙌 Your turn

Make the manifest your own — no sync required, this is all declaration:

  • Add a third repo to haw.toml — try octocat/git-consortium.git on the gh remote — give it a rev and put it in a new group.
  • Add it to the site stack’s repos list, then add a second stack, core-only, that lists just hello-world. Notice the repo is shared, not copied.
  • Sketch on paper what you expect on disk after a sync: how many clones? (Hint: one per repo, no matter how many stacks reference it.)

✅ Recap

  • The manifest (haw.toml) is your intent — nothing is cloned or frozen until you sync.
  • [remote.NAME] names a base URL + forge once; repos reference it.
  • [repo.NAME] declares one repo with a rev (branch/tag/SHA, auto-detected) and free-form groups.
  • [stack.NAME] composes repos under a name — shared, never copied; a repo can be in many stacks and groups.
  • [overlay.…] applies per-variant overrides at lock time, for the rare repo that must vary.

👉 Next

You’ve declared the fleet. Now let’s make it real — clone it, freeze it to a lockfile, and watch haw catch drift → 3. Sync and the lockfile.

3. Sync and the lockfile

This is where it clicks. You have a haw.toml from the last chapter. Now you’ll run haw sync to clone the fleet for real, read the lockfile that freezes it to exact commits, prove it’s reproducible, and — deliberately — break something to watch haw catch drift.

Everything here clones over HTTPS with no authentication, so you can actually run every command as you read. Keep the my-first-stack workspace from Chapter 2 open.

Syncing and pinning a fleet to a lockfile

Sync clones the fleet; the lockfile freezes it to exact commits — the whole reproducibility trick.

🎯 In this chapter, you’ll learn to…

  • Run haw sync to clone the fleet and generate haw.lock.
  • Read the fleet with haw tree and haw status, and understand every column.
  • Read the lockfile’s real fields — rev (the resolved SHA), source-rev, and branch.
  • Re-sync and confirm it’s idempotent — the lock, not the branch, is now the truth.
  • Cause drift on purpose and catch it with haw verify (exit 3), then restore.

The full compose loop you’re about to run: sync clones the fleet, then tree / status read it and the lock pins it.

🔄 1. Sync — clone everything and write the lock

One command materializes the tree:

haw sync

haw resolves each repo’s rev to an exact commit, clones it, and — because there’s no lockfile yet — writes one. You’ll see progress per repo and a summary. Afterwards, look around:

$ ls
haw.lock   haw.toml   hello-world/   spoon-knife/

There they are: two real, complete Git clones, plus a brand-new haw.lock. No submodules, no symlinks — you could cd hello-world && git log and it’s just Git.

Tip: haw sync is idempotent. Run it again and, since the lock already pins exact SHAs, haw just makes sure your tree matches — no surprises, safe to repeat in scripts and CI.

🔍 2. Explore the fleet

Now the read commands. First, the shape of things:

haw tree
haw.toml
└─ site
   ├─ hello-world  master  (https://github.com/octocat/Hello-World.git)
   └─ spoon-knife  main  (https://github.com/octocat/Spoon-Knife.git)

That’s your stack → repo tree: the stack site, the two repos under it, each with its declared rev and origin.

Now the health check:

haw status
REPO         BRANCH                   HEAD       DIRTY  DRIFT
hello-world  master                    7fd1a60b   -      -
spoon-knife  main                      d0dd1f61   -      -

Read the columns left to right: the repo, the branch it’s on, its short HEAD SHA, whether the working tree has uncommitted changes (DIRTY), and whether HEAD differs from the locked SHA (DRIFT). Right now everything is clean and in sync — the dashes mean “all good.”

Tip: On a terminal these are color-coded — cyan repo names, yellow revs, green for clean, red for drift. Pipe the output anywhere and it falls back to plain text automatically (it honors NO_COLOR), so it’s script-friendly by default.

🔒 3. Read the lockfile

Open haw.lock in your editor. Each repo is pinned to a full 40-character SHA in its rev field — the exact commit, not the branch name — while source-rev records what you asked for and branch records the branch that SHA came from:

# excerpt — your SHAs will differ
[[repo]]
name = "hello-world"
url = "https://github.com/octocat/Hello-World.git"
path = "hello-world"
rev = "7fd1a60b01f91b314f59955a4e4d4e80d8edf11d"   # the resolved commit — this is the pin
source-rev = "master"                               # what you declared in haw.toml
branch = "master"                                   # the branch that SHA was resolved from
groups = ["core"]

This is the whole reproducibility trick. Your manifest said “master” (a moving target), but the lock froze the exact commit master pointed at when you synced — that’s the SHA now in rev. source-rev remembers your intent (master) and branch remembers where it came from, so haw can later re-resolve if you ask. Commit haw.lock alongside haw.toml, and anyone who clones and runs haw sync gets precisely these commits — not whatever master happens to be today.

Don’t hand-edit it. haw.lock is generated. You commit it, but you change intent in haw.toml and let haw lock / haw sync regenerate the lock.

♻️ 4. Prove it’s reproducible — re-run sync

haw sync

Because the lock exists, haw syncs to the pinned SHAs, not to wherever the branches have moved. Run haw status again and you’ll see the same clean fleet. That idempotence is the point: the lock, not the branch, is the source of truth now.

🧭 5. See drift with your own eyes

Reproducibility is only useful if you can detect when the tree wanders off the baseline. Let’s cause that on purpose. Move one repo to a different commit by hand:

cd hello-world
git checkout HEAD~1     # step one commit back — now HEAD ≠ the locked SHA
cd ..

Ask haw what it thinks:

haw status
REPO         BRANCH                   HEAD       DIRTY  DRIFT
hello-world  (detached)                553c2077   -      YES
spoon-knife  main                      d0dd1f61   -      -

There it is — DRIFT: YES on hello-world. Checking out a specific commit also detaches HEAD (hence (detached) in the BRANCH column), and its HEAD no longer matches the locked SHA. haw status flagged it, but for CI you want a command that fails on drift. That’s verify:

haw verify

verify asserts the on-disk tree matches haw.lock and exits 3 when it doesn’t — a clean, scriptable drift gate. Check the exit code:

haw verify; echo "exit code: $?"
  ✗ hello-world  drift (head != lock)
verify failed: 1 repo(s) diverge from haw.lock
exit code: 3

That exit 3 is what a CI pipeline keys on: “the tree drifted from the lock — stop the build.” (When everything matches, verify prints verified: tree matches haw.lock (2 repos) and exits 0.)

Wide-eyed, shocked reaction

haw verify finding drift in CI right before release.

Now put it back. haw sync restores every repo to its locked SHA:

haw sync
haw verify; echo "exit code: $?"
verified: tree matches haw.lock (2 repos)
exit code: 0

Clean again. You just watched the full loop: declare → sync → pin → detect drift → restore.

You just did the core loop. Declare intent, pin it, detect drift, and restore the baseline — the same four moves scale from two octocat repos to a hundred-repo fleet.

🙌 Your turn

Prove the lockfile really is the source of truth:

  • Open haw.lock and find each repo’s pinned SHA in its rev field. Confirm it’s a 40-char commit, not a branch name, and that source-rev still shows what you declared.
  • Drift one repo on purpose (cd hello-world && git checkout HEAD~1 && cd ..), run haw verify; echo $?, and confirm you get exit code 3.
  • Run haw sync to restore, then haw verify; echo $? again — back to exit 0. That round trip is the reproducibility guarantee.

✅ Recap

  • haw sync clones every repo and writes haw.lock — real Git clones, no submodules/symlinks. It’s idempotent.
  • haw tree shows the stack→repo shape; haw status shows branch/HEAD/dirty/drift per repo.
  • haw.lock pins each repo to an exact 40-char SHA in rev, with source-rev (your declared intent) and branch alongside — commit it for byte-identical rebuilds.
  • Drift = HEAD differs from the lock. haw status flags it; haw verify exits 3 on drift (your CI gate). haw sync restores the baseline.

👉 Next

You can compose and pin a fleet — now let’s live in it. Meet the cockpit that drives the whole thing from the keyboard → 4. The TUI cockpit.

4. The TUI cockpit

Everything you’ve done at the command line has a home: a live, keyboard-driven cockpit — your mission control for the whole fleet. It’s in the spirit of k9s or htop: a full-screen terminal UI where you see the fleet, drill into any repo or PR or CI run, and act — merge, approve, checkout — without ever leaving the terminal.

You met it for a moment in Chapter 1. Now that you have a real fleet, let’s take the full guided tour. Open it and follow along.

The hawser TUI cockpit dashboard

Mission control for the whole fleet — read, drill, and act without leaving the terminal.

🎯 In this chapter, you’ll learn to…

  • Open the cockpit with bare haw (or haw dash –demo to explore offline).
  • Read the live fleet grid — the same columns as haw status, refreshing themselves.
  • Work the core loop: read → drill (Enter) → act → back (Esc).
  • Act from the home row: sync, mark, run, filter, browse files, and jump to the PR / CI / governance views with the digits 17.
  • Open a view’s context actions menu with a — merge, approve, checkout, request-PR, land — each confirm-gated.
  • Browse any repo’s files at any branch, tag, or SHA — local or straight from the forge, no checkout.
  • Use the command bar (:) that mirrors the CLI you already learned.

Your mission control: the fleet grid, drill-downs, PR/CI views, and keyboard actions — all in the terminal.

🚁 1. Open it

Run haw with no subcommand:

haw

Just want to explore without a real workspace or network? Use the built-in demo controller — it’s populated with canned repos, PRs, and CI runs, so every view has something to show:

haw dash --demo

Either way you land on the fleet grid — the cockpit’s home screen.

📋 2. Read the fleet grid

 haw ▸ ~/work/gateway ───────────────────────── stack: gateway   lock: ✓   repos: 3/3
──────────────────────────────────────────────────────────────────────────────────────
   REPO        BRANCH ▲      HEAD       DIRTY   DRIFT   ↑ / ↓    MERGE
   kernel      v6.1.2        a1b2c3d4     ·       ·      0 / 0     —
 ◉ hal         main          9f8e7d6c    yes      ·      2 / 0     —
▸⚠ app-mqtt    release/2.x   4d5e6f7a     ·      DRIFT   0 / 5     —
──────────────────────────────────────────────────────────────────────────────────────
 hal  ›  path hal/   branch main (ahead 2)   dirty   locked 9f8e7d6c   grp firmware
──────────────────────────────────────────────────────────────────────────────────────
 [s]ync [f]iles [x]shell [!]exec [/]filter [p]roblems [a]ctions [1-7]views [:]cmd [?]help

This is haw status, alive. Each row is a repo; the columns are the same ones you already know — branch, HEAD, dirty, drift, ahead/behind. The is your cursor, marks a selected repo, and flags a problem (like the drift on app-mqtt). The grid auto-refreshes about every 5 seconds while idle — never while you’re typing — and F5 / Ctrl-R refresh on demand.

Move with / (or k/j), exactly like Vim.

🔬 3. Drill in — the core loop is read → drill → act

Put the cursor on a repo and press Enter. You drill into that repo’s Git detail: branch, SHA, working-tree status, recent log, diffstat, remotes. Press Esc (or b) to come back up a level.

That’s the rhythm of the whole cockpit: read the grid → drill into a thing → act on it → back out. You’re never more than a keystroke from detail or from action.

⌨️ 4. Act on the fleet from the home row

Single keys on the cursor row do things. The essentials:

KeyDoes
ssync — the marked repos if any, else the cursor repo, else the stack
fbrowse the repo’s files — local disk or straight from the forge
xdrop into a shell in that repo (exits the cockpit)
!run one command (exec) in the repo, in its detail view
/fuzzy filter the grid live — /knl narrows to kernel
pproblems-only view — just the repos that need attention
Spacemark / unmark the cursor repo ()
rrun a command — across the marked repos if any, else the whole fleet
ggoto — quit and print the cursor repo’s path (cd "$(haw dash)")

Git-fetch, switch-stack, and lock moved to the command bar — :fetch, :stack (picker) or :stack NAME, and :lock. One key you’ll use everywhere is a — the actions menu. It opens a little popup listing exactly the actions the current view supports, each with its own sub-key; pick one and any write action (merge, land, …) still asks you y/n first. Views with no actions just say so.

Tip: Marks are the cockpit’s superpower. Press Space on a few repos, then s (sync) or r (run) acts on just that set. It’s how you do a surgical fleet operation without touching a manifest.

🌐 5. The network views — PRs, CI, and acting on them

View-switching is one keystroke: the digits 17 jump straight to a view from any top-level list. The network ones load on demand (nothing hits the network until you ask):

KeyView: alias
1fleet:fleet
2changesets:changesets
3PR/MRs — every open PR/MR across the fleet:prs
4CI runs — recent runs, live progress:ci
5tree:tree
6governance — plugins, SBOM, findings:governance
7plugins:plugins

Inside the PR/MR (3) or CI (4) views, Enter drills into detail — a PR’s reviewers and checks, or a CI run’s jobs and live progress. Press d to read a PR’s diff, l for a CI run’s logs, and f to browse a PR’s changed files. (A CI log that the forge has aged out — GitHub returns 410 — renders honestly as “logs unavailable — expired or empty” rather than an error.)

To act, press a for the actions menu. In the PR/MR view it offers:

Sub-keyDoes
mmerge the PR/MR on its forge
aapprove the PR/MR
ccheckout the PR branch locally

Every write is confirm-gated with a y/n so you never merge by fat-finger. o opens the cursor row in your browser. So the full cross-forge flow from Chapter 5 — see PRs, approve, merge — is right here, keyboard-only, all under a.

📂 6. Browsing files & branches — any ref, local or forge

Press f on any repo to open its file browser — a read-only view (it never stages or commits, only reads). Three keys turn it into a proper code explorer:

KeyDoes
Ttoggle the flat one-directory list ⇄ a navigable tree (/ to expand/collapse, /Enter expand, collapse)
rref picker — pick a branch or tag from the list, or type any SHA; the view reloads as of that ref and the header shows @ <ref>
eedit the file under the cursor in your $EDITOR (local files only)
Rtoggle local disk ⇄ the forge

The payoff: with r you can read a file as it exists on any branch of the remote — across GitHub, GitLab, or Bitbucket — without checking anything out. Point at a colleague’s feature/x branch, read the file straight from the forge API, and never touch your working tree. e is the one exception to read-only: it hands the file to your editor for a quick local fix, then reloads the listing.

💬 7. The command bar — one language for CLI and TUI

Press : to open the command bar (the command palette). Its verbs mirror the CLI you already learned, and the status line echoes the exact command each one runs — so the cockpit doubles as a way to discover the CLI:

:sync              sync the current stack
:grep TODO         fleet-wide grep
:switch platform   switch to another stack
:change land FEAT-42
:theme nord        change the skin live

Learn one, know both. :name also jumps the cursor to a repo by name.

🎨 8. Two more views, and themes

  • 7 (:plugins) — the Plugins view: every available plugin (manifest [plugins] keys unioned with haw-* executables on your PATH); Enter runs one and shows its output in a panel. You’ll build one that lands here in Chapter 8.
  • :errors (:err) — the Errors view: a rolling log of this session’s failures, so a transient error never scrolls away before you can read it.

Themes. Seven built-in skins — classic, catppuccin (default), dracula, nord, gruvbox, solarized, monochrome. classic is a neutral, standard-ANSI look that reads on light and dark terminals alike. Type :theme with no argument to open an interactive picker (j/k to move, Enter to apply); the chosen skin is applied live and saved to your config. :theme nord still switches directly. Set one at startup with HAW_THEME=nord haw, and NO_COLOR forces monochrome.

Make it yours — ~/.config/haw/config.toml. Optional and forgiving (a missing or partial file just uses defaults). Set a startup theme, the e-key editor, a collapsed header, and the idle-refresh cadence — plus remap a safe subset of action keys:

[ui]
theme = "classic"        # startup skin
editor = "nvim"          # the `e` key's editor ($VISUAL/$EDITOR still win)
compact_header = false   # start with the one-line header
refresh_secs = 5         # idle auto-refresh cadence (2–60)

[keys]                   # remap: sync, goto, run, shell, files, problems, watch
sync = "s"

The pickers write this file for you: :editor (no arg) lists the editors on your PATH, and :compact toggles the collapsed header — each persists your choice. The frozen globals (j, k, :, /, ?, q, b, space, g, w, digits) can’t be rebound; an invalid remap is dropped with a startup note, and the header hints always show the key that’s actually live.

Press ? any time for the help overlay, and q (or Ctrl-C) to quit.

Tip: Every heavy action runs on a background worker, so the UI never freezes while a sync, a fetch, or a forge call is in flight. Keep navigating.

🙌 Your turn

No workspace or network needed — the demo controller has everything to poke at:

  • Launch haw dash –demo. Move with /, then press Enter to drill into a repo and Esc to come back — feel the read → drill → act → back rhythm.
  • Press Space to mark a couple of repos (watch the ), then / to fuzzy-filter the grid. Press 3 for PRs and 4 for CI, then a to see the actions menu. Press ? for the help overlay.
  • Press f on a repo to browse its files, then T for the tree and r to read a file as of another branch or tag. Press b to come back.
  • Press : and type :theme nord. The skin changes live — and the status line echoes the exact command, so the cockpit is teaching you the CLI as you go.

✅ Recap

  • Bare haw (or haw dash --demo) opens the cockpit — a live haw status you can act on.
  • The loop is read → drill (Enter) → act → back (Esc).
  • Fleet keys: s sync, f files, x shell, ! exec, / filter, p problems, Space mark, r run, g goto, a actions.
  • View jumps are the digits 17: 1 fleet, 2 changesets, 3 PRs, 4 CI, 5 tree, 6 governance, 7 plugins. In PRs/CI, a (actions) does merge/approve/checkout (confirm-gated), d diff, l logs, f PR files.
  • File browser (f): T toggles tree, r picks a branch/tag/SHA (read any ref, local or forge, no checkout), e edits locally, R toggles local ⇄ forge.
  • : is a command bar (palette) mirroring the CLI; :plugins and :errors reach those views; seven themes (incl. classic) via :theme / HAW_THEME, an interactive :theme/:editor picker, and a ~/.config/haw/config.toml for themes, editor, display options, and custom keybindings.

👉 Next

You’ve seen the whole cockpit read the fleet. Now the signature move it drives — shipping one feature across many repos as a single coordinated changeset → 5. Changesets across repos.

5. Changesets across repos

This is hawser’s signature feature — the one that pays for the whole tool. In this chapter you’ll take one feature that touches several repos and drive it as a single unit: one branch across all of them, cross-linked pull requests, and a merge that lands in the right order.

Reviewing cross-repo pull requests as one changeset

One feature, one review flow — even when the code is spread across repos and forges.

🎯 In this chapter, you’ll learn to…

  • Understand what a changeset is: one feature = one branch across N repos.
  • Start a changeset with haw change start — the same branch in every repo, in one move.
  • Track the whole feature on one screen with haw change status.
  • Open cross-linked PR/MRs with haw change request — across GitHub, GitLab, and Bitbucket.
  • Merge in dependency order with haw change land, so main never breaks.

One feature, many repos: change start branches them all, status aggregates, request opens linked PRs.

😤 1. The pain this removes

Think back to the last time a feature spanned repos. You did this dance:

  1. git checkout -b feature in repo A. And repo B. And repo C.
  2. Push each. Open a PR in A. Then B. Then C.
  3. Chase reviews across three separate PR pages.
  4. Merge them — and remember that the shared library must merge before the services that depend on it, or main breaks.

Nothing tied those three PRs together. There was no single artifact that said “these are one feature.” A changeset is exactly that artifact: one feature = one branch across N repos, with linked PR/MRs and an ordered land.

Changesets need a real forge (GitHub, GitLab, or Bitbucket) and a token to open PRs. The start and status steps are local and safe to try anywhere; request and land talk to the forge. We’ll flag which is which.

🌱 2. Start the changeset

You name the feature and (optionally) the repos it touches:

haw change start FEAT-42 --repos api,billing,proto
changeset `FEAT-42` started across 3 repo(s):
  proto    -> change/FEAT-42
  billing  -> change/FEAT-42
  api      -> change/FEAT-42

In one move, haw created the same branchchange/FEAT-42 by default — in each of the three repos. Now you make your changes and commit in each repo as normal Git. The branch name ties them together.

A few useful options at start time:

  • --repos a,b,c — limit to the repos the feature touches (default: all repos).
  • --branch <name> — use a custom branch name instead of change/<id>.
  • --skip-branch — adopt whatever branch each repo is already on, instead of creating one.
  • --label <l> — attach a label (repeatable) that gets forwarded to the PR/MRs later.

📊 3. Watch it come together — change status

At any point, get the whole feature on one screen:

haw change status FEAT-42

This aggregates, per repo: the branch, whether each repo is on it, whether it’s dirty, its HEAD — and once PRs exist, the review state and CI status of each PR/MR. Before any PRs are open it looks like this (run against the DEMO-1 changeset from the “Your turn” below, so the repos are the two octocat clones):

changeset `DEMO-1`
REPO         BRANCH                   ON IT     DIRTY  HEAD       PR
hello-world  change/DEMO-1             yes       -      7fd1a60b   —
spoon-knife  change/DEMO-1             yes       -      d0dd1f61   —
(no PR/MRs yet — open them with `haw change request DEMO-1`)

Instead of three browser tabs, one dashboard. It’s the changeset equivalent of haw status.

Tip: Add --format json to change status (it emits a stable haw.change-status/1 document) when you want to pipe the state into another tool or a script:

$ haw change status DEMO-1 --format json
{
  "id": "DEMO-1",
  "repos": [
    {
      "branch": "change/DEMO-1",
      "dirty": false,
      "head": "7fd1a60b01f91b314f59955a4e4d4e80d8edf11d",
      "missing": false,
      "name": "hello-world",
      "on_branch": true,
      "pr": null
    }
  ],
  "schema": "haw.change-status/1"
}

🔀 4. Open the pull requests — change request

When your branches are pushed and ready, one command opens cross-linked PR/MRs — one per repo — on whichever forge each repo lives on:

haw change request FEAT-42

Here’s the quietly powerful part: your repos don’t all have to be on the same forge. haw speaks GitHub, GitLab, and Bitbucket, so a feature spanning a GitHub service and a GitLab library gets a PR on GitHub and an MR on GitLab, cross-linked, from this one command. Any labels you passed at start are forwarded here. Target a specific base branch with --base <branch>.

This step needs a forge token in your environment — e.g. export GITHUB_TOKEN=$(gh auth token). haw reads tokens from env vars only, never stores them. Read-only steps (start, status) need no token.

🛬 5. Land in dependency order — change land

Reviews are in, checks are green. Now merge — but in the right order. Remember proto is a shared library that api and billing depend on. Merging a service before its library lands is how you break main.

haw knows the order because your manifest declares it. Recall the deps key from the microservices example:

[repo.gateway]
deps = ["proto"]     # proto must land before gateway

So land merges the PR/MRs in stable topological order — dependencies first — and stops at the first failure rather than leaving a half-merged mess:

haw change land FEAT-42
landing changeset `FEAT-42` in dependency order:
  proto    ✓ merged
  billing  ✓ merged
  api      ✓ merged
changeset `FEAT-42` landed.

Forge step — needs a token. Like request, land talks to the forge and requires a token (e.g. export GITHUB_TOKEN=$(gh auth token)) plus merge rights on each repo. The output above is illustrative: against the public read-only octocat repos this course uses, request/land can’t push or merge, so don’t expect these lines without your own writable repos and a token.

proto merged first because everything depends on it; the services followed. One command, correct order, no broken main.

Jubilant celebration reaction

One command. Five repos merged in the right order. We did it.

change land merges the linked PR/MRs in topological order — dependencies first — and stops at the first failure.

🖼️ 6. The value, in one picture

        one feature (FEAT-42)
                │
   ┌────────────┼────────────┐
 proto        billing        api          ← change start:  one branch across N repos
   │            │             │
 MR/PR        PR             PR            ← change request: cross-linked, any forge
   │            │             │
   └──── land in deps order ──┘            ← change land:  proto → billing → api

You never lost track of which PRs were “the feature,” you never merged in the wrong order, and you drove all of it from four commands. That’s the whole point of a changeset: a multi-repo feature that behaves like a single, coherent change.

Tip: Working across repos and want to hop into one? haw change goto FEAT-42 <repo> prints its path so you can cd "$(haw change goto FEAT-42 api)". And haw change snapshot save <name> records every repo’s branch + HEAD so you can restore the exact multi-repo state later.

🙌 Your turn

The local half of the flow needs no forge token, so try it in a workspace right now:

  • Run haw change start DEMO-1 –repos hello-world,spoon-knife and confirm the same change/DEMO-1 branch appears in both repos (peek with haw run ‘git branch –show-current’).
  • Run haw change status DEMO-1 and read the aggregated branch/dirty/HEAD — one dashboard instead of two tabs.
  • Now sketch it on paper: for a real feature spanning a shared lib and two services, in what order must they land? (Lib first — that’s the deps key doing its job.)

✅ Recap

  • A changeset is one feature across N repos: a shared branch, linked PR/MRs, an ordered merge.
  • haw change start <id> --repos a,b,c creates the same branch in each repo.
  • haw change status <id> aggregates branch + review + CI across the whole feature.
  • haw change request <id> opens cross-linked PR/MRs on GitHub, GitLab, and Bitbucket.
  • haw change land <id> merges in dependency order (from each repo’s deps), stopping on the first failure.
  • start/status are local; request/land need a forge token in the environment.

👉 Next

You can ship a feature across the fleet. Now let’s build, test, and verify the whole thing — the daily workflow that keeps it green → 6. Build, test, and verify.

6. Build, test, and verify

You can compose a fleet, pin it, and ship changesets across it. Now let’s make it earn its keep day to day. The promise of a multi-repo tool is simple: do something across every repo at once, in parallel, without a hand-rolled for loop — and then gate the whole fleet so CI stays green.

In this chapter you’ll run arbitrary commands across the fleet, wire up build and test, search every repo in one shot, and use verify as the drift gate that ties it together. Keep the my-first-stack workspace open.

Running commands across the whole fleet in parallel

One command in, the whole fleet processed in parallel — no hand-rolled for loop.

🎯 In this chapter, you’ll learn to…

  • Fan any command across every repo with haw run.
  • Declare build = / test = per repo and drive the whole fleet with haw build / haw test — live-streamed, per-repo color.
  • Search every repo at once with haw grep.
  • Gate the fleet against the lock with haw verify — the CI drift gate.
  • Control the blast radius with -j (parallelism) and –group (scope).

haw run fans a command across every repo in parallel; verify gates the fleet against the lock.

⚙️ 1. Run any command across every repo — haw run

The workhorse. haw run takes a command positionally and runs it in every repo, in parallel:

haw run 'git log -1 --oneline'
── hello-world ──
7fd1a60 Merge pull request #6 from Spaceghost/patch-1
── spoon-knife ──
d0dd1f6 Pointing to the guide for forking
ran in 2/2 repos

haw groups the output per repo so you always know which repo said what. Anything you’d type in one repo works: git fetch, git status -s, a linter, a shell one-liner.

Tip: Quote the command. haw run 'git status -s' passes the whole string as one command; without quotes your shell would try to interpret the flags itself.

🔨 2. Declare build and test — then run them fleet-wide

haw stays build-system-agnostic: each repo names the shell command that builds or tests it, and haw fans those out. You declare them in the manifest with build = and test =.

In the real world each repo brings its own toolchain. Here’s the shape, from the shipped microservices example:

[repo.proto]
build = "buf generate"
test  = "buf lint"

[repo.gateway]
build = "go build ./..."
test  = "go test ./..."

[repo.billing]
build = "cargo build --release"
test  = "cargo test"

[repo.accounts]
build = "npm ci && npm run build"
test  = "npm test"

Our octocat repos have nothing real to compile — and if you run haw build now, it tells you so and exits non-zero:

$ haw build
error: no cloned repo declares a `build` command in the manifest

So let’s declare trivial commands to see the mechanism. Add a build = and test = line to both repos in your my-first-stack/haw.toml:

[repo.hello-world]
remote = "gh"
repo = "Hello-World.git"
rev = "master"
groups = ["core"]
build = "echo built"
test  = "echo tested"

[repo.spoon-knife]
remote = "gh"
repo = "Spoon-Knife.git"
rev = "main"
groups = ["web"]
build = "echo built"
test  = "echo tested"

Now two commands drive the whole fleet:

haw build     # runs every repo's `build =`, in parallel
haw test      # runs every repo's `test =`, in parallel
$ haw build
── hello-world ──
built
── spoon-knife ──
built
build ran in 2/2 repos

Output is live-streamed as each repo runs and grouped under a per-repo header, so on a color terminal each repo’s stream carries its own color — you can read a fleet-wide build without losing track of which repo produced which line. Repos that don’t declare the command (or aren’t cloned) are simply skipped. And here’s the detail that makes this a CI building block:

CI building block: haw build and haw test exit non-zero if any repo fails. That’s why the same command you run locally drops straight into a pipeline — the pipeline stops when any repo’s build breaks.

🔎 3. Search the whole fleet — haw grep

Need to find every use of a symbol, a TODO, a deprecated API across all repos? haw grep fans git grep across every cloned repo at once:

haw grep guide
spoon-knife (1 hit(s))
  README.md:9:For some more information on how to fork a repository, [check out our guide, "Forking Projects""](http://guides.github.com/overviews/forking/). Thanks! :sparkling_heart:
1 hit(s) in 2 repo(s) for `guide`

Each repo with a match gets a header and its hits underneath, and a final line totals the hits and repos searched — a repo with no match just doesn’t appear. It searches tracked files via Git, so it’s fast and ignores your build artifacts for free. Scope it to one stack with --stack <name> when the fleet is large.

🛡️ 4. Verify — the CI gate

Building and testing green is only half the story. Before any of it, CI must prove the tree is the one you pinned — no drift from haw.lock. That’s haw verify, which you met in Chapter 3:

haw verify; echo "exit code: $?"
verified: tree matches haw.lock (2 repos)
exit code: 0

verify asserts the on-disk tree matches the lock and exits 3 on drift — a clean, scriptable gate. It’s the first real step of every pipeline: sync to the lock, verify you’re on it, then build and test. Chapter 7 wires the full sync → verify → build → test job.

Tip: verify reads the tree and the lock only — no network, no builds. It’s cheap enough to run as a git pre-commit hook (haw hooks install) so drift never even reaches CI.

🎚️ 5. Control the blast radius: parallelism and groups

Two levers keep fleet-wide commands under control.

Parallelism — -j. By default haw runs up to min(cores, 8) repos at once. Cap it when a task is heavy (or a CI runner is small):

haw test -j 4     # at most 4 repos building/testing at a time
haw run -j 1 'git fetch'   # fully serial

Groups — --group. Remember the groups = [...] labels from your manifest? They exist precisely so you can act on a slice of the fleet. In my-first-stack, hello-world is in group core and spoon-knife is in web:

haw status --group core
REPO         BRANCH                   HEAD       DIRTY  DRIFT
hello-world  master                    7fd1a60b   -      -

Only the core repo. The same --group filter works on sync, status, and run (and build/test), and it’s repeatable — pass it twice to select two groups. An empty filter means everything; a filter excludes ungrouped repos.

haw run --group core 'git log -1 --oneline'   # only core repos

🗺️ 6. When to reach for each

A quick map so you pick the right verb without thinking:

You want to…UseWhy
run an ad-hoc command everywherehaw run '<cmd>'one-off, not part of the manifest
build the whole producthaw buildruns each repo’s declared build =, CI-ready exit code
test the whole producthaw testruns each repo’s declared test =, fails on any failure
prove the tree matches the lockhaw verifydrift gate, exit 3 on drift
find text across all reposhaw grep <pat>fleet-wide git grep, grouped output
limit to part of the fleetadd --group <g>act on a labeled slice
tame a heavy or CI runadd -j <n>cap concurrent repos

The rule of thumb: run for one-off commands, build/test for the commands your repos declare, verify to gate. The declared ones are the ones you’ll want identical locally and in CI.

🙌 Your turn

Put the fleet through its paces in my-first-stack:

  • Add the trivial build = / test = lines above, then run haw build and haw test — watch the per-repo streamed output and the 2/2 repos summary.
  • Run haw grep guide across the fleet (a real hit in spoon-knife). Then narrow it: haw run –group core ‘git log -1 –oneline’ — only the core repo should answer.
  • Run haw verify; echo $? and confirm exit 0 on a clean tree. Force it fully serial with haw run -j 1 ‘git status -s’ and watch the repos process one at a time.

✅ Recap

  • haw run '<cmd>' runs any command in every repo in parallel, output grouped per repo.
  • Declare build = / test = per repo; haw build / haw test fan them out, live-stream per-repo output, and exit non-zero on any failure — so they double as CI steps.
  • haw grep <pattern> is a fleet-wide git grep.
  • haw verify gates the tree against haw.lock (exit 3 on drift) — the first move of every pipeline.
  • -j N caps parallelism; --group G (repeatable) scopes commands to labeled repos.

👉 Next

You can build, test, and gate the fleet locally. Now let’s make all of it production-grade — trust, CI, signing, and audit → 7. Going to production.

7. Going to production

You can compose a fleet, work across it, ship changesets, build and test it. This final chapter is about doing all of that for real — safely, in CI, with an audit trail — and about the one extensibility escape hatch that ties the governance features together: plugins. It’s the difference between a neat local tool and something you trust to gate releases.

Taking haw to production

From a neat local tool to something you trust to gate releases — let’s launch.

🎯 In this chapter, you’ll learn to…

  • Internalize the trust model — the manifest is trusted code, tokens live only in the environment.
  • Wire the four-move CI pipeline: sync → verify → build → test.
  • Enforce reproducibility with haw sync –locked and haw verify.
  • Extend haw with plugins — any unknown haw <name> runs haw-<name>, no fork required.
  • Distribute artifacts with haw publish, and produce SBOM, provenance, and signatures.
  • Bundle an audit trail with haw evidence and map it to compliance standards.

🛡️ 1. First, the trust model — because it matters

Before you run haw on anything you didn’t write, internalize one rule:

The hawser trust model

Treat a haw.toml like a Makefile: powerful, and only run one you trust.

The manifest is trusted code. A haw.toml’s build, test, run, and exec commands are executed through your shell.

Running haw build, haw run, or haw sync on an untrusted checkout is equivalent to running its Makefile. Treat haw.toml exactly like a Makefile or a package.json scripts block: only run it on manifests you trust.

Two corollaries you already half-know:

  • Plugins are trusted binaries. haw <name> runs haw-<name> from your PATH with your full environment (including any tokens). Install only plugins you trust; keep PATH clean.
  • Tokens live in the environment only. haw reads forge tokens from env vars at call time, and never stores or logs them. Git transport auth stays with your existing SSH keys / credential helper — haw doesn’t touch it.

Read the full trust model before you wire haw into anything shared.

🧩 2. Extend it with plugins — no fork required

Before we wire the pipeline, meet the escape hatch that powers the governance features below. haw follows the same pattern as git, cargo, and kubectl: any subcommand haw doesn’t recognize is dispatched to a haw-<name> executable on your PATH.

Extending haw with plugins

A plugin is just a program that reads some JSON and prints some JSON — write one in any language.

haw jira sync      # not built in → runs `haw-jira sync`

The plugin runs as a separate process (a broken plugin can never crash haw), haw hands it the current fleet as JSON via HAW_JSON + stdin (haw.plugin/1), and the plugin’s exit code becomes haw’s — so a plugin is a first-class CI gate. Discover, install, and scaffold them:

haw plugins list                     # first-party + installed plugins
haw plugins list --remote            # the community index
haw plugins install aspice           # shells out to cargo install
haw plugins new mycheck --lang python   # runnable skeleton (rust|python|go|shell)

The scaffold is a complete, runnable plugin: it reads the context, handles --help and --format json, emits a haw.plugin.report/1 document, and fails open outside a workspace. Put it on PATH and it’s instantly a haw subcommand — no rebuild of haw.

Tip: haw’s own governance features — SBOM, signing, secret-gate — ship as plugins on exactly this model, so nothing here is second-class. The full contract, JSON Schemas, and language bindings are in Plugins.

The real power is lifecycle hooks: subscribe a plugin to a phase in [plugins] and it fires automatically around fleet operations — which is exactly how the supply-chain features below are wired.

🔧 3. The CI pipeline — always the same four moves

Here’s the payoff of everything you learned in Chapters 3 and 6. A haw pipeline is the same shape everywhere:

sync  →  verify  →  build  →  test
  • sync the tree to the SHAs pinned in haw.lock,
  • verify that the tree matches the lock — the drift gate, exit 3 on drift,
  • build and test the whole fleet (each fails the job non-zero).

A GitHub Actions job, straight from the README:

jobs:
  fleet:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4          # the manifest repo (haw.toml + haw.lock)
      - run: cargo install hawser          # or download the signed musl binary
      - run: haw sync --filter=blob:none   # partial clone → fast on large fleets
      - run: haw verify                    # exit 3 if the tree drifts from haw.lock
      - run: haw build
      - run: haw test
    env:
      GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}   # only if a step hits the forge API

GitLab CI is the identical four moves with variables: { GITLAB_TOKEN: $CI_JOB_TOKEN }.

Tip: On a big fleet, pair haw sync --filter=blob:none (partial clone — all history, lazy blobs) with a cache of the shared object store. Clones stay fast without breaking the pinned SHAs, because every commit is still reachable.

♻️ 4. Reproducibility, enforced

The whole pipeline rests on haw.lock. Two flags make it airtight in CI:

haw sync --locked        # fail unless haw.lock exists (no rev resolution)
haw verify               # assert tree == lock, exit 3 on drift

--locked refuses to invent a lock on the fly — CI must build from the committed baseline, never resolve fresh. verify then proves the checkout matches it. Together they guarantee the tree in CI is byte-for-byte the tree you committed.

📦 5. Distribution — publish what the fleet produced

Once the fleet builds, haw publish uploads its artifacts to a generic/raw artifact registry — Nexus, Artifactory, GitLab, or Bitbucket:

haw publish dist/*.tar.gz --to nexus
haw publish dist/*.tar.gz --to nexus --dry-run   # print the plan, no creds, no network

--dry-run prints exactly what would upload (target, name, version, each file) without touching the network or needing credentials — perfect for wiring the step up safely first. Credentials come from the target’s env vars (e.g. NEXUS_URL, NEXUS_USER, NEXUS_PASS).

🔏 6. Signing, SBOM, and provenance — the supply chain

This is where haw earns its keep for serious releases. Every signed release ships with a .sha256 checksum and a keyless cosign signature (.sig/.pem) you can verify offline — even on an air-gapped host (see Installing hawser).

For your fleet, the supply-chain features ship as the governance plugins you met at the top of this chapter, subscribed to lifecycle phases:

[plugins]
compliance = ["post-build"]    # SBOM (CycloneDX + SPDX) after a build
artifact   = ["post-land"]     # SLSA/in-toto provenance + cosign/minisign signing
gate       = ["pre-request"]   # secret/hygiene gate blocks a bad PR before it opens
  • SBOM — a bill of materials (CycloneDX + SPDX) of what went into the build.
  • Provenance — SLSA/in-toto records of how it was built and by whom.
  • Signing — cosign/minisign signatures so consumers can verify authenticity.

And when someone asks “what exactly shipped?”, haw evidence bundles the manifest, the lock, the audit log, and status into one archive:

haw evidence --out haw-evidence.tar.gz
wrote evidence bundle haw-evidence.tar.gz

(Run bare, haw evidence writes ./haw-evidence.tar.gz by default; --out just picks the path.)

🏛️ 7. The compliance / automotive angle

If you work under a standard — ISO 26262, DO-178C, Automotive SPICE, CRA — the pieces above are your evidence, because they’re grounded in the pinned lock:

Standard / artifactHow haw covers it
Automotive SPICEhaw-aspice emits repo → pinned SHA → process-area traceability
MISRA Chaw-misra runs cppcheck --addon=misra fleet-wide as a pre-request gate
ISO 26262 / DO-178Chaw evidence bundle + SBOM + provenance from the governance plugins
AUTOSAR ARXMLconfig repos pinned to exact SHAs in haw.lock, versioned with the code

The reproducible lock is the foundation: it makes “the baseline that was live in March” an exact, re-buildable, auditable fact rather than a guess. See Domains and Compliance for the full mapping.

🪝 8. Integrity hooks — catch drift before it’s committed

One last guardrail. haw hooks install writes a pre-commit hook in every repo that runs haw verify — so a commit that would drift the tree from the lock is caught locally, before it ever reaches CI:

haw hooks install
  ✓ hello-world  pre-commit -> haw verify
  ✓ spoon-knife  pre-commit -> haw verify
installed the integrity pre-commit in 2 repo(s)

haw hooks list shows the lifecycle hooks the workspace defines (executables under .haw/hooks) — separate from the per-repo integrity pre-commit above. On a fresh workspace with none defined yet, it tells you where to add them:

haw hooks list
no lifecycle hooks — add executables under /path/to/my-first-stack/.haw/hooks

That’s the whole production loop. Pinned lock, drift gate, fleet build/test, signed artifacts, and an evidence bundle — the same primitives serve a hobby project or an ISO 26262 audit.

✅ Recap

  • The manifest is trusted code — only run haw on haw.toml / plugins you trust; tokens stay in the environment, never stored.
  • The CI pipeline is always sync → verify → build → test; verify exits 3 on drift.
  • --locked + verify enforce that CI builds the committed baseline, reproducibly.
  • haw publish --to <nexus|artifactory|gitlab|bitbucket> distributes artifacts (--dry-run to preview).
  • Any unknown haw <name> runs a haw-<name> plugin from PATH — no fork; governance plugins add SBOM, provenance, and signing on lifecycle phases; haw evidence bundles the audit trail; releases are cosign-signed.
  • The same primitives map straight onto ASPICE / ISO 26262 / DO-178C / CRA compliance.

🎉 You did it

Course complete — you've learned hawser

The whole loop, checked off — compose, orchestrate, ship, extend, and govern.

You’ve gone from “what even is this?” to composing a fleet, pinning it to a lockfile, living in the cockpit, shipping cross-repo changesets, building and testing the whole thing, extending it with a plugin, and running it all in production with an audit trail. That’s the whole tool.

Where to next:

Now go compose something. Welcome aboard.

8. Build a plugin — and let Claude write your commits

Pair-programming with Claude to build a haw plugin

You bring the workspace, Claude brings the prose — pair-programming your commits and PRs.

You’ve composed a fleet, pinned it, lived in the cockpit, shipped changesets, and gated it in CI. Now the fun part: you’re going to extend haw yourself. And by the end of this chapter you’ll have taught an AI assistant — Claude — to read your whole fleet and write your commits and pull-request text for you.

This chapter assumes nothing. If you’ve never written a haw plugin, don’t know what MCP is, and have never touched the plugin protocol, you’re in exactly the right place. We’ll define every term the first time it appears, walk every command, and show you the output you should expect at each step.

We’ll build one real plugin — haw-commit-ai — and grow it in two levels:

  • Level 1 — the foundation (single repo). Learn the plugin mechanics and the MCP handshake on one repo at a time. Honest and simple. At this level Claude already sees a single repo’s diff on its own, so we’re mostly teaching it to speak haw’s protocol cleanly.
  • Level 2 — the cross-repo power (changeset-wide). The payoff. Claude on its own cannot see a fleet-wide changeset that spans several repos. haw can. We hand Claude the combined cross-repo diff and let it write one coherent pull request that narrates every repo together. This is the thing neither Claude nor a single-repo tool like lazygit can do alone.

In this chapter, you’ll learn to…

  • Understand what a haw plugin actually is, and how haw finds and runs it.
  • Read the haw.plugin/1 context that haw hands every plugin, field by field.
  • Scaffold a working plugin with haw plugins new.
  • Emit the two machine shapes haw understands: a haw.plugin.report/1 for –format json and a haw.plugin.view/1 panel for the cockpit’s Plugins view (7).
  • Understand what MCP is, and turn the same script into an MCP server so Claude can call small, safe tools.
  • Level 2: hand Claude the combined cross-repo diff and let it draft a single fleet-wide PR — the cross-repo story no single-repo tool can tell.

Level 1 — the foundation (single repo)

This is the base you build everything on: one repo at a time, and every concept you need to understand the plugin protocol and the MCP handshake. It’s deliberately simple. Get comfortable here, then Level 2 unlocks the cross-repo power.

Think of Level 1 as the training wheels: useful, honest, and on purpose not yet magical. Stay through to Level 2 — that’s where it takes off.

1. What a haw plugin actually is

Let’s start from zero. A haw plugin is nothing more than an executable program named haw-<name> that lives somewhere on your PATH. That’s the whole idea. There is no plugin registry to sign up for, no dynamic library to load, no special API to link against.

haw follows the same convention as git, cargo, and kubectl: when you type a subcommand it doesn’t recognize, it looks for a matching executable and runs it. Here’s the flow in words:

  1. You type haw commit-ai.
  2. haw checks its built-in subcommands. commit-ai isn’t one of them.
  3. haw searches every directory on your PATH for an executable named haw-commit-ai.
  4. It finds one, runs it as a separate process, and forwards your arguments to it.
  5. Whatever that program prints becomes the output; whatever exit code it returns becomes haw’s exit code.

So haw commit-ai is really just “run the program haw-commit-ai, and hand it some context about my workspace.” This is called PATH dispatch, and it’s why you can ship haw-jira, haw-sbom, or haw-whatever without ever touching haw’s source code. A broken plugin can’t crash haw, because it runs in its own process.

You can see exactly which directories haw scans:

haw plugins path      # prints the PATH directories haw searches for haw-* binaries

We’re going to build a plugin called haw-commit-ai. It has two faces, both from a single script:

  • As an ordinary plugin (haw commit-ai) it drafts commit messages and PR text from your diffs.
  • As an MCP server it lets Claude read your workspace and diffs and write the real commit and PR text itself — safely.

We’ll do the plain plugin first, then add the MCP face.

2. The context haw hands every plugin: haw.plugin/1

When haw runs your plugin, it doesn’t just launch a blind program. It hands the plugin a context: a JSON document that describes your workspace. This document has a schema name, haw.plugin/1, and it is the contract between haw and every plugin.

Where does it come from? haw provides the same JSON in two places, so you can read whichever is convenient:

  • the HAW_JSON environment variable, and
  • the plugin’s standard input (stdin).

The content is identical in both. The environment variable is usually easier because reading it never blocks.

What’s inside? Here’s a real example of the context inside a workspace:

{
  "schema": "haw.plugin/1",
  "root": "/path/to/workspace",
  "stack": "gateway",
  "repos": [
    { "name": "kernel", "path": "/path/to/workspace/kernel", "rev": "v6.1.2", "groups": ["firmware"] },
    { "name": "hal",    "path": "/path/to/workspace/hal",    "rev": "main",   "groups": ["firmware"] }
  ]
}

Let’s read it field by field:

  • schema — always "haw.plugin/1". It tells you which contract version you’re looking at.
  • root — the absolute path to the workspace root (the directory that holds your manifest). Everything the plugin writes should stay inside this.
  • stack — the name of the active stack (the named selection of repos you’re working with). Here it’s "gateway".
  • repos — the list of repositories in play. Each entry has:
    • name — the repo’s short name (kernel, hal).
    • path — its absolute on-disk location. This is the important one: to run git diff or git commit on a repo, you shell out into this path.
    • rev — the pinned revision (a tag like v6.1.2 or a branch like main).
    • groups — the groups the repo belongs to (["firmware"]).

Run outside a workspace, the context degrades to just {"schema": "haw.plugin/1"} — no root, no repos. A well-behaved plugin checks whether root and repos are present and does something sensible when they’re absent.

Here is how you read that context in Python, step by step. Read the environment variable first; if it’s empty, fall back to stdin; if there’s nothing at all, return the minimal context so the plugin never crashes:

import json, os, sys

def read_context() -> dict:
    raw = os.environ.get("HAW_JSON", "")       # 1. prefer the env var (never blocks)
    if not raw and not sys.stdin.isatty():     # 2. fall back to stdin if it's piped in
        raw = sys.stdin.read()
    if not raw:                                # 3. nothing at all → minimal context
        return {"schema": "haw.plugin/1"}
    try:
        ctx = json.loads(raw)                  # 4. parse the JSON
    except ValueError:
        return {"schema": "haw.plugin/1"}      # 5. malformed → degrade gracefully
    return ctx if isinstance(ctx, dict) else {"schema": "haw.plugin/1"}

Once you have ctx, everything else is ordinary shell work: ctx["repos"] gives you each repo’s on-disk path, and git diff / git commit are just subprocess calls into that path.

3. The three output shapes a plugin can print

A plugin can print three different kinds of output, depending on how it’s called. You don’t have to support all three, but a good one does:

  1. Plain text — the default. When someone runs haw commit-ai in a terminal, print friendly human-readable text.
  2. A machine report — when called with --format json, print a haw.plugin.report/1 document: {schema, plugin, ok, summary, findings}. Tools and CI parse this instead of scraping human text.
  3. A cockpit panel — when haw wants to render your plugin inside the TUI cockpit, it sets the environment variable HAW_RENDER=1 and puts "intent": "render" in the context. Your plugin then prints a haw.plugin.view/1 document: {schema, title, lines[]}. haw draws those lines in the cockpit’s Plugins view (press 7).

The three JSON schemas live in schemas/ — they’re the source of truth for every field.

4. Scaffold the plugin

You don’t have to write any of this from a blank file. haw generates a runnable skeleton that already implements the contract for you.

Prerequisite: you need Python 3 installed. Check it:

python3 --version      # any recent Python 3.x is fine

Now scaffold:

haw plugins new commit-ai --lang python
created ./haw-commit-ai/haw-commit-ai   (executable, python3)
created ./haw-commit-ai/README.md
next:
  chmod is already set — drop it on PATH:
    PATH="$PWD/haw-commit-ai:$PATH" haw commit-ai

Two files land in a new ./haw-commit-ai/ directory:

  • haw-commit-ai — the plugin executable itself (a Python script with a #!/usr/bin/env python3 shebang, already marked executable).
  • README.md — notes for the plugin.

The scaffold already reads $HAW_JSON, handles --help and --format json, and emits a haw.plugin.report/1. It’s a correct, working plugin as-is. In the next sections we’ll replace its body with our MCP-capable version.

Why Python? Because the MCP SDK we’ll use for the Claude side is Python-first. The plugin face stays zero-dependency (standard library only); only the --mcp face needs one extra package, which we install later with pip install mcp.

5. The plugin faces: human text, JSON report, and cockpit panel

Let’s build the plugin. We’ll introduce it piece by piece so nothing is a mystery, then show you the full script.

First, a few small helpers. context_repos pulls the repo list out of the context safely, and _run is a thin wrapper around running a shell command and capturing its output:

import json, os, subprocess, sys

def context_repos(ctx):
    r = ctx.get("repos")
    return [x for x in r if isinstance(x, dict)] if isinstance(r, list) else []

def _run(cmd, cwd=None):
    p = subprocess.run(cmd, cwd=cwd, capture_output=True, text=True, check=False)
    return p.returncode, p.stdout, p.stderr

def repo_diff_text(path):                        # staged + unstaged changes vs HEAD
    rc, out, _ = _run(["git", "-C", path, "diff", "HEAD"], cwd=path)
    return out

Next, changeset_repos figures out which repos to act on. It asks haw which repos the current changeset touched; if that comes back empty, it falls back to any repo with dirty (uncommitted) changes:

def changeset_repos(ctx):                        # touched repos, else dirty repos
    root, repos = ctx.get("root"), context_repos(ctx)
    if root:
        rc, out, _ = _run(["haw", "change", "status", "--format", "json"], cwd=root)
        if rc == 0 and out.strip():
            try: data = json.loads(out)
            except ValueError: data = {}
            names = {r.get("name") for r in data.get("repos", []) if isinstance(r, dict)}
            touched = [r for r in repos if r.get("name") in names]
            if touched: return touched
    dirty = []
    for r in repos:
        rc, out, _ = _run(["git", "-C", r["path"], "status", "--porcelain"], cwd=r["path"])
        if out.strip(): dirty.append(r)
    return dirty

Now the two machine outputs. emit_report prints the haw.plugin.report/1 document for --format json, and emit_view prints the haw.plugin.view/1 panel for the cockpit:

def emit_report(ctx):
    repos = changeset_repos(ctx) or context_repos(ctx)
    findings = [{"level": "info", "message": f"{r['name']}: draft a commit"} for r in repos]
    print(json.dumps({"schema": "haw.plugin.report/1", "plugin": "commit-ai",
                       "ok": True, "summary": f"{len(repos)} repo(s)", "findings": findings}, indent=2))

def emit_view(ctx):
    repos = changeset_repos(ctx) or context_repos(ctx)
    lines = [f"{r['name']:<16} draft a commit" for r in repos] or ["nothing to commit"]
    print(json.dumps({"schema": "haw.plugin.view/1",
                      "title": "commit-ai — proposed commits", "lines": lines}))

That’s the entire plugin face — the part that needs no external packages at all. The MCP face comes next.

6. What MCP is, and why it matters here

Before we write the MCP face, let’s define MCP, because you can’t wire up what you don’t understand.

MCP (Model Context Protocol) is a standard way for an AI assistant to call tools you expose. In three sentences:

  1. It’s a simple protocol spoken over stdio (standard input/output) using JSON-RPC messages — your program reads requests on stdin and writes responses on stdout.
  2. Your program advertises a set of tools (named functions with typed arguments), and an AI assistant like Claude can call them and read the results.
  3. That’s it: MCP is the bridge that lets Claude do things in your world — read a diff, commit a repo — instead of only chatting about it.

Why it matters here: haw knows about your whole fleet — every repo, every path, every changeset. If we expose that knowledge as MCP tools, Claude can call them to read your diffs and write accurate commit messages and PR text. Claude stops guessing and starts working from the real diff.

The good news: you don’t implement the JSON-RPC wire protocol yourself. The official MCP SDK ships a helper called FastMCP that turns a plain Python function into a tool with one decorator. You write normal functions; FastMCP handles the protocol.

7. The MCP face: the tools Claude will call

Here’s the run_mcp() function. It imports FastMCP (failing gracefully if the package isn’t installed), creates a server, and registers each tool with the @mcp.tool() decorator. The docstring of each function is what Claude sees as the tool’s description, so we write them clearly.

Two small guards are defined first: _repo_path looks up a repo’s on-disk path by name, and _within_root ensures any write stays inside the workspace root:

def run_mcp():
    try:
        from mcp.server.fastmcp import FastMCP
    except ImportError:
        sys.stderr.write("haw-commit-ai --mcp needs the MCP SDK: pip install mcp\n")
        return 1
    mcp = FastMCP("haw-commit-ai")

    def _repo_path(ctx, repo):
        return next((r.get("path") for r in context_repos(ctx) if r.get("name") == repo), None)

    def _within_root(root, path):                 # path-guard: writes stay inside root
        if not root or not path: return False
        root_abs, path_abs = os.path.realpath(root), os.path.realpath(path)
        return path_abs == root_abs or path_abs.startswith(root_abs + os.sep)

Now the five Level 1 tools. Read the docstrings — that’s what Claude reads too:

    @mcp.tool()
    def haw_context() -> dict:
        """Workspace root, current stack, and repos (name, path, rev, groups)."""
        ctx = read_context()
        return {"root": ctx.get("root"), "stack": ctx.get("stack"), "repos": context_repos(ctx)}

    @mcp.tool()
    def repo_diff(repo: str) -> str:
        """The staged+unstaged git diff for a repo — see what changed."""
        path = _repo_path(read_context(), repo)
        return repo_diff_text(path) if path else f"no repo named {repo!r}"

    @mcp.tool()
    def changeset_repos_tool() -> list:
        """Repos touched by the current changeset, else the dirty repos."""
        return changeset_repos(read_context())

    @mcp.tool()
    def write_commit(repo: str, message: str) -> str:
        """git commit -m in a repo. Path-guarded to the workspace root."""
        ctx = read_context(); path = _repo_path(ctx, repo)
        if not _within_root(ctx.get("root"), path):
            return f"refused: {repo!r} is outside the workspace root."
        rc, out, err = _run(["git", "-C", path, "commit", "-m", message], cwd=path)
        return f"committed {repo}:\n{out}" if rc == 0 else f"commit failed:\n{err or out}"

    @mcp.tool()
    def draft_pr(repo: str, title: str, body: str, submit: bool = False) -> str:
        """Return PR text. Dry by default — never pushes unless submit=True."""
        text = f"# {title}\n\n{body}"
        if not submit:
            return text + "\n\n(dry run — pass submit=True to run `haw change request`)"
        ctx = read_context()
        rc, out, err = _run(["haw", "change", "request", "--title", title, "--body", body],
                            cwd=ctx.get("root"))
        return f"{text}\n\n[request: {'ok' if rc == 0 else 'failed'}]\n{out or err}"

    mcp.run()
    return 0

Here’s what each tool does and why it exists:

  • haw_context() — hands Claude the workspace shape: root, stack, and repos. This is how Claude learns your fleet exists.
  • repo_diff(repo) — returns one repo’s diff so Claude reads exactly what changed before writing about it.
  • changeset_repos_tool() — tells Claude which repos are in play right now.
  • write_commit(repo, message) — actually commits, but only inside the workspace root (the path-guard refuses anything outside).
  • draft_pr(repo, title, body) — returns PR text; dry by default, so it never pushes anything unless you explicitly pass submit=True.

Finally, main() ties every face together — help, MCP, JSON report, cockpit render, and the default human text:

def main():
    args = sys.argv[1:]
    if "-h" in args or "--help" in args:
        print("haw-commit-ai — draft commits/PRs; --mcp to serve Claude"); return 0
    if "--mcp" in args: return run_mcp()
    ctx = read_context()
    if "--format" in args and "json" in args: emit_report(ctx); return 0
    if os.environ.get("HAW_RENDER") == "1" or ctx.get("intent") == "render":
        emit_view(ctx); return 0
    repos = changeset_repos(ctx) or context_repos(ctx)
    print(f"haw-commit-ai — {len(repos)} repo(s). Run with --mcp to let Claude write.")
    return 0

if __name__ == "__main__":
    sys.exit(main())

The listing above is the Level 1 version. The shipped examples/plugins/haw-commit-ai/haw-commit-ai is the fully-commented version (with a real conventional-commit skeleton and a PR-body template) and also carries the Level 2 cross-repo tools we add below. Both pass python3 -m py_compile and run with zero dependencies in plugin mode.

8. Run the plugin — no MCP, no Claude yet

Let’s prove it works as a plain plugin first. Make the file executable and put its directory on your PATH for the command:

chmod +x haw-commit-ai
PATH="$PWD:$PATH" haw commit-ai               # human draft
PATH="$PWD:$PATH" haw commit-ai --format json # a haw.plugin.report/1

The first command prints a friendly one-liner. The second prints a JSON report you can parse — something like:

{
  "schema": "haw.plugin.report/1",
  "plugin": "commit-ai",
  "ok": true,
  "summary": "2 repo(s)",
  "findings": [
    { "level": "info", "message": "kernel: draft a commit" },
    { "level": "info", "message": "hal: draft a commit" }
  ]
}

No recompile, no core change: drop haw-commit-ai on your PATH and haw dispatches to it like any built-in.

And because it also emits a haw.plugin.view/1, your plugin gets a home in the cockpit — open haw dash, press 7, and there it is in the Plugins panel:

9. Wire the MCP server into Claude Code

Now the Claude side. First install the MCP SDK — this is the only dependency, and only the --mcp face needs it:

pip install mcp

Register the server with Claude Code — one command. Use an absolute path to your plugin file:

claude mcp add haw-commit-ai -- python3 /abs/path/to/haw-commit-ai --mcp

Or, per project, drop it in a .mcp.json file at your project root:

{
  "mcpServers": {
    "haw-commit-ai": {
      "command": "python3",
      "args": ["/abs/path/to/haw-commit-ai", "--mcp"]
    }
  }
}

Verify Claude sees the server:

claude mcp list            # haw-commit-ai should be listed

Inside a Claude session, /mcp shows the connected server and its tools. At Level 1 that’s haw_context, repo_diff, changeset_repos_tool, write_commit, and draft_pr. Once you add Level 2 below, changeset_diff and draft_changeset_pr join them.

10. Worked example — Claude writes your commit and PR text

Make a change across two repos in your workspace (say kernel and hal), stage them, then ask Claude — from inside the workspace directory:

“Read the diffs for the repos touched by my current changeset and write a conventional-commit message for each. Then draft a single cross-repo PR body. Commit each repo with its message; leave the PR as a dry draft.”

Claude will:

  1. call changeset_repos_tool() → sees kernel, hal,
  2. call repo_diff("kernel") and repo_diff("hal") → reads exactly what changed,
  3. write conventional-commit messages (e.g. fix(kernel): guard against null irq handler),
  4. call write_commit("kernel", …) and write_commit("hal", …) — each path-guarded to your workspace,
  5. call draft_pr("kernel", "…", "…") → returns a PR body dry (nothing pushed).

You review the drafts, and when you’re happy, run haw change request yourself (or let Claude call draft_pr(..., submit=True)).

Being honest about Level 1. At this point, Claude Code already sees a single repo’s diff natively — you haven’t given it a superpower yet, you’ve just taught it to speak haw’s protocol cleanly. The real power arrives at Level 2: showing it an entire changeset, spread across several repos, in a single view.

So far we’ve mostly reinvented what Claude does for one repo for free. Keep the faith: the next level is the part it can’t do on its own.

This is fine — a cartoon dog sipping coffee as the room burns

“We built a whole plugin to do what Claude already did.” This is fine — Level 2 fixes it.

Your turn (Level 1)

  • Scaffold your own: haw plugins new commit-ai –lang python, then run the zero-dependency face with haw commit-ai –format json and confirm you get a haw.plugin.report/1 document.
  • Drop the plugin on PATH, open haw dash, press 7, and select commit-ai — your haw.plugin.view/1 panel renders right in the cockpit.
  • pip install mcp, register it with claude mcp add …, and ask Claude to read one repo’s diff and propose a commit — without committing. Then let it call write_commit, and watch the path-guard in action by asking it to commit a path outside the workspace (it should refuse).

Level 2 — the cross-repo power (changeset-wide)

Here’s the pitch, sharp: Claude alone can’t see a fleet-wide changeset. It can read one repo’s diff — but a haw changeset spans several repos at once (kernel, hal, app…), and that combined story lives between the repos. A single-repo tool like lazygit can’t show it either. haw knows the whole changeset, so haw can hand Claude the whole picture.

We add two tools to the same plugin — no new script, no new server. They turn haw-commit-ai from “a nice commit helper” into “the thing that gives an LLM fleet-wide vision.”

11. Why cross-repo is the unique value

Imagine you add one feature — say an irq_mask flag — and it has to land in three repos at once: the kernel driver that owns the register, the hal layer that threads it through, and the app that exposes it on the CLI. Each repo’s diff, read alone, is a fragment. The meaning — “these three moves are one feature and must land together” — only exists when you see all three diffs side by side.

  • Claude, on its own, reads one repo at a time. It can’t see the fragments as one story.
  • A single-repo tool sees one repo, full stop.
  • haw knows the changeset, so it can concatenate every repo’s diff into one document and hand that to Claude. Now Claude writes one coherent PR that narrates the whole feature.

That combined view is the unique value. Everything in Level 2 exists to deliver it.

12. Two cross-repo tools

Drop these alongside the Level 1 tools (the shipped examples/plugins/haw-commit-ai/haw-commit-ai already has them). First, the helper that builds the combined diff — it concatenates every repo’s diff under a clear === <repo> === header:

def changeset_diff_text(ctx):
    """The COMBINED git diff across every repo of the current changeset."""
    repos = changeset_repos(ctx) or context_repos(ctx)
    if not repos:
        return "no changeset and no dirty repos — nothing to diff."
    chunks = []
    for r in repos:                                  # clear per-repo headers
        diff = repo_diff_text(r["path"]) if r.get("path") else ""
        body = diff.rstrip() if diff.strip() else "(no changes)"
        chunks.append(f"=== {r.get('name','?')} ===\n{body}")
    return "\n\n".join(chunks)                        # the whole story, top to bottom

This is the killer function: one call, and Claude sees kernel, hal, and app’s diffs concatenated under === <repo> === headers — the fleet-wide changeset as a single readable document.

Next, the helper that builds a single cross-repo PR skeleton — a combined-summary slot plus one section per repo, which Claude then fills with prose:

def draft_changeset_pr_body(ctx, title):
    """ONE coherent cross-repo PR skeleton narrating every repo together."""
    repos = changeset_repos(ctx) or context_repos(ctx)
    lines = [f"# {title}", "", "## Combined summary", "",
             "<!-- one narrative covering all repos and why they move together -->", "",
             "## Per-repo changes", ""]
    for r in repos:
        files, changed = diff_stat(r.get("path", "")) if r.get("path") else (0, 0)
        stat = f" ({files} file(s), {changed} line(s))" if files or changed else ""
        lines += [f"### {r.get('name','?')}{stat}", "", "<!-- what changed here and why -->", ""]
    lines += ["## Testing", "", "- [ ] `haw build`", "- [ ] `haw test`", ""]
    return "\n".join(lines)

Both are wrapped as MCP tools with the FastMCP decorator, exactly like the Level 1 ones — add them inside run_mcp():

    @mcp.tool()
    def changeset_diff() -> str:
        """The COMBINED git diff across ALL changeset repos, with =-headers per repo.
        The fleet-wide view a single-repo tool can't give you."""
        return changeset_diff_text(read_context())

    @mcp.tool()
    def draft_changeset_pr(title: str) -> str:
        """ONE coherent cross-repo PR skeleton narrating every repo together.
        Dry — assembles the scaffold; you fill the prose, then `haw change request`."""
        return draft_changeset_pr_body(read_context(), title)

draft_changeset_pr(title) returns one PR body: the plugin assembles the skeleton (per-repo sections plus a combined-summary slot), and Claude fills the prose from the diffs. Dry by default; feed the result to haw change request to open the linked PRs across the fleet.

write_commit stays path-guarded. There’s no cross-repo “write everything” tool by design — for a changeset you commit per repo (Claude calls write_commit for each, each guarded to root), then run haw change request to open the linked PRs across the fleet. Writes stay small, reviewable, and inside your workspace.

13. Worked example — one PR for a three-repo changeset

Touch two or three repos in a changeset — say kernel, hal, and app — stage them, then ask Claude, from inside the workspace:

“Call changeset_diff() to read my whole changeset, then draft_changeset_pr() and write one PR that tells the combined story — a section per repo plus a combined summary.”

Claude will:

  1. call changeset_diff() → one document with === kernel ===, === hal ===, === app ===, each repo’s diff underneath,
  2. call draft_changeset_pr("…") → gets the skeleton with a section per repo,
  3. fill the prose into a single coherent narrative.

Here’s an illustrative result (labelled as illustrative — your prose will match your actual diffs):

# feat: propagate the new irq-mask flag end to end

## Combined summary
A new `irq_mask` flag flows from the kernel driver up through the HAL and into
the app's config surface. The three repos move together so the feature lands atomically.

## Per-repo changes
### kernel
Add `irq_mask` to the driver's register write and guard the null-handler path.
### hal
Thread `irq_mask` through the HAL's `configure()` and expose it in the C header.
### app
Surface `--irq-mask` on the CLI and wire it to the HAL call.

This is what neither Claude nor a single-repo tool can do alone. A single-repo tool sees three disconnected diffs; haw plus this plugin hand Claude the changeset, so it writes the one story that spans them. When you’re happy, commit each repo (write_commit, per repo) and run haw change request to open the linked PRs across the fleet.

Jubilant celebration reaction

One prompt. Three repos. One coherent PR narrative. That’s the payoff — go celebrate.

14. Safety notes — this is the important bit

Writing tools plus an LLM means guardrails matter. This is the section you don’t skim. The plugin bakes the guardrails in:

  • Path-guarded writes. write_commit and draft_pr(submit=True) refuse any repo path that isn’t inside the workspace root — Claude can’t commit outside your fleet.
  • Dry by default. draft_pr returns text only; it never pushes or force-pushes unless you explicitly pass submit=True.
  • No secrets in the plugin. Forge authentication comes from your environment — haw’s normal token resolution (GITHUB_TOKEN, and so on). The plugin stores nothing.
  • Separate process, honest exit codes. The plugin runs out-of-process; a bug can’t crash haw, and a non-zero exit propagates so CI still gates.

Your turn (Level 2)

  • Touch three repos in a changeset, then ask Claude to call changeset_diff() and draft_changeset_pr() and write one PR narrative covering all three (kernel / hal / app plus a combined summary). Compare it to what you’d get by asking Claude repo-by-repo — the cross-repo story only appears when it sees the whole changeset at once.
  • Extend the plugin: add a repo_log(repo, n) tool so Claude can see recent history for better messages. Keep it read-only.
  • Add a per-repo line count to the combined diff output, so Claude knows which repo carries the bulk of the change before it starts writing.

Glossary

  • plugin — an executable named haw-<name> on your PATH; haw runs it as haw <name>.
  • PATH dispatch — the convention (shared with git/cargo/kubectl) where haw runs an unknown subcommand by finding a matching executable on PATH.
  • haw.plugin/1 — the JSON context haw hands every plugin (via HAW_JSON or stdin), describing root, stack, and repos.
  • haw.plugin.report/1 — the machine report a plugin prints for --format json.
  • haw.plugin.view/1 — the cockpit panel a plugin prints when haw asks it to render (HAW_RENDER=1, "intent": "render").
  • MCP (Model Context Protocol) — a standard stdio JSON-RPC protocol that lets an AI assistant call tools you expose.
  • stdio — a program’s standard input and output streams; MCP messages travel over them.
  • tool — a named function (with typed arguments) your MCP server advertises for Claude to call.
  • FastMCP — the helper in the official MCP SDK that turns a Python function into an MCP tool with a decorator.

What you learned

  • A plugin is any executable named haw-<name> on PATH; haw hands it the haw.plugin/1 context via $HAW_JSON / stdin and propagates its exit code.
  • It can print a haw.plugin.report/1 (--format json) and a haw.plugin.view/1 panel (render intent, HAW_RENDER=1) for the cockpit’s Plugins view (7).
  • MCP is a standard stdio protocol that lets Claude call tools you expose; the same script becomes an MCP server with --mcp, using FastMCP.
  • Level 1 (single repo) teaches the protocol: haw_context, repo_diff, changeset_repos_tool, write_commit, draft_pr — but Claude already sees one repo natively.
  • Level 2 (cross-repo) is the real power: changeset_diff hands Claude the combined diff across the whole changeset, and draft_changeset_pr gets it to write one fleet-wide PR narrative — the cross-repo story neither Claude nor a single-repo tool can tell alone.
  • Guardrails: path-guard writes to inside root (commit per repo, then haw change request), keep PR drafting dry by default, and never store secrets — auth stays in your environment.

Where to next

You can now extend haw in any language and give an LLM safe, context-rich tools. From here:

That’s the whole tool — now go build your own beam. Welcome aboard.

Examples

Every example is a real, runnable haw.toml in the repo under examples/. Clone it (or copy the manifest), then haw sync and explore. The domain examples compose real public upstream repos, so haw sync needs network; a few build steps use Docker (noted per example).

Start with quickstart, then jump to whichever domain matches your fleet.

Learn-by-doing

ExampleWhat it shows
quickstartTwo octocat repos + a stack — the whole sync → status → lock → verify loop (the course walks it).
haw-helloA tiny plugin — the haw <name>haw-<name> contract in ~20 lines.
governance[plugins] lifecycle hooks — SBOM / provenance / gate wired to phases.

By domain

ExampleDomainWhat it shows
microservicesBackend4 services + a shared proto/lib; a feature branched, PR’d, and landed together in dependency order.
ml-platformML / dataModel + data-pipeline + serving-infra pinned as one reproducible baseline, with stacks + an overlay.
automotiveEmbedded / AUTOSARARXML config + shared HAL + two ECU apps, cross-toolchain builds, [plugins] misra + aspice.
automotive-pinnedEmbeddedA fully SHA-pinned automotive fleet — the reproducibility/audit baseline.
embedded-bspEmbeddedA shared BSP/HAL reused across ECU stacks via overlays.
embedded-realEmbeddedFive real upstreams (CoreMark, cJSON, Monocypher, libcanard, Mbed-TLS) — all build with one haw build -j4 (validated).
rpi-picoEmbedded / firmwareTwo real Raspberry Pi Pico (RP2040) firmwares (embassy blinky + rp-hal) cross-compiled to Cortex-M0+ with Rust’s built-in thumbv6m-none-eabi — no ARM toolchain — plus cJSON (ctest 19/19). Build+test validated; all three have active GitHub Actions CI.
devops-infraDevOps / InfraThree real upstreams — terraform-aws-vpc (init+validate), Prometheus helm-charts (helm lint), a Dockerfile app (docker build+hadolint) — build+test 3/3 (validated).
ml-aiML / AIReal from-source LLM runtime — llama.cpp compiled to llama-cli + nanoGPT parse-check, build+test 2/2 (validated).
mobileMobileApp+SDK pinned in lockstep — OkHttp SDK builds+tests for real via a JDK-21 Docker image; the Now-in-Android app half is a pattern (needs Android SDK).

Real build & emulation recipes

For copy-paste build/test wiring to real toolchains — Docker cross-compile (Cortex-M4), FreeRTOS booted under QEMU, and patterns for EB tresos / Vector / Green Hills / IAR / Tasking / Zephyr / Renode — see Integration recipes.

Integration recipes — wiring haw to your toolchain

haw never bundles or reimplements a compiler, a config generator, or an emulator. It only shells out the per-repo build = / test = command you declare (with the repo directory as the working directory, so $PWD inside the command is the repo path). That is the entire integration surface: put your toolchain’s command in build =, and haw drives the whole fleet — in parallel, pinned to haw.lock, with a CI-grade exit code.

So “integrating haw” is the same one move whether your compiler is gcc, a Docker image, or a €50k licensed automotive suite. This page shows both:

  • Recipes that were actually executed (open toolchains: Docker cross-compile, QEMU emulation, FreeRTOS) — with real captured output.
  • Wiring patterns for proprietary/licensed tools (Vector, EB tresos, Green Hills, IAR, Tasking, Renode) — the exact shape, honestly marked not run here (we don’t have the licenses), so nothing is fabricated.

The one idea: haw build / haw test run build = / test = per repo and fail (non-zero) if any repo’s command fails. Nothing in haw is ARM-, Docker-, or QEMU-specific. Wrap any toolchain the same way.


Toolchain in a container (no host cross-toolchain needed)

You rarely install a cross-compiler on every developer’s machine. Put it in a Docker image once and reference it from build =. The two images used below:

# haw-arm-gcc — bare-metal ARM cross-compiler
FROM ubuntu:22.04
RUN apt-get update && apt-get install -y --no-install-recommends \
    gcc-arm-none-eabi libnewlib-arm-none-eabi make ca-certificates \
 && rm -rf /var/lib/apt/lists/*
# haw-arm-emu — same, plus the QEMU emulator for on-CI firmware runs
FROM ubuntu:22.04
RUN apt-get update && apt-get install -y --no-install-recommends \
    gcc-arm-none-eabi libnewlib-arm-none-eabi make git ca-certificates qemu-system-arm \
 && rm -rf /var/lib/apt/lists/*
$ docker run --rm haw-arm-emu sh -c 'arm-none-eabi-gcc --version | head -1; qemu-system-arm --version | head -1'
arm-none-eabi-gcc (15:10.3-2021.07-4) 10.3.1 20210621 (release)
QEMU emulator version 6.2.0 (Debian 1:6.2+dfsg-2ubuntu6.31)

Recipe 1 — Docker cross-compile (bare-metal ARM Cortex-M) ✅ executed

littlefs (a real fail-safe filesystem for MCUs) compiled to a Cortex-M4 static archive inside the toolchain image; the test = step asserts the produced object is genuinely ARM.

[repo.littlefs]
url    = "https://github.com/littlefs-project/littlefs.git"
rev    = "master"
groups = ["firmware"]
build  = "docker run --rm -v \"$PWD\":/w -w /w haw-arm-gcc sh -c 'arm-none-eabi-gcc -mcpu=cortex-m4 -mthumb -Os -Wall -c lfs.c lfs_util.c && arm-none-eabi-ar rcs lfs-cortexm4.a lfs.o lfs_util.o && arm-none-eabi-size lfs-cortexm4.a'"
test   = "docker run --rm -v \"$PWD\":/w -w /w haw-arm-gcc sh -c 'arm-none-eabi-objdump -f lfs.o | grep -i \"architecture: arm\" && echo CONFIRMED_ARM_CORTEX_M_OBJECT'"

[stack.fw]
repos = ["littlefs"]

Real captured output (haw build then haw test, both exit 0):

$ haw build
── littlefs ──
   text    data     bss     dec     hex  filename
  21880       0       0   21880    5578  lfs.o (ex lfs-cortexm4.a)
    120       0       0     120      78  lfs_util.o (ex lfs-cortexm4.a)
build ran in 1/1 repos

$ haw test
── littlefs ──
architecture: armv7e-m, flags 0x00000011:
CONFIRMED_ARM_CORTEX_M_OBJECT
test ran in 1/1 repos

armv7e-m is the Cortex-M4 ISA — a genuine bare-metal ARM object, produced by arm-none-eabi-gcc inside Docker, orchestrated by haw, with a native-machine haw.


Recipe 2 — QEMU emulated run (FreeRTOS on Cortex-M3) ✅ executed

The official FreeRTOS QEMU demo built with the FreeRTOS-Kernel, then booted on qemu-system-arm -M mps2-an385 -cpu cortex-m3. The scheduler runs the blinky demo (a task + a software timer feeding a queue) and prints over the semihosted UART; test = greps for a live marker, so a genuinely running RTOS exits 0 and a dead image exits 1.

[repo.freertos]
url    = "https://github.com/FreeRTOS/FreeRTOS.git"
rev    = "main"
groups = ["rtos"]
build  = "docker run --rm -v \"$PWD\":/w -w /w haw-arm-emu make -C FreeRTOS/Demo/CORTEX_MPS2_QEMU_IAR_GCC/build/gcc"
test   = "docker run --rm -v \"$PWD\":/w -w /w haw-arm-emu sh -c 'timeout 8 qemu-system-arm -machine mps2-an385 -cpu cortex-m3 -kernel FreeRTOS/Demo/CORTEX_MPS2_QEMU_IAR_GCC/build/gcc/output/RTOSDemo.out -monitor none -nographic -serial stdio -semihosting-config enable=on,target=native 2>&1 | head -40 | grep -q \"Message received from task\" && echo QEMU_FREERTOS_RUN_CONFIRMED'"

[stack.rtos]
repos = ["freertos"]

Real captured output:

$ haw build        # relinks output/RTOSDemo.out
   text    data     bss     dec     hex  filename
  23902     232  121589  145723   2393b  ./output/RTOSDemo.out
build ran in 1/1 repos

$ haw test         # FreeRTOS scheduler actually executing under QEMU
── freertos ──
Message received from task
Message received from task
... (37 total, plus 3 "Message received from software timer") ...
QEMU_FREERTOS_RUN_CONFIRMED
test ran in 1/1 repos

Negative control verified: point the grep at a marker that never appears and the QEMU step exits 1 — so haw test genuinely fails if the RTOS doesn’t boot, rather than always passing.

Submodules are fault-tolerant. haw sync --recurse-submodules initializes each submodule independently and skips broken/unreachable ones with a warning instead of aborting — important for big upstream repos like FreeRTOS/FreeRTOS that declare a large forest of heavy submodules (wolfSSL, several AWS IoT SDKs, …). The kernel your target needs (FreeRTOS/Source) is initialized; a submodule that 404s just prints haw: skipped submodule '<path>': … and the sync still succeeds. (ext/fd/file transports stay hard-disabled in submodule fetches — the RCE guard is preserved.)

Swap QEMU for Renode with one line — test = "renode --console -e 'include @sim.resc; start; sleep 5; quit'" then grep the UART log the same way.


More validated embedded fleets ✅ executed

Beyond the single-repo cross-compile/QEMU recipes above, the examples/embedded-real/ manifest composes five real public embedded/safety-critical upstreams into one fleet and builds them all with a single haw build -j4. Every command in that manifest was actually run and seen to succeed on a clang/cmake/make host:

RepoDomainResult
CoreMarkbenchmarkbuilds and runs the benchmark
cJSONdatabuilds + ctest 19/19
Monocyphercryptobuilds libmonocypher.a
libcanardprotocolC11 compile-check passes
Mbed-TLSsecuritybuilds libmbedcrypto/tls/x509.a (needs submodules)

Real captured output (haw test over the fleet, exit 0):

── coremark ──
CoreMark 1.0 : 26021.337497 / Apple LLVM 17.0.0 (clang-1700.0.13.5) -O2 -DPERFORMANCE_RUN=1   / Heap
COREMARK_RAN
── cjson ──
100% tests passed out of 19
── monocypher ──
MONOCYPHER_LIB_OK
test ran in 3/3 repos

Mbed-TLS declares its framework/ and tf-psa-crypto/ as git submodules; haw sync --recurse-submodules initializes them (fault-tolerant — a broken submodule is skipped with a warning, not fatal). The same cJSON checkout also cross-compiles to a bare-metal Cortex-M4 object via haw-arm-emu (architecture: armv7e-m), so one fleet spans a host build and a cross build. Full per-repo table, prerequisites, and the cross recipe are in the example README.


Patterns for licensed / proprietary toolchains ⚠️ not run here

These are the exact wiring shapes for commercial automotive/safety tools. We can’t run them (licensed) — the flags follow each vendor’s batch/CLI interface; adjust to your project per the vendor docs. The point: it’s the same build = shell-out.

The AUTOSAR mental model per ECU repo: config (ARXML) → vendor generator → generated BSW/RTE C → vendor compiler → ELF, all pinned in haw.lock.

# EB tresos (Elektrobit) — generate BSW from config, then compile
[repo.ecu-comfort]
url    = "git@gitlab.company.com:ecu/comfort-bsw.git"
rev    = "release/2.4"
groups = ["ecu", "autosar"]
build  = "$TRESOS_BASE/bin/tresos_cmd.sh -p ComfortEcu generate && make -C output"
test   = "make -C output test"     # VectorCAST / Tessy / your MCAL harness

[plugins]
misra  = ["pre-request"]   # gate generated + hand code on MISRA C
aspice = ["post-land"]     # emit ASPICE traceability as the change lands
# Vector MICROSAR / DaVinci Configurator Pro
build = "DVConfiguratorCmd -d PowertrainEcu.dpa --generateAll && make"

# Compilers (drop-in — one string each; a fleet can mix them)
build = "gbuild -top default.gpj"                 # Green Hills MULTI (ccarm)
build = "iarbuild MyProject.ewp -build Release"   # IAR Embedded Workbench (iccarm)
build = "amk -f project.mk"                        # TASKING (cctc / carm)
build = "make CC=dcc"                              # Wind River Diab

# Zephyr RTOS — west drives the board build + QEMU/renode run itself
build = "west build -b qemu_cortex_m3 samples/hello_world"
test  = "west build -t run"        # or: twister

Because each is just a string, one haw.toml can compose a GHS ECU next to an IAR ECU next to a gcc gateway, and haw build -j8 builds them all in parallel with correct per-repo pass/fail and a non-zero exit if any breaks — your CI gate. (haw can also import a Zephyr west.yml / Google repo manifest: haw import --from west.yml.)


Why this matters for regulated work

  • Reproducible. Every config/BSW/RTE repo is pinned to an exact SHA in haw.lock — the generated code is auditable and byte-identical rebuild-to-rebuild (the argument for ASPICE / ISO 26262 / DO-178C).
  • Orchestrated. haw runs generate → compile → emulate across the whole ECU fleet in parallel, with a CI exit code, using your licensed tools unchanged.
  • Governed. Plugins on lifecycle phases produce the qualification work products — misra (pre-request gate), aspice / SBOM / provenance / signing (post-build/post-land), haw evidence bundles. See Plugins, Domains, Compliance.

You don’t adapt your toolchain to haw. You put its command in build =, and haw drives the fleet.

Domains

haw composes, orchestrates, and ships change across many Git repos — in any domain. Nothing in the manifest, the lockfile, the changeset flow, the fleet-wide build/test, or the governance hooks is specific to one industry. A repo is a repo; a build is whatever shell command you declare; a PR is a PR.

This page shows how the same loop —

compose (manifest) → pin (lock) → change (changeset) → build/testgovern (hooks + evidence)

— maps onto five different worlds. Embedded/automotive is one proof point among several, not haw’s identity.

Each section names the pain, then the mapping. Illustrative manifests live under examples/.


Backend microservices

The pain. A single user-facing feature spans four services and a shared protobuf/lib repo. You branch each by hand, open four PRs, and try to remember to land them in the right order without breaking main. There is no single artifact that says “these five SHAs are the feature.”

How haw maps:

  • Manifest / lock. Declare the services plus the shared proto repo; commit haw.lock so CI and every teammate resolve the identical set of SHAs.
  • Changeset. haw change start FEAT-42 --repos api,billing,proto creates one branch across exactly the repos the feature touches; haw change request opens the linked PRs, haw change status aggregates review + CI, and haw change land merges them in deps order (proto before its consumers).
  • Build / test. Each service declares its own build/test (cargo, go test, npm test, ./gradlew); haw build -j N fans them out in parallel and exits nonzero if any fails — drop it straight into a pipeline.
  • Govern. A pre-request gate can enforce policy (secret scan, license check) before any PR opens.

See examples/microservices/.


ML / data platforms

The pain. A model repo, the data-pipeline repo that feeds it, and the serving-infra repo that deploys it drift apart. Reproducing “the model that was live in March” means guessing which commit of the pipeline produced it.

How haw maps:

  • Manifest / lock. Pin the model, pipeline, and serving repos together to exact SHAs. The committed lockfile is the reproducible baseline — a clone months later resolves the same three trees.
  • Stacks / overlays. A training stack and a serving stack can share the pipeline repo without duplication; an overlay follows main for the model while everything else stays pinned.
  • Build / test. Declare test = "pytest" on the pipeline, build = "dvc repro" or a training command on the model, and an infra plan/apply on serving — haw shells out, it never bundles a toolchain.
  • Govern. SBOM + provenance hooks record exactly which model, data, and infra SHAs shipped together — the audit trail for an ML release.

See examples/ml-platform/.


Platform / infra

The pain. Terraform root modules, reusable submodules, and Helm charts live in separate repos. A change to a shared module needs coordinated bumps across every consumer, and “what was deployed” is spread across N repos at N revisions.

How haw maps:

  • Manifest / lock. Compose the module and chart repos into one pinned fleet; the lock is the deployed-baseline record.
  • Changeset. Bump a shared module and its consumers on one branch across repos, PR them together, and land in dependency order.
  • Build / test. Declare test = "terraform validate" / "helm lint" and a build/plan command per repo; haw test runs the whole fleet’s checks in parallel.
  • Govern. haw verify (exit 3 on drift) is a CI gate that fails if the checked-out tree no longer matches the pinned infra baseline.

See examples/microservices/ for the changeset pattern — the same shape applies to Terraform/Helm repos.


Mobile

The pain. An app repo depends on an in-house SDK repo. A feature needs a change in both, released in lockstep, but the two repos have independent branches, PRs, and CI.

How haw maps:

  • Manifest / lock. Pin the app and SDK repos together; the lock guarantees the app builds against the exact SDK commit under test.
  • Changeset. One branch across app + SDK, cross-linked PRs, landed SDK-first via deps.
  • Build / test. Declare the Gradle/Xcode/fastlane commands per repo; haw build drives both.
  • Govern. Signing + SBOM hooks capture what shipped in a release.

Embedded & automotive

The pain. A shared HAL/BSP/MCAL is reused across many ECUs; AUTOSAR configuration lives in ARXML repos that must stay pinned beside the code; and the whole thing has to be reproducible and auditable for functional-safety qualification.

How haw maps:

  • Manifest / lock. Pin BSW/MCAL to exact tags/SHAs and pin the AUTOSAR ARXML config repos in the lock alongside them — the baseline is the audit evidence, byte-for-byte reproducible years later.

  • Stacks / overlays. Multiple ECUs (gateway, body, …) share one BSW+MCAL foundation as separate stacks, no duplication; overlays swap a variant’s revisions without rewriting the repo list.

  • Changeset. A cross-ECU fix branches across the affected repos and lands in deps order (base software before the ECU apps that depend on it).

  • Build / test — toolchain-agnostic. haw shells out to the declared build/test command per repo and bundles no compiler. That means it drives whatever the repo declares:

    • Vector MICROSAR / DaVinci configuration + generation steps,
    • Elektrobit (EB) tresos generators,
    • Green Hills (MULTI / ccrh, …), IAR (iccarm), Tasking, Wind River Diab, and arm-none-eabi-gcc compilers.

    Each is named in that repo’s build = / test =; haw never bundles or requires any of them.

  • Govern — standards mapping. The governance hooks map directly onto the standards work:

    Standard / artifactHow haw covers it
    Automotive SPICE (ASPICE)haw-aspice emits repo → pinned SHA → process-area traceability
    MISRA Chaw-misra runs cppcheck --addon=misra across the fleet as a pre-request gate
    ISO 26262 / DO-178Chaw evidence bundles (manifest + lock + audit + status) plus SBOM + provenance from the governance plugins
    AUTOSAR ARXMLconfig repos pinned to exact SHAs in haw.lock, versioned with the code they configure

See examples/automotive/ and examples/automotive-pinned/.


The common thread

Across all five, the moving parts are identical — only the repos and the declared build/test commands change:

Loop stageBackendML / dataInfraMobileEmbedded / automotive
Composeservices + shared protomodel + pipeline + servingmodules + chartsapp + SDKBSW/MCAL + ARXML + ECU apps
Pinreproducible feature setreproducible model baselinedeployed baselineapp↔SDK lockstepaudit baseline
Changefeature across servicesmodel + pipeline togethermodule + consumersapp + SDKcross-ECU fix
Build/testcargo/go/npmpytest/dvcterraform/helmGradle/XcodeVector/EB/GHS/IAR/gcc
Governpolicy gateSBOM/provenancedrift verifysigningASPICE/MISRA/26262/DO-178C

One binary, one loop, every domain.

Distributing hawser to private registries

Every tagged release publishes a signed GitHub Release first. After that succeeds, the distribute job in .github/workflows/release.yml mirrors the same artifacts to any of four private registries — Nexus, Artifactory, GitLab, and Bitbucket — for organizations that install from an internal mirror rather than from GitHub.

Each registry is opt-in: a target is only attempted when its secrets are configured. A repo with no secrets set still releases successfully — the distribute job logs a clear skipping <target>: secret not set and the whole job is continue-on-error, so a registry outage can never fail the release.

What gets uploaded

For every configured registry, the job uploads the full artifact set for the release:

  • haw-<version>-<target>.tar.gz / .zip — the platform archives
  • haw-<version>-<target>.<ext>.sha256 — SHA-256 checksums
  • haw-<version>-<target>.<ext>.sig and .pem — cosign keyless signature + certificate
  • hawser_<version>-1_amd64.deb and hawser-<version>-1.x86_64.rpm — Linux packages

These are the identical files attached to the GitHub Release, so checksums and cosign signatures verify the same way regardless of which mirror you pulled from (see INSTALL.md → Verify).

Secret matrix

Configure these as GitHub Actions repository (or organization) secrets. Only the registries whose required secrets are present will be published to.

RegistrySecretRequired?DefaultPurpose
NexusNEXUS_URLrequiredBase URL, e.g. https://nexus.example.com
NEXUS_USERrequiredUsername
NEXUS_PASSrequiredPassword / token
NEXUS_REPOoptionalraw-hostedRaw hosted repo name
ArtifactoryARTIFACTORY_URLrequiredBase URL, e.g. https://artifactory.example.com/artifactory
ARTIFACTORY_TOKENrequiredBearer / identity token
ARTIFACTORY_REPOoptionalgeneric-localGeneric repo key
GitLabGITLAB_TOKENrequiredPersonal/project access token (api scope)
GITLAB_PROJECT_IDrequiredNumeric project ID
GITLAB_URLoptionalhttps://gitlab.comSelf-managed instance base URL
BitbucketBITBUCKET_USERrequiredUsername
BITBUCKET_TOKENrequiredApp password / access token
BITBUCKET_WORKSPACErequiredWorkspace slug
BITBUCKET_REPOrequiredRepository slug

A registry is skipped (logged, not failed) unless all its required secrets are set.

Per-registry layout and install

Throughout, <version> is the tag without the leading v (e.g. 0.1.7).

Nexus (raw hosted repository)

Each file is PUT to a raw hosted repo under a versioned path:

<NEXUS_URL>/repository/<NEXUS_REPO>/haw/<version>/<file>

Upload (what CI runs, per file):

curl -u "$NEXUS_USER:$NEXUS_PASS" \
  --upload-file haw-0.1.7-x86_64-unknown-linux-musl.tar.gz \
  "$NEXUS_URL/repository/raw-hosted/haw/0.1.7/haw-0.1.7-x86_64-unknown-linux-musl.tar.gz"

Consume:

curl -u "$NEXUS_USER:$NEXUS_PASS" -O \
  "$NEXUS_URL/repository/raw-hosted/haw/0.1.7/haw-0.1.7-x86_64-unknown-linux-musl.tar.gz"
tar xzf haw-0.1.7-x86_64-unknown-linux-musl.tar.gz && sudo install haw /usr/local/bin/

Artifactory (generic repository)

Each file is PUT (Bearer auth) to a generic repo under a versioned path:

<ARTIFACTORY_URL>/<ARTIFACTORY_REPO>/haw/<version>/<file>

Upload (per file):

curl -H "Authorization: Bearer $ARTIFACTORY_TOKEN" \
  --upload-file haw-0.1.7-x86_64-unknown-linux-musl.tar.gz \
  "$ARTIFACTORY_URL/generic-local/haw/0.1.7/haw-0.1.7-x86_64-unknown-linux-musl.tar.gz"

Consume:

curl -H "Authorization: Bearer $ARTIFACTORY_TOKEN" -O \
  "$ARTIFACTORY_URL/generic-local/haw/0.1.7/haw-0.1.7-x86_64-unknown-linux-musl.tar.gz"

GitLab (generic package registry + Release)

Two things happen. First, each file is PUT to the project’s generic package registry:

<GITLAB_URL>/api/v4/projects/<GITLAB_PROJECT_ID>/packages/generic/haw/<version>/<file>

Then a GitLab Release is created for the tag, with assets.links[] pointing at each uploaded package file (an existing release for the tag is tolerated, not an error).

Upload (per file):

curl --header "PRIVATE-TOKEN: $GITLAB_TOKEN" \
  --upload-file haw-0.1.7-x86_64-unknown-linux-musl.tar.gz \
  "https://gitlab.com/api/v4/projects/$GITLAB_PROJECT_ID/packages/generic/haw/0.1.7/haw-0.1.7-x86_64-unknown-linux-musl.tar.gz"

Consume:

curl --header "PRIVATE-TOKEN: $GITLAB_TOKEN" -O \
  "https://gitlab.com/api/v4/projects/$GITLAB_PROJECT_ID/packages/generic/haw/0.1.7/haw-0.1.7-x86_64-unknown-linux-musl.tar.gz"

Or open the project’s Deploy → Releases page and download from the release assets.

Bitbucket (repository Downloads)

Each file is POSTed (multipart) to the repository’s Downloads area:

https://api.bitbucket.org/2.0/repositories/<BITBUCKET_WORKSPACE>/<BITBUCKET_REPO>/downloads

Upload (per file):

curl -u "$BITBUCKET_USER:$BITBUCKET_TOKEN" \
  -X POST \
  "https://api.bitbucket.org/2.0/repositories/$BITBUCKET_WORKSPACE/$BITBUCKET_REPO/downloads" \
  -F files=@haw-0.1.7-x86_64-unknown-linux-musl.tar.gz

Consume (files land under the repo’s Downloads tab; filenames are flat, not versioned):

curl -u "$BITBUCKET_USER:$BITBUCKET_TOKEN" -O -L \
  "https://bitbucket.org/$BITBUCKET_WORKSPACE/$BITBUCKET_REPO/downloads/haw-0.1.7-x86_64-unknown-linux-musl.tar.gz"

Bitbucket Downloads is a flat namespace (no per-version folders), so the <version> is carried in the filename itself.

haw publish — upload artifacts from the CLI

The CI distribute job mirrors release archives, but you can push any artifacts (build outputs, an evidence bundle, an SBOM) to the same four registries yourself with haw publish. It uses the identical upload paths and auth as CI, reading credentials from the same environment variables.

haw publish <files…> --to <nexus|artifactory|gitlab|bitbucket>
            [--name <NAME>] [--version <VER>] [--url <URL>]
            [--dry-run] [--insecure] [--format json]
FlagMeaning
<files…>Files or globs to upload. Defaults to haw-evidence.tar.gz if present and no files are given.
--toTarget registry: nexus, artifactory, gitlab, or bitbucket (required).
--namePackage name. Default: the current stack, else the workspace directory name.
--versionPackage version. Default: the short HEAD SHA, else unversioned.
--urlOverride the target’s base URL (else taken from the target’s env var).
--dry-runPrint exactly what would upload (method, URL, auth slot) and exit — no network, no credentials needed.
--insecureAllow a non-HTTPS (http://) registry. By default http:// registries are rejected; without this flag haw publish refuses to send credentials in cleartext.
--format jsonEmit a JSON summary {target, name, version, uploads:[…]}.

Credentials come from the environment, per target (same variables as the CI secret matrix above):

TargetEnv vars
NexusNEXUS_URL, NEXUS_USER, NEXUS_PASS, optional NEXUS_REPO (default raw-hosted)
ArtifactoryARTIFACTORY_URL, ARTIFACTORY_TOKEN, optional ARTIFACTORY_REPO (default generic-local)
GitLabGITLAB_TOKEN, GITLAB_PROJECT_ID, optional GITLAB_URL (default https://gitlab.com)
BitbucketBITBUCKET_USER, BITBUCKET_TOKEN, BITBUCKET_WORKSPACE, BITBUCKET_REPO
haw publish ./out/*.bin --to nexus                 # upload build outputs to Nexus raw-hosted
haw publish --to gitlab                             # upload haw-evidence.tar.gz to GitLab packages
haw publish sbom.json haw-evidence.tar.gz --to artifactory   # several files at once
haw publish app.bin --to bitbucket                  # POST to the Bitbucket repo Downloads
haw publish app.bin --to nexus --dry-run            # print the plan (method/URL/auth), no network
haw publish app.bin --to nexus --format json        # machine-readable upload summary

Verifying after download

Regardless of the mirror, verify exactly as with the GitHub Release — download the matching .sha256, .sig, and .pem alongside the archive:

sha256sum -c haw-0.1.7-x86_64-unknown-linux-musl.tar.gz.sha256
cosign verify-blob \
  --certificate haw-0.1.7-x86_64-unknown-linux-musl.tar.gz.pem \
  --signature   haw-0.1.7-x86_64-unknown-linux-musl.tar.gz.sig \
  --certificate-identity-regexp 'https://github.com/Nastwinns/hawser' \
  --certificate-oidc-issuer https://token.actions.githubusercontent.com \
  haw-0.1.7-x86_64-unknown-linux-musl.tar.gz

See INSTALL.md for the full verification and air-gap workflow.


Back to INSTALL.md.

hawser Architecture

hawser is a multi-repo workspace manager. It pins a fleet of git repositories to exact commits, drives their PR/MR lifecycle across GitHub and GitLab, and presents the whole fleet through a keyboard-first ratatui cockpit (the haw binary). This document describes how the code is actually organized — the crate boundaries, the concurrency model, and the invariants that keep it testable.

The design goal that shapes everything below: domain logic never does I/O it can’t fake. Git side effects cross the GitBackend trait, forge calls cross the Forge trait, and the TUI’s side effects cross the Controller trait. Each seam has a production impl and a test fake, so the bulk of the code is unit-tested without a network or a real git.

1. Overview

The workspace (Cargo.toml, resolver = "3", edition 2024, unsafe_code = "forbid" workspace-wide) is a set of small crates. haw-core holds the domain model and depends on nothing but serialization crates. Everything else fans out from it. The binary hawser (shipping as haw) is the only crate that wires the pieces together and the only place anyhow is allowed.

                    +----------------------+
                    | haw-tui              |   depends ONLY on
                    | (ratatui cockpit)    |   ratatui + nucleo + haw-core types
                    +----------+-----------+   — no git, no network.
                               | Controller trait (side-effect seam)
                               v
+---------+   +----------+   +----------+   +-----------+   +-----------+
| hawser  |-->| haw-core |   | haw-git  |   | haw-forge |   | haw-merge |
| (bin)   |   | (domain) |<--| ShellGit |   | Forge     |   | slice/seal|
+----+----+   +----------+   +----------+   +-----------+   +-----------+
     |            ^   ^            (impl of        (octocrab /
     |            |   |         GitBackend)         reqwest)
     +---> haw-git, haw-forge, haw-merge, haw-tui  (binary wires them all)

haw-plugin ....... SDK for out-of-process `haw-<name>` plugin binaries
haw-artifact ..... plugin: SLSA/in-toto provenance + signing
haw-compliance ... plugin: CycloneDX 1.5 + SPDX 2.3 SBOM
haw-git-gate ..... plugin: secret/hygiene gate (gitleaks or heuristic)
xtask ............ release/packaging automation (`cargo xtask dist`)

haw-core has zero dependency on haw-git, haw-forge, or haw-tui: it declares the GitBackend trait (haw-core/src/git/mod.rs) and lets callers inject an implementation. haw-git depends on haw-core and implements that trait; the arrow points from the impl to the trait.

2. Crate responsibilities

CrateResponsibilityKey public types
haw-coreDomain model, no I/O opinions. Manifest → lock → workspace state, resolution, snapshots, audit log, plugin dispatch.manifest::Manifest, lock::Lockfile / LockedRepo, workspace::Workspace / RepoStatus / SyncPlan / RepoTask, resolver::ResolvedRepo, git::GitBackend (trait)
haw-gitProduction GitBackend: shells out to the user’s git. Bounded fan-out helper.ShellGit, parallel::fan_out
haw-forgePR/MR + CI orchestration behind the Forge trait; forge-agnostic changeset lifecycle.Forge (trait), github::GitHub, gitlab::GitLab, ForgeFactory / Tokens, ForgeError, OpenPr / CiRun / PrStatus
haw-mergeOptional mergetopus-style collaborative merge: slice a conflict-heavy merge by top-level path, resolve piecewise, seal into one commit on an integration branch.MergeBackend (trait), git::GitMerge
haw-tuiThe haw cockpit. Renders and dispatches; knows nothing about git or the network.Controller (trait), run, Snapshot, FleetPr / FleetCiRun, Exit
haw-pluginSDK for authoring out-of-process haw-<name> plugin binaries (haw.plugin/1 context in/haw.plugin.report/1 out).run, Report
hawserThe haw binary. Clap CLI, wires core+git+forge+merge+tui, owns anyhow and the actionable error surface.CliController, DemoController, main
xtaskRelease/packaging: build a release binary, archive under dist/, print SHA-256 for the Homebrew formula / Scoop manifest.main

3. The Controller boundary

haw-tui’s dependencies are ratatui, nucleo-matcher, and haw-core (for the plain data types it renders). It has no dependency on haw-git, haw-forge, or tokio. Every side effect the cockpit needs — refreshing status, fetching PRs, merging, checking out a branch, reading a file tree — is a method on Controller (haw-tui/src/lib.rs:351):

#![allow(unused)]
fn main() {
pub trait Controller: Send {
    fn snapshot(&mut self) -> io::Result<Snapshot>;
    fn fleet_prs(&mut self) -> io::Result<Vec<FleetPr>>;
    fn pr_merge(&mut self, repo: &str, number: u64) -> io::Result<String>;
    fn pr_checkout(&mut self, repo: &str, number: u64) -> io::Result<String>;
    fn repo_tree(&mut self, repo: &str, subpath: &str, remote: bool) -> io::Result<Vec<FileEntry>>;
    fn file_content(&mut self, repo: &str, path: &str, remote: bool) -> io::Result<String>;
    // ~25 verbs total; all return io::Result and all are Send.
}
}

This is a deliberate dependency inversion. The TUI is the top of the graph and depends only on an abstraction; the binary supplies the concrete implementation:

  • CliController (hawser/src/main.rs:2295) is the production impl. Each method opens a Workspace, builds a ShellGit backend and/or a Forge client, and runs the same code paths the CLI subcommands use (e.g. sync_filtered reuses Workspace::plan_sync + fan_out).
  • DemoController (hawser/src/main.rs:3052) returns canned in-memory data and reaches no workspace, git, or network. haw dash --demo renders every view deterministically for GIF recordings and, crucially, for tests.

Because the seam is a trait object (Box<dyn Controller> passed to haw_tui::run), the 59 unit tests in haw-tui construct App fixtures and assert on rendered spans and on the Jobs dispatched to the worker channel — no terminal, no git, no sockets. The cockpit’s logic is exercised headlessly.

4. Concurrency model

The cockpit is single-threaded for rendering and drives all blocking work on one dedicated worker thread. There is no async in the UI at all.

Why not full async. The forge clients and git shell-outs are inherently blocking; making the whole UI async would buy nothing but a runtime and colored functions. Instead the render loop stays synchronous and offloads blocking work to a thread, communicating over two std::sync::mpsc channels.

The worker. haw_tui::run (haw-tui/src/lib.rs:886) creates a Job channel and an Outcome channel, then spawn_worker (:914) moves the Box<dyn Controller> onto a std::thread::spawn. The worker is a serial loop: while let Ok(job) = jobs.recv(), matching each Job to a Controller call and sending back an Outcome. Serialization is a feature: entering a view while a job is in flight still enqueues the read; the worker runs it after the current one (comments at :1005, :1144), so navigation is never refused.

The channel enums (:426, :463):

#![allow(unused)]
fn main() {
enum Job {
    Refresh, ChangesetPrs(String), FleetPrs, FleetCi, Governance,
    RepoDetail(String), PrDetail(String, u64), CiDetail(String, u64),
    PrDiff(String, u64), CiLogs(String, u64),
    RepoTree(String, String, bool), FileContent(String, String, bool, String),
    Action(&'static str, ActionKind),   // side-effecting verbs (sync, land, merge, ...)
}

enum Outcome {
    Snapshot(Box<io::Result<Snapshot>>), FleetPrs(Box<io::Result<Vec<FleetPr>>>),
    Detail(String, Box<io::Result<String>>),   // shared drill-in (repo git / PR / CI)
    Tree(Box<io::Result<Vec<FileEntry>>>),
    Action(&'static str, io::Result<String>),
    // ...
}
}

Results are boxed to keep the enum small despite carrying large payloads (snapshots, diffs). The &'static str label on Action flows through unchanged so the outcome handler knows what completed.

Staying responsive. The event loop (event_loop, :1345) never blocks on the worker. Each iteration: drain outcomes.try_recv() (non-blocking) and apply results, opportunistically auto-refresh when idle (5s cadence, suppressed during input/overlays/in-flight work, :1525), draw one frame, then event::poll(Duration::from_millis(120)) for input. app.busy: Option<&'static str> gates a spinner and prevents double-dispatch; it is cleared when the matching Outcome arrives. Network views (Prs/Ci/Governance) are strictly on-demand — the idle auto-refresh only touches the local status snapshot.

Separately, cross-repo CLI work (sync, run) uses haw_git::parallel::fan_out (haw-git/src/parallel.rs): bounded fan-out across repos with plain std::thread::scope and a shared atomic index, jobs.clamp(1, items.len()) workers. No tokio there either.

The octocrab runtime.enter() gotcha

Forge is a synchronous trait, but octocrab is async. github::GitHub owns a private tokio::runtime::Builder::new_current_thread().enable_all().build() runtime (haw-forge/src/github.rs:16) and calls runtime.block_on(...) for each request — a synchronous facade over an async client, one worker thread, no shared global runtime.

The subtle bug this guards against: building the octocrab client is not itself an await, but internally it spawns a tower::buffer worker task, and that spawn panics with “no reactor running” if there is no live Tokio reactor in the current thread’s context. block_on establishes that context only for the future it drives — not for the synchronous builder.build() call. The fix is the guard in client (github.rs:32):

#![allow(unused)]
fn main() {
// octocrab's client spawns a tower::buffer worker on build, which needs a
// live Tokio reactor; enter the runtime so the spawn doesn't panic.
let _guard = self.runtime.enter();
builder.build()...
}

runtime.enter() returns an EnterGuard that installs the reactor for the current scope, so the tower::buffer spawn finds a reactor. A dedicated regression test builds the client with an empty token and no network to keep this from silently breaking again (github.rs:626, client_builds_inside_runtime_without_panic).

5. The Forge abstraction

Forge (haw-forge/src/lib.rs:130) is one trait with two production impls:

  • github::GitHuboctocrab (REST v3) over the private current-thread runtime described above. Supports github.com and Enterprise (/api/v3 base).
  • gitlab::GitLabreqwest::blocking::Client against REST v4. No runtime needed; MRs map onto the forge-neutral PR vocabulary.

ForgeFactory::client_for (impl Tokens, :273) picks the impl from the manifest’s explicit forge = key if present, else by URL host substring (detect, :347), reads tokens from the conventional env vars (falling back to a logged-in gh auth token, :261), and returns a Box<dyn Forge>.

Cheap-list vs detail-drill. The trait splits deliberately into cheap fleet scans and expensive drill-ins so the fleet views load fast and detail is fetched only on Enter:

  • list_open_prs / list_ci_runs — one bounded call per repo, capped at OPEN_PRS_LIMIT = 25 / CI_RUNS_LIMIT = 15 to keep request counts bounded on busy repos. Returns forge-neutral OpenPr / CiRun rows.
  • pr_detail / ci_run_detail / pr_diff / ci_logs / file_blob — the drill-in fetches, each returning plain text capped by DIFF_LINE_CAP = 600, LOG_LINE_CAP = 800, FILE_LINE_CAP = 600 via cap_lines (which appends a “truncated, N more line(s)” note).

Media-type handling. octocrab decodes JSON, but diffs, raw blobs, and logs are plain text. GitHub::get_text (github.rs:77) sidesteps octocrab with a small blocking reqwest GET carrying a custom Accept header and following redirects, returning Ok(None) on 404:

  • unified diffs: Accept: application/vnd.github.v3.diff (the pulls endpoint returns the diff verbatim);
  • raw file contents: application/vnd.github.raw;
  • Actions job logs: served via a 302 redirect to a signed URL (expired logs surface as a clear message, not an error).

The plain-text-report contract (no ANSI; the caller styles it) is what lets the same detail strings render identically in the CLI and in the TUI’s scrollable detail view.

6. Reproducibility model

The core contract is a three-stage pipeline: manifest → lock → state.

  1. haw.toml (manifest)manifest::Manifest (model.rs:16): remotes, repos, stacks, overlays. Human-authored intent. A repo’s rev is a branch/tag/sha reference.
  2. haw.lock (lockfile)lock::Lockfile (lock/mod.rs:34), LOCK_VERSION = 1, #[serde(deny_unknown_fields)]. Machine-generated. Each LockedRepo pins rev (the exact resolved SHA), source_rev (the manifest ref it was resolved from), and branch (repos are never left detached). The lock covers all repos in the manifest, not just one stack — so switching stacks never rewrites the lock; overlays only take effect on regeneration.
  3. Workspace state — the .haw/ directory: current stack, snapshots, audit log. Workspace (workspace/mod.rs:54) reads the manifest + lock and plans sync (plan_syncSyncPlan of RepoTasks targeting each locked SHA).

The lock is the proof/audit artifact. It is deterministic and LF-only — a golden test (hawser/tests/golden.rs::lockfile_is_deterministic_and_lf_only) asserts identical bytes across two runs on the same inputs, no CRLF, trailing newline. That determinism is what makes the lock committable and diffable as a build-provenance record; haw-compliance and haw-artifact consume the pinned revs directly to emit SBOMs and SLSA provenance.

Drift. RepoStatus (workspace/mod.rs:99) carries head (the repo’s actual HEAD), locked_rev (what haw.lock says), and drift: bool — true when HEAD differs from the locked rev. Workspace::status computes it per repo; haw status/haw verify and the cockpit’s fleet grid surface it. pin (:257) does the inverse: rewrite the lock from current HEADs (no network), turning the working state into the new pinned truth.

7. Error handling

Two-layer strategy, split cleanly at the binary boundary:

  • Libraries use typed errors via thiserror: ForgeError (haw-forge/src/lib.rs:116MissingToken, UnknownForge, Api, …), LockError, WorkspaceError / SyncError, GitError, ManifestError, MergeError. Callers can match on the variant. The Controller trait narrows these to io::Result at the TUI seam (io::Error::other), because the cockpit only ever renders the message.
  • The binary uses anyhow. hawser is the only crate that depends on anyhow; run() returns anyhow::Result and adds .context(...) at call sites. main (main.rs:609) prints the top-level error:, walks err.chain().skip(1) for causes, and — the actionable part — runs hint_for (:632) over the lowercased error text to attach a one-line fix: no manifest → haw init; missing token → set HAW_GITHUB_TOKEN / gh auth login; “drift”/“lock” → haw sync; “not a git repo” → haw sync to clone.

Workspace lints (Cargo.toml) set clippy::unwrap_used and clippy::expect_used to warn across the workspace; test modules opt back in with #![cfg_attr(test, allow(...))].

8. Testing

~173 tests, layered to match the seams:

  • Pure/unit in each crate: haw-core (manifest edit, lock round-trips, change lifecycle, resolver, snapshots), haw-forge (repo_coords for every URL shape, detect, cap_lines, progress_bar).
  • haw-tui (59 tests) drive App state fixtures (e.g. fleet_app()) and assert two things: rendered Span contents/colors from the pure draw_* helpers, and the exact Job dispatched onto the worker channel after a keypress (rx.try_recv()Ok(Job::FleetPrs) etc.). No terminal, no network — the Controller seam and the channel make the whole cockpit headlessly testable.
  • FakeForge / FakeGit (haw-forge/tests/orchestrate.rs): the changeset orchestration (request/status/land) runs against in-memory fakes injected via FakeFactory, so cross-repo lifecycle logic is verified with no HTTP.
  • Golden end-to-end (hawser/tests/golden.rs): builds real git repos in tempdirs, runs the actual haw binary, and asserts normalized stdout against golden strings — tree, status + the dirty-repo exit-code-3 CI contract, sync, the stable haw.status/1 JSON schema, and lockfile determinism. These run on the CI matrix, so passing means the shipped binary behaves.

The Forge/GitBackend/Controller triad is the reason this coverage is cheap: every expensive dependency has a fake, and the one place they’re wired to real I/O — hawser — is covered by the golden binary tests.

CLI design — lexicon & options

Goal: a lexicon a new user understands without a glossary, and options that fix what repo/west users always missed.

Lexicon (canonical since v0.1)

TermMeaningReplaces / rejected
repoone Git repository in the workspace ([repo.NAME])brick (accepted alias), project (repo-tool jargon)
stacka named composition of repos ([stack.NAME], repos = [...])product (accepted alias)
overlaynamed per-repo overrides applied at lock timeprofile, variant
changesetone feature across N repos (branch + PR/MRs)topic, issue
groupfree-form label on a repo, used to filter commandskept from repo tool, now actually wired
revwhat you ask for: branch, tag, or SHA — kind auto-detectedrevision, refspec
lock / pinresolved SHA in haw.lockfreeze (planned rename: haw pin / haw unpin)
driftHEAD differs from the locked SHA

Old spellings (brick, product, bricks, --product, --bricks) parse forever as aliases; serialization and docs use the new words only.

Verbs (commands)

Rule: one guessable verb per action, git-adjacent, no jargon. Old names kept as hidden aliases so nothing breaks.

VerbDoesAlias (accepted)
haw init <path>bootstrap a workspace from a manifest
haw syncmaterialize the tree to haw.lock (writes lock if absent)
haw treeprint the stack → repo treegraph
haw statusfleet status: branch, head, dirty, drift per repost
haw run '<cmd>'run a command in every repo, in parallel (positional)forall (with -c)
haw lockresolve every repo’s rev → SHA into haw.lock
haw pinpin haw.lock to current checkouts (no network)freeze
haw unpinrestore haw.lock to manifest revsunfreeze
haw switch <stack>record a stack as current and sync it
haw repo add|remove|listedit the repos of the manifestbrick
haw stack add|remove|listedit the stacks of the manifestproduct
haw change start|status|listcross-repo feature (changeset) workflow
haw grep <pat>fan-out grep across every repo
haw verifydrift gate — exit 3 if the tree diverges from haw.lock
haw buildrun each repo’s manifest build command across the fleet
haw testrun each repo’s manifest test command across the fleet
haw hooks installinstall lifecycle hooks from .haw/hooks/
haw evidencebundle SBOM / provenance / signatures into haw-evidence.tar.gz
haw publish <files> --to <registry>upload artifacts to a private registry (see DISTRIBUTION.md)
haw import --from <west.yml|default.xml>convert a west / Google-repo manifest to haw.toml
haw merge plan|resolve|status|cleanup|abortparallel collaborative merge (per-slice)
haw completions <shell>print a shell completion script to stdout
haw plugins new|list|installscaffold, discover, and install haw-<name> plugins
haw (no args) / haw dashopen the TUI cockpittui

haw run takes the command positionally (haw run 'git fetch'); -c/--command still works via the forall alias. Running haw with no subcommand opens the dashboard (like htop, k9s).

Rev handling (user-friendly by default)

  • One field: rev = "main" | "v6.1.2" | "<40-hex sha>". No type = key; the kind is detected (refs/heads > peeled tag > tag > full SHA).
  • Display: SHAs are shown 8 chars everywhere; haw.lock stores the full 40.
  • Never detached: branch revs check out on a same-name branch, tags/SHAs on haw/<rev>.

Groups (implemented)

  • groups = ["firmware", "ci"] on a repo.
  • haw sync --group firmware, haw status --group ci, haw forall --group firmware -c ... (repeatable; empty filter = everything; a filter excludes ungrouped repos).
  • Groups are recorded in haw.lock so filtering works offline.

Options grid

OptionCommandsNote
--stack <S>sync, treealias --product; default: last switch, else the only stack
--overlay <O>lock, sync*, treerepeatable, later wins; *sync only when generating the lock
--group <G>sync, status, runrepeatable
--repos a,bchange startalias --bricks
--slug <S>repo addrepo path under --remote (alias --repo); with --remote, not --url
-j, --jobs <N>sync, switch, rundefault min(cores, 8)
--recurse-submodulessyncclone/update each repo’s git submodules, pinned to the superproject
--skip-branchchange startadopt current branches (RepoFleet)
--branch <B>change startdefault change/<id>

TUI keymap

k9s/lazygit-style, keyboard-first. Three mechanisms carry everything:

  • digits 17 switch views (from any top-level list view),
  • a opens the current view’s context actions (a lazygit-style menu),
  • : is the command bar for the rest — its verbs mirror the CLI (learn one, know both).

Data loads on a background worker — the UI never blocks. The fleet grid auto-refreshes every ~5s while idle (never while you’re typing, in an overlay, a confirm, or a job is in flight); F5 / ctrl-r refresh on demand.

Global (frozen — these keys mean the same thing in every view)

KeyAction
/ or k/jmove cursor (in a drill-in: scroll one line)
enterdrill in (stack → fleet → repo/PR/CI detail) · confirm a y/n prompt
esc / b / clear an active filter, else go back one level
qquit · ctrl-c force-quit
/fuzzy filter the grid (live, case-insensitive: /knlkernel)
:command bar (mirrors the CLI verbs, see below)
?help overlay
F5 / ctrl-rrefresh now
ctrl-d / ctrl-uhalf-page down / up · PageUp / PageDown full page
ggoto — quit and print the cursor repo’s path (cd "$(haw dash)")
wtoggle watch — auto-refresh the fleet & the open PR/CI view
spacemark / unmark the cursor repo (Fleet & Changeset only; shown as )

View jumps (17) — from any list view

KeyView: alias
1fleet:fleet
2changesets:changesets
3PR/MRs:prs
4CI runs:ci
5tree:tree
6governance:governance
7plugins:plugins

Digits are inert in the scroll/detail views (repo/PR/CI detail, files, grep) — jump from a list. Sorting (</>/.) applies to the Fleet, PR/MR, and CI tables.

Fleet view

KeyAction
ssync — the marked repos if any, else the cursor repo, else the stack
spacemark / unmark the cursor repo (shown as )
rrun a command — across the marked repos if any, else the whole fleet
pproblems-only filter (⚠ dirty / drift / behind / missing)
xdrop into a shell in the cursor repo (exits the cockpit)
fbrowse the cursor repo’s files (local disk or forge)
!run one shell command in the cursor repo (in its detail view)
enterdrill into the cursor repo’s git detail (branch, SHA, status, log, diffstat, remotes)

Switch-stack, lock, and git-fetch moved to the command bar: :stack (picker) / :stack NAME, :lock, :fetch. Pinning the lock is p in the Stacks view (or :pin).

Marks persist across the Fleet and Changeset views; with marks set, both s (sync) and r/:run act on just the marked set.

Fleet PR/MR view (3) and CI view (4)

KeyAction
enterdrill in — PR/MR: reviewers, checks, body, url · CI run: jobs, steps, conclusion
aactions menu — PR/MR: m merge · a approve · c checkout (each asks y/n)
dread the PR/MR’s diff (scrollable)
lread the CI run/pipeline’s logs (scrollable)
fbrowse the PR/MR’s changed files (PR-files view)
oopen the cursor row in your browser
< > .sort the table
b / escback

a (actions) and d are also available from within a PR/MR drill-in. Refetch is now just F5 / ctrl-r.

Files view (f from a repo)

A read-only browser: view or pick a file at any ref, on local disk or the forge (GitHub / GitLab / Bitbucket). It never stages/commits — it only reads. Two modes share the same repo / ref / scope context and toggle with T: a flat one-directory list (default) and a navigable expandable tree.

KeyAction
enteropen a directory, or view a file’s content (scrollable)
Ttoggle to the tree view (and back)
rref picker — read files AS OF a chosen branch / tag / SHA
eedit the file under the cursor in $EDITOR (local files only)
Rtoggle between the local-disk tree and the forge view
b / escup a directory, then back to the fleet
xdrop into a shell in the repo

Tree view (T from Files)

KeyAction
enter / expand the directory (or open the file) under the cursor
collapse the directory (or jump to and collapse its parent)
rref picker (same as Files)
Tback to the flat list
Rtoggle local ⇄ forge
b / escback to the fleet

The tree fetches every file path of the repo at the active ref once, then expands/collapses client-side. Collapsed dirs show , expanded ; files are indented under their parents.

Ref picker (r in either mode)

r opens a popup listing the repo’s branches then tags (j/k + enter to pick), plus an input row to type an arbitrary ref or commit SHA. Selecting a ref reloads the current view AS OF that ref (the flat list re-roots at the repo root; the tree re-fetches its paths). The panel title shows the active ref honestly: @ main, @ v1.0.0, @ a1b2c3d, or @ HEAD (local) / @ default (remote) when none is pinned. Local refs come from git for-each-ref / git ls-tree / git show <ref>:<path>; forge refs and trees come from each forge’s REST API.

e suspends the cockpit, hands the current TTY to $VISUAL/$EDITOR (falling back to nvim/vim/vi) on the file’s absolute path, then resumes and reloads the listing. It is declined on the forge view (R) and on directories; if the repo isn’t on disk it prompts to sync.

Errors view, Plugins view, Governance view

Reach them from a list view (Errors via :errors/:err, Plugins via 7/:plugins, Governance via 6/:governance). In Governance, o opens the cursor plugin’s artifact (SBOM / provenance / …). Refetch is F5 / ctrl-r; b / esc go back.

Changeset view

KeyAction
nnew changeset
spaceselect / deselect a repo
aactions menu — r request cross-linked PR/MRs (selected, or all if none) · l land in dependency order (each asks y/n)
ggoto the cursor repo

Actions menu (a)

a opens a bordered actions popup listing the current view’s context actions, each with its sub-key. Pressing a listed sub-key fires that action — write actions keep their y/n confirm gate. esc (or any unlisted key) cancels. Views with no actions report so.

Command bar (:)

Verbs mirror the CLI, and the status line echoes the exact command each one runs, so the TUI doubles as a way to discover the CLI.

CommandAction
:stackopen the switch-stack picker (alias :stacks)
:stack NAME / :switch NAMEswitch to a stack
:lockcommit the lock (resolve revs → SHA)
:fetchgit fetch the cursor repo
:errors / :errerrors view — failures collected across the fleet
:fleet / :changesets / :treeview jumps (same as 1 / 2 / 5)
:prs / :cifleet-wide PR/MR / CI views (same as 3 / 4)
:governance / :pluginsgovernance / plugins view (same as 6 / 7)
:syncsync the current stack
:run CMDrun a command (across marked repos in the Fleet, else the fleet)
:build / :test / :verifyfleet build / test / drift-verify
:pin / :lockpin HEADs / commit the lock
:change [ID | start ID | land ID | request ID]changeset workflow
:merge [cleanup <repo> | abort <repo>]list / seal / abort in-progress merges
:grep <pat>fan-out grep across every repo
:sh CMDrun a shell command in the cursor repo
:problemstoggle the problems-only filter (⚠ dirty/drift/behind/missing)
:watchtoggle watch auto-refresh (same as w)
:<repo>jump the fleet cursor to a repo whose name matches
:theme [NAME]switch skin live (no arg opens the theme picker; a chosen theme persists)
:editor [CMD]set the e-key editor (no arg opens a picker of editors on PATH; persists)
:compacttoggle the collapsed one-line header (persists)
:helphelp overlay

Themes / skins

Seven built-in skins: classic, catppuccin (default), dracula, nord, gruvbox, solarized, monochrome. classic is a neutral 16-color-ANSI look that reads on both light and dark terminals. :theme with no argument opens an interactive picker (highlighting the active skin); pressing enter applies the skin live and persists it to [ui].theme in the config. :theme <name> still switches (and persists) directly.

User config — ~/.config/haw/config.toml

Optional. A missing file, a partial table, or an unknown key all fall back to sane defaults (the cockpit never errors on absence). HAW_CONFIG overrides the path.

[ui]
theme = "classic"        # startup theme (a built-in name)
editor = "nvim"          # editor for the `e` key
compact_header = false   # start with the header collapsed
refresh_secs = 5         # idle auto-refresh cadence (clamped 2–60)

[keys]                   # remap a SAFE subset of action keys (single chars)
sync = "s"               # remappable: sync, goto, run, shell, files, problems, watch
goto = "g"

Precedence:

  • Theme: NO_COLOR (non-empty) → monochrome; else HAW_THEME (env) if it names a built-in; else [ui].theme; else the default catppuccin.
  • Editor: $VISUAL$EDITOR (env always wins) → [ui].editor → the first of nvim/vim/vi on PATHvi.

Custom keybindings ([keys])

Each entry maps an action name to a single key char. Only a safe subset is remappable — sync, goto, run, shell, files, problems, watch. The frozen globals (j, k, :, /, ?, q, b, space, g, w, and the digit view-jumps 17) can never be the target of a remap; a remap that targets a global, duplicates another remap, collides with a default action key, or isn’t a single char is dropped with a startup warning. When a remap is active, the header hints show the active key so they stay honest. The original default key keeps working too (a remap adds, never removes).

Shipped since this design was written

  • haw pin / haw unpin (aliases freeze/unfreeze).
  • --label <L> on change start, forwarded to PR/MRs at change request.
  • forge = "github" | "gitlab" key on [remote.X] for hosts the URL heuristic misses.
  • deps = [...] on a repo — change land merges in stable topological order.
  • haw verify, haw sync --locked, --format json on status/tree, exit 3 on drift.
  • haw build / haw test (per-repo commands in the manifest), lifecycle hooks in .haw/hooks/, haw hooks install, haw evidence, haw-<name> plugins.
  • Lexicon nuance: --slug on repo add accepts --repo as alias; haw run takes the command positionally (forall -c still works).
  • TUI g (goto) quits and prints the repo path — cd "$(haw dash)" — instead of spawning a nested shell.
  • TUI: live idle auto-refresh (~5s), fuzzy / filter (nucleo), column sorting (</>/.), marks + bulk s/r, drill-ins for repos/PRs/CI runs, the a actions menu (merge / approve / checkout in PR/MR; request-PR / land in Changeset), fleet-wide governance (6) view, the file browser (f) with a navigable tree (T), ref picker (r), and local edit (e), and seven themes including classic (HAW_THEME, NO_COLOR, live :theme with an interactive picker).
  • TUI config file ~/.config/haw/config.toml: [ui] startup theme / editor / compact_header / refresh_secs, plus [keys] custom keybindings for a safe subset of action keys. Interactive :theme / :editor pickers persist a choice; :compact toggles the collapsed header.

Planned (not yet implemented)

  • Tag conveniences: haw lock --as-of <tag>; haw status marking rev kind (branch/tag/sha).
  • haw auth login — OAuth device flow + OS keychain (see ARCHITECTURE DR-14).
  • TUI: mouse support.

hawser — Extensions, Plugins, Auth & CI/CD

How hawser stays open at the edges: it orchestrates git, forges, and build tools — it never reimplements them. Everything task-specific (build systems, forges, custom steps) plugs in from outside through stable, boring interfaces. Pairs with ARCHITECTURE.md (internals) and COMPLIANCE.md (evidence).

Design rules, in order:

  1. Git-native. If it’s already a text file git understands (lock, alternates, gitdir), use that. Never a hidden database.
  2. Orchestrate, don’t reimplement. haw decides what and when; the user’s tools do how. haw knows nothing about CMake, Bazel, Yocto, Jenkins — it shells out.
  3. Unix pipes. Human view in the TUI; machine view in --format json on stdout. Anything haw shows, a script can consume.
  4. Fail open. An unknown forge, a missing token, an absent plugin degrades one feature — it never blocks sync/tree/status.

1. Extension mechanisms

Four layers, cheapest first. Reach for the lowest one that solves the problem.

1.1 run — run any command across repos

The universal escape hatch. Parallel command execution across every repo (group-filterable).

haw run 'git fetch --tags'
haw run --group firmware 'cmake --build build'

No plugin needed for the 80% case: “do X in every repo”.

1.2 Hooks — run scripts at lifecycle points

Git-style hooks fired around haw operations. Scripts live in .haw/hooks/ (or are declared in the manifest) and receive context via env + stdin JSON.

HookFires
pre-sync / post-syncbefore/after a haw sync
pre-lock / post-lockaround lockfile (re)generation
post-switchafter haw switch <stack>
post-change-startafter a changeset branch is created

Example — install a git hook that rejects a commit when haw.lock is stale (the “git-way” integrity guarantee):

haw hooks install    # writes a pre-commit that runs `haw verify --lock`

1.3 Per-repo commands in the manifest

Declare how a repo is built/tested so haw can drive it without hard-coding any build tool.

[repo.app-mqtt]
remote = "github"
repo   = "app-mqtt.git"
rev    = "release/2.x"
build  = "cmake --build build --preset release"   # haw just shells out
test   = "ctest --test-dir build"

haw stays build-system-agnostic: CMake, Bazel, Meson, Cargo, Make — all just strings.

1.4 Subcommand plugins — haw-<name> on PATH

The git / cargo / kubectl pattern. haw foo … that isn’t a built-in execs haw-foo from PATH, forwarding args and the workspace context (via env + HAW_JSON on stdin). The community ships haw-jira, haw-bazel, haw-sbom-scan without touching core.

  • Discovery: any executable named haw-* on PATH.
  • Contract: haw passes workspace root, current stack, and resolved repos as JSON on stdin; the plugin prints results as JSON on stdout; haw renders or pipes them.
  • Isolation: plugins are separate processes — a broken plugin can’t crash haw.

Core stays small. haw maintains the composition + orchestration engine; integrations (every forge quirk, every build tool, every tracker) live in plugins and hooks.

1.5 Machine interface — --format json

Every read command (status, tree, change status, verify, evidence) offers --format json with a stable, versioned schema, plus stable exit codes. This is what CI, dashboards, and plugins consume. The TUI is for humans; JSON is for machines.


2. Authentication (works on any repo)

The adoption unlock: transport auth is free; forge auth is opt-in. haw never invents its own credential store.

2.1 Transport (clone / fetch / push) — zero config

haw shells out to the user’s git, so it inherits existing git auth automatically:

  • SSH keys via ssh-agent (the enterprise/embedded default).
  • HTTPS via git credential helpers (Git Credential Manager, osxkeychain, cache).

Works with any host — GitHub, GitLab, Gitea, Bitbucket, self-hosted, plain SSH — with no integration. If git clone works, haw sync works. This is exactly how repo and west authenticate: they don’t.

2.2 Forge API (open/read PR-MR, CI status) — token, only when used

Only the PR/MR features need API credentials, resolved in this order:

  1. Env: GITHUB_TOKEN / GH_TOKEN, GITLAB_TOKEN, HAW_FORGE_TOKEN.
  2. Reuse an existing CLI’s stored token: gh auth token, glab auth.
  3. git credential for HTTPS tokens.
  4. OAuth device flow (haw auth login) — prints a code to enter in a browser, no localhost redirect, works headless/over SSH; token stored in the OS keychain. This is the gh / docker login / aws sso model.
  • Self-hosted: configurable API base URL per forge (GitHub Enterprise, GitLab self-managed). Mandatory for the target market.
  • Air-gapped: token via env/file only; no browser flow, no egress (see COMPLIANCE §6).
  • Never persist a token in haw.toml, haw.lock, logs, or workspace state (COMPLIANCE §5.6). Redact credential-shaped strings.

2.3 Forge detection

haw maps each repo’s remote URL → forge (GitHub/GitLab/…) via the Forge trait. Unknown host → transport still works; only PR/MR features disable for that repo. Fail open.


3. CI/CD integration

haw is designed to be driven by pipelines, not just humans.

3.1 Reproducible checkout

haw sync --locked      # materialize the exact haw.lock tree; fail if lock is missing/stale
haw verify             # assert on-disk tree == lock (drift gate); non-zero on drift

--locked is the CI contract: no rev resolution, no network nondeterminism — the committed lock is law. Deterministic on Linux/macOS/Windows (COMPLIANCE §8).

3.2 Gates via JSON + exit codes

haw status --format json | jq -e '.repos[] | select(.dirty or .drift)' && exit 1 || true

Stable exit codes: 0 ok, distinct non-zero for drift / verify failure / signature failure.

3.3 Tokens in CI

Inject forge tokens as CI secrets (GITHUB_TOKEN in Actions, masked vars in GitLab). No interactive login in pipelines. Transport uses the runner’s SSH key or a deploy token.

3.4 Object-sharing cache (fast CI)

haw sync --locked --shared   # git alternates against a warm mirror cache; text file, no symlinks

Cache the mirror between runs to avoid re-cloning large repo trees.

3.5 Evidence in the release pipeline

haw evidence --out haw-evidence.tar.zst   # baseline + SBOM + provenance + tool config record

Attach to the release for the certification data package (COMPLIANCE §3, §4).

3.6 GitHub Actions (sketch)

- uses: actions/checkout@v4
- run: cargo install hawser
- run: haw sync --locked --shared
  env: { GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} }
- run: haw verify
- run: haw run --group firmware 'cmake --build build --preset release'

3.7 GitLab CI (sketch)

build:
  script:
    - haw sync --locked
    - haw verify
    - haw run 'make'
  variables: { GITLAB_TOKEN: $CI_JOB_TOKEN }

4. Where this lands in the plan

Additions to the phased plan in ARCHITECTURE.md §6; each item is scoped to keep core small and push integrations to the edges. Status as of 2026-07-15: everything below is shipped except the OAuth device-flow login (deferred — ARCHITECTURE DR-14) and the full haw evidence SBOM/provenance payload (today’s bundle: manifest, lock, audit log, status JSON, tool record).

CapabilityLayerPhase
run parallel across repos (alias forall)core3
--format json + stable schemas/exit codescore1→3
Forge transport (git-native, zero config)haw-git1
Forge API tokens (env / gh-glab reuse)haw-forge1 (GH), 3 (GL)
OAuth device-flow login + keychainhaw-forgedeferred (DR-14)
Self-hosted forge base URLhaw-forge3
Lifecycle hooks (pre/post-sync, …)core + haw hooks4
haw hooks install (stale-lock pre-commit)core4
Per-repo build/test commands in manifestmanifest model4
Subcommand plugins (haw-<name> on PATH)hawser dispatch5
Plugin stdin/stdout JSON contractcore5
haw verify drift gate (CI)core1→2
haw evidence bundlecore3
--shared object-sharing cachehaw-git2

Guiding constraint: the core never grows a hard dependency on a specific build tool, tracker, or CI system. Those arrive as hooks, per-repo commands, or haw-* plugins.

Plugins

haw follows the git / cargo / kubectl pattern: any subcommand haw doesn’t recognize is dispatched to an executable on your PATH. Ship haw-jira, haw-bazel, haw-sbom-scan without touching core.

A plugin is:

  • an executable named haw-<name> somewhere on PATH,
  • run as a separate process — a broken or hanging plugin can’t crash haw,
  • handed the workspace context as JSON so it can act on the current fleet.

Dispatch contract

When you run haw <name> <args...> and <name> is not a built-in command, haw:

  1. Resolves the binary name haw-<name> and spawns it via PATH lookup.
  2. Forwards <args...> verbatim as the plugin’s argv (haw does not parse them).
  3. Passes the workspace context as a haw.plugin/1 JSON document two ways:
    • in the HAW_JSON environment variable, and
    • written to the plugin’s stdin. (Both carry the identical document — read whichever is convenient.)
  4. Leaves the plugin’s stdout and stderr connected to the terminal (or the pipe haw was invoked with) — the plugin prints directly.
  5. Waits for the plugin and propagates its exit code as haw’s own exit code (clamped to 0–255; a killed-by-signal plugin surfaces as 1).

If no built-in matches and no haw-<name> is found on PATH, haw fails with:

error: no built-in `<name>` and no `haw-<name>` on PATH: No such file or directory (os error 2)

and exits non-zero. Dispatch fails open: an unknown word is an error, never a crash.

The haw.plugin/1 context

The JSON document haw provides (via HAW_JSON and stdin). Inside a workspace it is fully populated:

{
  "schema": "haw.plugin/1",
  "root": "/path/to/workspace",
  "stack": "gateway",
  "repos": [
    { "name": "kernel", "path": "/path/to/workspace/kernel", "rev": "v6.1.2", "groups": ["firmware"] },
    { "name": "hal",    "path": "/path/to/workspace/hal",    "rev": "main",   "groups": ["firmware"] }
  ]
}
FieldMeaning
schemaAlways "haw.plugin/1". Check this before trusting the rest.
rootAbsolute workspace root (the directory tree containing haw.toml).
stackCurrent stack name, or null if none is selected.
repos[]Resolved repos: name, absolute path, rev, and groups.

Run outside a workspace, haw still dispatches the plugin but the context degrades to the schema marker only:

{ "schema": "haw.plugin/1" }

A well-behaved plugin checks for root/repos and does something sensible when they are absent (print help, operate on cwd, or exit cleanly).

TUI panels — rendering your own cockpit surface

The cockpit (haw dash) has a first-class Plugins view (press 7, or :plugins) that lists every available plugin — the manifest [plugins] keys unioned with the haw-* executables discovered on PATH. Selecting one with Enter runs the plugin in a render intent and shows its output in the scrollable detail panel titled plugin: <name>.

The render contract adds two signals on top of the normal haw.plugin/1 context so a plugin can tell it is being asked for a human-readable panel (rather than being fired for a lifecycle phase):

  • the environment variable HAW_RENDER=1 is set, and
  • the context JSON (on HAW_JSON and stdin) carries "intent": "render".
{
  "schema": "haw.plugin/1",
  "intent": "render",
  "root": "/path/to/workspace",
  "stack": "gateway",
  "repos": [ /* ... as above ... */ ]
}

When it sees these, the plugin should print a panel to stdout and exit. Two output shapes are accepted:

  1. Structured — a haw.plugin.view/1 document. haw renders its title followed by each string in lines:

    {
      "schema": "haw.plugin.view/1",
      "title": "SBOM status",
      "lines": [
        "kernel   ✓ SBOM emitted",
        "hal      ✓ SBOM emitted",
        "app-mqtt ⚠ stale"
      ]
    }
    
  2. Raw text — anything that is not a haw.plugin.view/1 document is shown verbatim as the panel body. This lets a plugin printf a plain report with no JSON at all.

Output is line-capped to keep the panel bounded. A plugin that produces no output shows a short placeholder. A plugin that is not on PATH reports a clear error in the cockpit rather than crashing it.

Managing plugins

haw plugins (plural) is the management surface — discover what exists, see what’s installed, and install first-party plugins. It is a defined subcommand, so it never collides with haw <name> dispatch: haw plugins list always runs the built-in, even if a haw-plugins binary sits on PATH.

haw plugins list

A table merging three sources, deduped by name:

  • Official catalog — the first-party plugins shipped in this repo.
  • Installed — every haw-<name> executable found on PATH.
  • Subscribed — the workspace manifest [plugins] entries and their phases (when run inside a workspace; it degrades gracefully when there is none).
$ haw plugins list
NAME        STATUS     SUBSCRIBED    DESCRIPTION
artifact    available  -             SLSA/in-toto provenance + cosign/minisign signing
aspice      installed  pre-request   ASPICE/qualification traceability from the pinned fleet
compliance  available  post-build    SBOM (CycloneDX + SPDX) generation
...

STATUS is installed when the haw-<name> binary is on PATH, else available. SUBSCRIBED lists the phases from the manifest, or -. A plugin discovered on PATH that is not in the catalog still appears (with source path).

--format json emits a haw.plugins/1 document for tooling:

haw plugins list --format json | jq '.plugins[] | select(.installed | not) | .name'
{
  "schema": "haw.plugins/1",
  "plugins": [
    {
      "name": "aspice",
      "crate": "haw-aspice",
      "installed": true,
      "subscribed_phases": ["pre-request"],
      "description": "ASPICE/qualification traceability from the pinned fleet",
      "source": "catalog"
    }
  ]
}

haw plugins install <name>

Install a plugin binary via cargo install. A catalog name (aspice) resolves to its crate (haw-aspice); any other value is used verbatim, so a full crate name works too. The first-party plugins are workspace members (not yet on crates.io), so the default source is --git https://github.com/Nastwinns/hawser:

haw plugins install aspice                 # cargo install --git <repo> haw-aspice
haw plugins install aspice --dry-run        # print the command, run nothing
haw plugins install haw-foo --git https://example.com/me/plugins   # custom source
haw plugins install haw-foo --git https://example.com/me/plugins --tag v1.2.0   # pin to a tag
haw plugins install haw-foo --git https://example.com/me/plugins --rev 9f3c1a2   # pin to a commit
haw plugins install some-crate --locked     # honor the crate's Cargo.lock

Pin a custom --git source with --tag <TAG> or --rev <SHA> (mutually exclusive). They only apply to a custom --git source — the default first-party source is already pinned to this hawser version automatically. Installs are always --locked for reproducibility (--locked is now a no-op, kept for compatibility).

haw prints exactly what it will run ($ cargo install …) before running it, streams cargo’s output, and propagates cargo’s exit code. --dry-run prints the command and exits without touching cargo. If cargo is not on PATH, haw fails with an actionable error pointing at https://rustup.rs.

haw plugins path

Print the directories haw scans for haw-* plugins (the PATH entries) — drop a haw-<name> executable into any of them to make it discoverable:

haw plugins path

Scaffold a plugin

haw plugins new <name> --lang <rust|python|go|shell> [--dir <path>] writes a runnable plugin skeleton that already implements the contract: it reads the haw.plugin/1 context from HAW_JSON (falling back to stdin), handles --help and --format json, emits a haw.plugin.report/1 document, and degrades gracefully when run outside a workspace. The target defaults to ./haw-<name>; --dir overrides it. haw refuses to overwrite a non-empty directory.

haw plugins new sbom --lang shell     # ./haw-sbom/haw-sbom (POSIX sh) + README.md
haw plugins new sbom --lang python    # ./haw-sbom/haw-sbom (python3) + README.md
haw plugins new sbom --lang go        # ./haw-sbom/{main.go, go.mod, README.md}
haw plugins new sbom --lang rust      # cargo crate: Cargo.toml + src/main.rs + README.md
haw plugins new sbom --lang shell --dir /tmp/sbom   # choose the target dir

Per language, the entry point and build step differ:

--langEntry file(s)Make it runnable
shellhaw-<name> (executable POSIX sh)already executable — drop on PATH
pythonhaw-<name> (executable, python3)already executable — drop on PATH
gomain.go + go.mod (module haw-<name>)go build -o haw-<name>
rustCargo.toml ([[bin]] haw-<name>) + src/main.rscargo build --release

Each skeleton ships a README.md with the “drop on PATHhaw <name>” recipe and a [plugins] subscribe snippet. The rust and go skeletons are standalone — the rust one depends only on serde/serde_json, not on any haw crate. After building, put the binary on PATH and run it:

haw plugins new demo --lang shell --dir /tmp/haw-demo
PATH="/tmp/haw-demo:$PATH" haw demo
HAW_JSON='{"schema":"haw.plugin/1"}' /tmp/haw-demo/haw-demo --format json

The haw plugins new output lists every file it created and prints the exact next steps (build + PATH=… invocation) for the chosen language.

Discover community plugins

haw plugins list --remote merges a community index into the local table. Each merged-in plugin shows STATUS available and source remote with its description; anything already installed, in the catalog, or subscribed keeps its own status (dedup is by name).

haw plugins list --remote
haw plugins list --remote --index https://example.com/plugins-index.json
haw plugins list --remote --format json   # remote entries carry "source":"remote"

The default index URL is https://raw.githubusercontent.com/Nastwinns/hawser/main/plugins-index.json; pass --index <url> to point at your own. A network or parse failure is not fatal — haw prints a warning and falls back to the local-only list.

The haw.plugins.index/1 format

The index is a single JSON document:

{
  "schema": "haw.plugins.index/1",
  "plugins": [
    {
      "name": "sbom",
      "crate": "haw-sbom",
      "git": "https://github.com/you/haw-sbom",
      "description": "CycloneDX SBOM generation for the pinned fleet"
    }
  ]
}
FieldMeaning
schemaAlways "haw.plugins.index/1".
plugins[]One entry per plugin.
nameThe verb users type (haw <name>).
crateCrate name for cargo install (optional).
gitSource repository URL (optional).
descriptionOne-sentence summary shown in haw plugins list.

Add your plugin to the community index

Open a PR against the repo-root plugins-index.json that adds one entry — name, crate, git, and a one-sentence description. Once merged it appears for everyone running haw plugins list --remote.

Machine interface — consuming haw’s own output

Plugins rarely need to re-derive fleet state: haw’s read commands already speak JSON. Every read command (status, tree, change status, verify, evidence) offers --format json with a stable, versioned schema and stable exit codes. Shell out to haw and parse it:

haw status --format json | jq '.repos[] | select(.dirty)'

The haw.plugin/1 context tells you where the workspace is; --format json tells you what state it’s in. See EXTENDING.md §1.5 for the machine interface contract.

Hello, plugin — in two languages

Both versions below implement the same command: haw hello prints a greeting, --help describes itself, and --format json emits haw.plugin/1 JSON.

POSIX shell

A full working version lives in examples/haw-hello. The core:

#!/usr/bin/env sh
set -eu

case "${1:-}" in
-h | --help)
	echo "haw-hello — say hello. Options: --help, --format json"
	exit 0
	;;
esac

# haw hands us the workspace context in $HAW_JSON (and on stdin).
root=$(printf '%s' "${HAW_JSON:-}" | sed -n 's/.*"root":"\([^"]*\)".*/\1/p')

if [ "${1:-}" = "--format" ] && [ "${2:-}" = "json" ]; then
	printf '{"schema":"haw.plugin/1","plugin":"hello","root":"%s"}\n' "$root"
	exit 0
fi

if [ -n "$root" ]; then
	printf 'hello from haw-hello — workspace at %s\n' "$root"
else
	printf 'hello from haw-hello (no workspace here)\n'
fi

Make it executable and drop it on PATH:

chmod +x haw-hello
PATH="$PWD:$PATH" haw hello

Rust

A standalone binary — no dependency on any haw crate.

cargo new --bin haw-hello
cd haw-hello

src/main.rs:

use std::env;
use std::process::ExitCode;

fn main() -> ExitCode {
    let args: Vec<String> = env::args().skip(1).collect();

    if args.iter().any(|a| a == "-h" || a == "--help") {
        println!("haw-hello — say hello. Options: --help, --format json");
        return ExitCode::SUCCESS;
    }

    // haw passes the haw.plugin/1 context in HAW_JSON (also on stdin).
    let ctx = env::var("HAW_JSON").unwrap_or_default();
    let root = ctx
        .split("\"root\":\"")
        .nth(1)
        .and_then(|s| s.split('"').next())
        .unwrap_or("");

    if args == ["--format", "json"] {
        println!(r#"{{"schema":"haw.plugin/1","plugin":"hello","root":"{root}"}}"#);
        return ExitCode::SUCCESS;
    }

    if root.is_empty() {
        println!("hello from haw-hello (no workspace here)");
    } else {
        println!("hello from haw-hello — workspace at {root}");
    }
    ExitCode::SUCCESS
}

Build and run it as a plugin:

cargo build --release
PATH="$PWD/target/release:$PATH" haw hello

(For real plugins, parse HAW_JSON with serde_json instead of string slicing.)

Write in any language

The plugin contract is language-agnostic — it is just JSON on HAW_JSON / stdin (haw.plugin/1) and JSON on stdout (haw.plugin.report/1 for lifecycle phases, haw.plugin.view/1 for TUI render intent). Any language that can read an env var and print JSON can be a haw plugin.

The schemas/ directory holds the official JSON Schemas (draft 2020-12) — the source of truth for every field name and shape. Validate your plugin’s I/O against them.

Thin reference bindings mirror those schemas so you don’t hand-roll the JSON:

  • Pythonbindings/python (haw_plugin): Context.from_env(), Report.emit(), view(title, lines). No deps beyond stdlib.
  • Gobindings/go (hawplugin): ReadContext(), Report.Emit(), View(title, lines). Stdlib only.
  • POSIX shell and Rust — the examples/haw-hello and the “Hello, plugin” section above show zero-dependency implementations.

For a curated list of existing plugins to install or learn from, see AWESOME-HAW-PLUGINS.md.

Starter example plugins

The examples/plugins/ directory ships small, runnable starters — zero to light dependencies, POSIX sh or Python 3 stdlib, all read-only / dry-run by default. Each is a real haw-<name> executable with --help, --format json (a haw.plugin.report/1), and the cockpit render intent (a haw.plugin.view/1). Copy one onto your PATH and read it as a template.

PluginLangWhat it does / how to try it
haw-fleet-statusPOSIX shCompact per-repo health panel — branch, dirty?, ahead/behind. Pure git, zero deps. haw fleet-status
haw-dockerPOSIX shReports Dockerfile/compose assets per repo; lints with hadolint and checks local images with docker when present (degrades gracefully). haw docker
haw-webPython 3Counts/validates *.html (doctype, title, tag balance), flags *.css, reports sizes. Stdlib only. haw web
haw-k8sPOSIX shFinds *.yaml under k8s//deploy//manifests/ and validates each with kubectl apply --dry-run=client (offline; never touches a cluster). haw k8s
haw-commit-aiPython 3Drafts commit/PR text from your changeset — and doubles as an MCP server so Claude reads the diffs. haw commit-ai

Editor integration — Neovim

examples/nvim (haw.nvim) is a small, dependency-free Lua plugin that runs haw from inside Neovim by shelling the haw binary — no server:

  • :HawSync — run haw sync, echo the result.
  • :HawStatushaw status in a scratch buffer.
  • :HawDash — open haw dash (the TUI cockpit) in a terminal split.
  • :HawFleet — list the fleet (repo / branch / state) in a scratch buffer, parsed from haw status --format json.

Install with lazy.nvim / packer / native :packadd — see the README.

Conventions

  • Name it haw-<verb>. The verb is what users type: haw-jirahaw jira. Keep it short and unclaimed by built-ins (haw --help lists those).
  • Self-describing --help. Users discover your plugin’s flags through it; haw does not document plugins for you.
  • Human on stdout, JSON on --format json. Print a readable line by default; emit a haw.plugin/1 document (or your own versioned schema) under --format json so other tools can pipe you.
  • Exit codes carry meaning. 0 = success. Non-zero = failure, and haw propagates it — CI gates and && chains rely on it. Don’t exit 0 on error.
  • Fail open. Handle the workspace-less context (schema-only JSON) gracefully. Don’t assume root/repos exist. Never hang: your process blocks haw until it exits.
  • Stay a separate process. You get isolation for free — don’t try to reach into haw’s internals; consume --format json and the haw.plugin/1 context instead.

Distributing your plugin

Any executable named haw-<name> on PATH works. Two common paths:

  • Publish a crate. Name the binary haw-<name>; users get it with cargo install haw-<name>, which drops it into ~/.cargo/bin (usually on PATH).
  • Ship a binary or script. Drop haw-<name> into any PATH directory (/usr/local/bin, ~/.local/bin, ~/bin). Shell scripts count — mark them executable.

Verify with:

which haw-<name>   # haw finds exactly what your shell finds
haw <name> --help

Submitting your plugin

Built something useful? Share it. See CONTRIBUTING.md for the build/test checklist and PR etiquette, then open a PR that adds your plugin to the community list — one line: name, one-sentence description, and a link. We keep core small on purpose; the ecosystem lives in plugins.

Lifecycle phases

Plugins can subscribe to lifecycle phases in the manifest’s [plugins] table and are invoked out-of-process with --haw-phase <name> (e.g. an SBOM plugin on post-build). The optional haw-plugin SDK crate gives Rust authors the Context/Report ergonomics while still compiling to a standalone haw-<name> binary.

hawser — Compliance, Certification & Security Specification

Target market: safety- and security-critical programs (avionics, space, defense, rail, automotive, industrial, medical). These buyers cannot adopt an SCM tool unless it produces certification evidence, is itself qualifiable, has an auditable security posture, and is data-protection clean. This document specifies what hawser must provide, per domain, and maps each requirement to a technical feature and a delivery phase.

Terminology: “the tool” = the haw binary + haw-core. “The applicant” = the customer’s safety/security team who owns the certification argument. hawser supplies evidence and a qualification kit; the applicant owns the final determination in their plan (PSAC, safety plan, SEooC assumptions).


1. Where hawser sits in the lifecycle

hawser is a Software Configuration Management (SCM) + composition tool. It decides which revision of which repository enters a build and records that decision immutably. It does not compile, generate, or verify airborne/embedded code. That scoping is the single most important sentence for qualification: it bounds the tool’s failure modes to “selects/records the wrong source set” — not “emits wrong object code”.

Consequences of that scope:

  • The tool’s output (haw.lock + materialized tree) is verifiable independently by the downstream build + review, which lowers required qualification rigor.
  • The evidence it produces (baseline, SBOM, provenance) is consumed by the customer’s SCM, safety, and security processes — hawser is an evidence producer, not an authority.

2. Tool qualification

hawser is qualification-relevant in every safety standard because a config/composition tool can select the wrong source into a safety build. The classification and rigor differ by standard; the artifacts hawser must supply are largely shared (§2.6).

2.1 DO-178C / DO-330 (airborne) & DO-278A (ground/ATM)

  • Tool qualification governed by DO-330. Criterion is set by whether the tool can insert an error into the product and/or fail to detect one:
    • If the customer’s process re-verifies the materialized tree/build independently → Criterion 3 → typically TQL-5 (lowest rigor).
    • If the lockfile/tree is trusted without independent re-verification → Criterion 1TQL-4 (or higher at DAL A/B).
  • hawser’s recommendation to applicants: keep the downstream build+review as the verifying activity so the tool stays Criterion 3 / TQL-5. Document this in the PSAC.
  • Deliverables hawser supplies: Tool Operational Requirements (TOR), Tool Qualification Plan (TQP), tool requirements + test cases + results with traceability, Tool Accomplishment Summary (TAS) template, and a known-limitations / errata list.

2.2 ISO 26262-8:2018 §11 (automotive) + ASPICE

  • Determine Tool Confidence Level TCL1–3 from Tool Impact (TI) × Tool error Detection (TD). hawser is TI2 (a malfunction can violate a safety requirement) with achievable TD1/TD2 if the customer verifies the build → typically TCL1–TCL2.
  • Qualification methods hawser supports: 1a increased confidence from use (usage metrics, field-history template), 1b evaluation of the tool development process (our SDLC evidence, §7), 1c validation of the tool (our test suite + results).
  • ASPICE mapping: SUP.8 Configuration Management and SUP.10 Change Request Management (see §3). Provide the mapping table as a sales/audit artifact.

2.3 IEC 61508-3 (industrial functional safety)

  • Offline support tool classification T2/T3. hawser contributes to what is built → treat as T3 unless the customer’s build independently re-derives the source set.
  • Supply: tool validation evidence, version + configuration record, known-defects list.

2.4 EN 50128 / EN 50716 (rail)

  • Tool class T2/T3 (§6.7). Supply a Tool Qualification Report and evidence the tool is used within its validated operating envelope.

2.5 Space (ECSS-E-ST-40 / ECSS-Q-ST-80) & medical (IEC 62304)

  • Space: tool used within an SCM plan; supply configuration + validation records.
  • Medical IEC 62304 §8 SOUP/configuration management: supply SBOM + baseline evidence.

2.6 The shared qualification artifact set (produced once, mapped per standard)

  1. Tool Operational Requirements — what haw shall do, in verifiable statements.
  2. Requirements → test traceability matrix (every requirement has a test).
  3. Test suite + machine-readable results per released LTS, per supported OS.
  4. Safety/Operation Manual — intended use, operating envelope, constraints, and the error-avoidance measures the user must apply (e.g. “commit the lockfile”, “verify the build re-derives the tree”).
  5. Known limitations & errata, versioned.
  6. Configuration record — exact tool version, dependency versions, build provenance.
  7. Per-standard mapping tables (DO-330 / ISO 26262-8 / IEC 61508 / EN 50128 / ASPICE).

Determinism is a hard requirement for all of the above: same inputs ⇒ byte-identical haw.lock and tree selection, on every OS. No wall-clock, no map iteration order, no network nondeterminism in resolution. See §8.


3. Reproducibility & SCM evidence

The commercial and certification wedge. hawser turns “trust our process” into “here is the signed, reproducible baseline”.

  • haw.lock = the configuration baseline. Every repo pinned to an exact object id. Committed, diff-reviewable, machine-verifiable.
  • Deterministic resolution. Documented, versioned resolution algorithm (see resolver/mod.rs); overlay precedence is total and stable.
  • Drift detection. haw status / haw verify proves the on-disk tree matches the baseline — this is a configuration verification activity for the customer’s SCM plan.
  • Evidence bundle. haw evidence emits: baseline (lock) + SBOM + provenance attestation + tool configuration record, as one signed archive for the cert data package.

Standards mapping:

RequirementDO-178CISO 26262ASPICE
Baselines / configuration idsSCM (Table A-8)Part 8 §7SUP.8
Change control across reposSCMPart 8 §8SUP.10
Reproducible build inputsSCM / SCPart 8 §7SUP.8
Traceability of what shippedSCM / verifyPart 8 §7–8SUP.8/10

4. Supply-chain security & SBOM

Regulatory tailwind — increasingly mandatory, not optional.

  • SBOM export in both dominant formats: CycloneDX and SPDX 2.3 (ISO/IEC 5962), SPDX 3.0 when stable. Include NTIA minimum elements (supplier, component, version, unique id, dependency relationship, author, timestamp).
  • hawser emits an SBOM of the composed product (repos + their pinned ids) and ships an SBOM of haw itself (its Rust dependency tree) per release.
  • EU Cyber Resilience Act (Regulation (EU) 2024/2847). In force since Dec 2024; core obligations apply ~Dec 2027, vulnerability/incident reporting ~Sept 2026. Requires manufacturers of products-with-digital-elements to maintain an SBOM and handle vulnerabilities. hawser’s lock→SBOM path is a direct compliance enabler for customers, and hawser itself must be CRA-conformant as a product we sell.
  • US EO 14028 / NIST SSDF (SP 800-218) and SLSA v1.0 provenance. hawser emits build/composition provenance attestations (in-toto style) so the composed tree carries verifiable “what went in, from where, at which id”.

5. Cryptography & integrity

Sensitive customers require cryptographic integrity end to end, and a documented crypto inventory. Design principles: verify by default where the customer opts in, never invent crypto, be FIPS-swappable, be secret-hygienic.

5.1 Signature verification (source integrity)

  • Verify commit/tag signatures before checkout when the customer enables it: OpenPGP (GPG), SSH signing, and Sigstore / gitsign (keyless). Policy modes: off | warn | require. require fails sync on any unsigned/unverified repo.
  • Trust anchors configured per workspace (allowed signer sets, keyrings, Fulcio roots).

5.2 Baseline integrity

  • Signed lockfile / signed resolution attestation. haw lock --sign produces a detached signature or an in-toto attestation over the canonical lock bytes, so a reviewer can prove the baseline was not altered after approval.
  • Canonical, byte-stable lock serialization is a prerequisite (§8).

5.3 Tool identity (a qualified tool must be identity-verifiable)

  • Signed, reproducible haw releases. Cosign/Sigstore signatures + SHA-256 checksums + SLSA build provenance for every artifact and every LTS. Customers verify the binary they run matches the qualified one.

5.4 Hash agility

  • Support git’s SHA-256 object format alongside SHA-1, and record which is in use in the lock. Long-lived safety programs outlive SHA-1’s collision safety margin; store the strongest available id. Never rely on SHA-1 as the sole integrity guarantee for a baseline.

5.5 Crypto module & transport

  • TLS to forge APIs via rustls; no protocol downgrade, pinned minimum TLS 1.2 (prefer 1.3). For FIPS 140-3 environments, ship a build backed by a FIPS-validated module (e.g. rustls + aws-lc-rs FIPS, or system OpenSSL FIPS) and document the boundary.
  • Publish a cryptographic inventory (algorithms, libraries, versions) — required for crypto-agility audits and FIPS/Common-Criteria conversations.

5.6 Secret handling

  • Forge tokens and credentials are never written to haw.toml, haw.lock, logs, or workspace state. Source them from the OS keychain, git credential helpers, or a secrets manager (Vault) via env/helper. Redact any credential-shaped string in logs and errors.

5.7 Export control (hawser as a crypto-bearing product)

  • hawser uses/links cryptography → likely ECCN 5D002 under the US EAR. Path: self-classify as mass-market (740.17 / mass-market note) and, for the open-source core, file the published-encryption-source notification to BIS/NSA (§742.15(b)). Publish the ECCN and classification so customers can clear import/use in their jurisdiction.
  • Wassenaar/dual-use: dual-use commercial software; keep it out of ITAR scope by not bundling controlled technical data. Defense customers own their ITAR/EAR program controls.

6. Data protection (GDPR & equivalents)

hawser processes personal data incidentally: git author/committer name + email, forge usernames, PR/MR reviewer identities, CI actor, review timestamps. All are personal data under GDPR (and CCPA, LGPD, UK-GDPR).

Design stance — this is a selling point, not just a constraint:

  • Local-first, zero-egress by default. For local operation, data never leaves the operator’s machine/network → the customer is the controller and hawser’s vendor is not a processor (nothing is transmitted to us). This is the cleanest possible posture for classified/regulated networks.
  • No telemetry by default. Any future telemetry is strictly opt-in, anonymized, EU- hostable, and fully documented; air-gapped builds have no network path to us at all.
  • Offline license activation. License validation must not phone home with personal data; provide file-based/offline activation for air-gapped and sovereign environments.
  • Data residency / sovereignty. Self-hosted and air-gapped deployments keep all data inside the customer boundary. For any optional hosted component (license server, mirror), offer an EU region and a signed Data Processing Agreement (DPA).
  • Records of processing (Art. 30) & DPIA support. Provide a data-flow description and a DPIA-input template documenting exactly which personal-data fields hawser reads and where they go (answer: nowhere, for local use).
  • Right to erasure vs immutable history. hawser creates no new personal-data store; it reads git/forge metadata that already exists. Erasure requests are handled at the git history / forge layer by the customer — hawser documents this boundary rather than pretending to satisfy erasure over immutable commit objects.
  • Ship a privacy notice and a security.txt / .well-known/security.txt contact.

7. Security of the tool itself (secure SDLC)

A tool sold into security-critical programs is itself part of the attack surface. Evidence of a secure SDLC is a purchase precondition and feeds ISO 26262 method 1b / DO-330 tool development assurance.

  • Memory safety. unsafe_code = "forbid" workspace-wide (already set). A Rust, memory-safe SCM tool is a concrete differentiator vs C/Python incumbents — state it.
  • Dependency assurance. cargo-audit (RUSTSEC advisories), cargo-deny (license policy
    • advisory + banned-crate gates), and supply-chain review (cargo-vet/cargo-crev) in CI, failing the build on violations.
  • Vendored + reproducible builds. Vendor dependencies for air-gapped rebuild; pin the toolchain (rust-toolchain.toml); byte-reproducible release builds.
  • Own SBOM published per release (§4) with signatures (§5.3).
  • Vulnerability disclosure & CVE handling. Published policy, security contact, coordinated-disclosure window, CVE issuance, and per-LTS backport commitment (§ commercial).
  • Bounded, documented network surface. The tool performs network I/O only for explicit git/forge operations the user invoked; document every egress. No hidden calls.
  • Least privilege. No elevation; no writing outside the workspace + configured cache dir.

8. Auditability & determinism (cross-cutting)

  • Determinism contract. Same manifest + lock + overlays ⇒ byte-identical resolution and tree selection on Linux/macOS/Windows. No Date.now, no unordered iteration in serialization (use ordered maps — already IndexMap), canonical TOML emission for the lock.
  • Structured audit log. Every mutating operation records actor, operation, affected repo, before/after object id, timestamp — machine-readable (JSON) for CI evidence capture.
  • Machine-readable output. --format json on status/verify/graph/evidence so pipelines can capture and diff cert evidence automatically. Stable, versioned schemas.
  • Stable exit codes so CI gates are reliable (0 ok, distinct non-zero for drift / verify failure / signature failure).

9. Feature backlog mapped to compliance requirements

What must be built to make the above real. Phases refer to ARCHITECTURE.md §6.

FeatureEnablesPhase
haw.lock + deterministic resolutionBaseline evidence, qualification1
Drift detection (status/verify)Config verification activity1
--format json + stable schemas + exit codesAuditability, CI evidence capture1
Canonical/byte-stable lock serializationSigned baseline, determinism1
Structured audit logAuditability2
SBOM export (CycloneDX + SPDX)CRA, EO 14028, IEC 62304 SOUP2
Commit/tag signature verification (gpg/ssh/sigstore)Source integrity2/3
Signed lockfile / in-toto attestationBaseline integrity3
Provenance attestation (SLSA/in-toto)Supply-chain assurance3
haw evidence bundleOne-shot cert data package3
SHA-256 object-format support + recordHash agility, long-lived programs3
Signed + reproducible haw releasesTool identity, qualification1→ongoing
Vendored deps + reproducible tool buildAir-gap, secure SDLC1→ongoing
FIPS-validated crypto build variantFIPS 140-3 environmentslater
Offline license activationAir-gap, GDPR zero-egresscommercial
Cryptographic inventory docCrypto-agility / FIPS / CC auditsdoc
Qualification Kit per LTSDO-330 / ISO 26262 / IEC 61508 / EN 50128commercial

Contact the maintainers for how these are packaged, licensed, and supported.

Security & trust model

This page describes what haw trusts and executes, so you can reason about the blast radius of running it against a given repository. For reporting a vulnerability and supported versions, see the repository-root SECURITY.md.

The one-line summary: haw runs code that is written in the manifest and code that lives in your PATH. Treat both as trusted inputs.

The manifest is trusted code

A workspace is defined by a haw.toml manifest. That manifest can declare build, test, run, and exec commands, plus lifecycle hooks. When you run a fleet operation, haw executes those commands through your shell, with your environment and your working tree:

  • haw build / haw test run the manifest’s build / test commands.
  • haw run <cmd> / haw exec <cmd> run arbitrary commands across the fleet.
  • haw sync can trigger hooks and post-checkout steps declared in the manifest.

Consequently, running haw build, haw run, haw exec, or haw sync on an untrusted checkout is equivalent to running that repository’s Makefile. A malicious haw.toml can do anything your shell can do.

Rule of thumb: treat haw.toml exactly like a Makefile or the scripts block of a package.json. Only run lifecycle commands on manifests you have reviewed or otherwise trust. Read-only inspection commands (haw status, haw tree, haw verify) do not execute manifest commands and are safe to run on an untrusted manifest.

Plugins are trusted binaries

haw follows the git / cargo / kubectl extension pattern: any subcommand haw does not recognize is dispatched to a haw-<name> executable found on your PATH. For example, haw jira sync runs haw-jira.

Two properties matter for security:

  1. Plugins are ordinary executables resolved from PATH. Whatever haw-<name> your PATH resolves to is what runs. A crafted PATH (or a haw-* binary dropped into a directory on it) can hijack a subcommand.
  2. Plugins inherit your full environment, including any forge tokens (GITHUB_TOKEN, GITLAB_TOKEN, HAW_*, …) that are exported in the shell that launched haw.

Therefore: install only plugins you trust, and keep your PATH clean — prefer absolute, well-known install locations and avoid putting untrusted or world-writable directories ahead of system paths.

Plugins do run as separate processes, so a broken or hanging plugin cannot crash haw — but process isolation is not a security boundary here: a plugin runs with your privileges and your secrets.

Tokens and credentials

  • Forge tokens are read from environment variables only and used solely for API requests (opening PRs, reading CI status). They are never written to disk and never logged by haw.
  • Git transport authentication is not handled by haw — it stays with your existing SSH keys or your git credential helper.
  • Read-only composition (haw sync, status, tree, verify) needs no token at all; only forge features do.

See the Secrets & tokens section of the README for the exact precedence order per forge.

Supply-chain hardening (this repository)

The haw project itself applies standard supply-chain controls:

  • GitHub Actions are pinned to full commit SHAs (with the human-readable tag in a trailing comment) in every workflow, so a compromised or re-pointed action tag cannot alter release artifacts before they are signed.
  • Release artifacts are signed with cosign (keyless / OIDC).
  • cargo audit and cargo deny run on every push/PR and on a weekly schedule (see .github/workflows/audit.yml and deny.toml) to gate known advisories, disallowed licenses, and unexpected dependency sources.

Reporting

To report a vulnerability, follow the process in the repository-root SECURITY.md: use GitHub private vulnerability reporting or email the maintainer. Do not open a public issue for security problems.