Skip to main content
Glama
README.md
<img src="src/renderer/src/assets/logo.png" alt="" width="112" height="112" />

# GitWarren

**[gitwarren.com](https://gitwarren.com)** — the official site, with downloads for
macOS, Windows and Linux. On macOS there is also a Homebrew cask:

```bash
brew install --cask klarluft/tap/gitwarren
```

There is a command line too, which serves the same review UI in a browser
instead of an Electron window — for a machine that will not have the app on it,
or one with no screen at all:

```bash
brew install klarluft/tap/gitwarren-cli               # macOS and Linux, brings its own Node
curl -fsSL https://gitwarren.com/install.sh | sh      # macOS and Linux, no Homebrew needed
npx gitwarren serve                                   # anywhere Node 22.14+ is, including Windows
```

Then `gitwarren serve --open`. See
[The `gitwarren` command line](#the-gitwarren-command-line).

If you arrive from a coding agent, start there instead. The plugin brings the
MCP server and a note that teaches the agent when to open a review and how to
answer your comments:

```bash
/plugin marketplace add klarluft/gitwarren-app       # Claude Code, then:
/plugin install gitwarren@gitwarren
gemini extensions install https://github.com/klarluft/gitwarren-app
npx skills add klarluft/gitwarren-app                 # the note alone, for any agent
```

Cursor, Codex, VS Code and Kiro read the same repository from their plugin
screens. See [Installing it as a plugin](#installing-it-as-a-plugin).

Code review for your own git repositories, on your own machines. Your machines,
your agents, no one else's server — and no account.

It runs as a desktop app on macOS, Windows and Linux, or as a command that
serves the same review UI into a browser tab. Same renderer either way; the
shell is the only thing that differs. A machine with no screen at all — a VPS, a
WSL distro, a box an agent works on — runs the headless half and is reviewed
from somewhere else.

Reviews live on the machine the code is on, and stay there. GitWarren reaches
your other machines over SSH, over `wsl.exe`, or over your own tailnet, and
nothing is replicated, relayed or stored anywhere but the computers you already
own. See [Your other machines](#your-other-machines).

Built for the moment a coding agent — Claude Code, Codex, or anything else that
edits files on your disk — has just finished, and its work is sitting in your
worktree uncommitted. Read that diff here, on your own machine, before it
becomes a commit.

<a href="https://gitwarren.com"><img src="docs/review-uncommitted.png" alt="A GitWarren review: the files-changed tab with staged, unstaged and untracked work folded into the diff, and the amber uncommitted badge in the branch header" width="800" /></a>

Tell GitWarren which local git repositories you care about, then open **reviews**
against them — a review is a comparison of two refs, presented the way a pull
request is, with *conversation*, *commits* and *files changed* tabs.

The part that makes it worth having: a review can include work that has not been
committed. If the branch you are reviewing is checked out in a worktree,
GitWarren finds that worktree — wherever it is — and folds its staged, unstaged
and untracked changes into the diff. You can review a change before it is a
commit, which is exactly when review is most useful.

Nothing is cached: every branch name, commit and diff on screen is read from git
at the moment it is shown.

Local AI agents get the same capabilities through an MCP server over stdio, and
a plugin puts it into Claude Code, Codex, Cursor, VS Code and Gemini CLI in one
line — see [Agent access (MCP)](#agent-access-mcp).

---

## Contents

- [Stack](#stack)
- [Architecture](#architecture)
- [Reviews](#reviews)
- [Navigating a large diff](#navigating-a-large-diff)
- [Development setup](#development-setup)
- [Project layout](#project-layout)
- [Data storage](#data-storage)
- [Database migrations](#database-migrations)
- [Agent access (MCP)](#agent-access-mcp)
- [Installing it as a plugin](#installing-it-as-a-plugin)
- [The `gitwarren` command line](#the-gitwarren-command-line)
- [Your other machines](#your-other-machines)
- [Linking the user back into the app](#linking-the-user-back-into-the-app)
- [Images in comments](#images-in-comments)
- [Release process](#release-process)
- [Auto-update](#auto-update)
- [Code signing and notarization](#code-signing-and-notarization)
- [Social preview](#social-preview)
- [Known limitations](#known-limitations)
- [Contributing](#contributing)
- [Support and privacy](#support-and-privacy)
- [License](#license)

---

## Stack

| Concern | Choice |
| --- | --- |
| Shell | Electron 44 + TypeScript |
| UI | React 19, Tailwind CSS v4, shadcn/ui-style components on **Base UI** (`@base-ui/react`) |
| Data fetching | SWR (client-side only, no SSR) |
| Storage | SQLite via `better-sqlite3`, Drizzle ORM, generated migration files |
| Validation | zod, shared between UI forms, IPC and MCP tools |
| Agent interface | `@modelcontextprotocol/sdk` over stdio |
| Packaging | electron-builder + electron-updater |

> **Why Electron and not `deno desktop`?** Silent auto-update has to work on
> Windows, and that is the requirement `deno desktop` could not meet. Everything
> in the packaging setup below exists to serve it.

> **Base UI, not Radix.** The components in `src/renderer/src/components/ui`
> follow shadcn/ui conventions (CVA variants, `cn()` merging, the same prop
> shapes) but are built on Base UI primitives. They were written for this
> project rather than pulled from the shadcn registry, because the registry's
> default output targets Radix.

---

## Architecture

The single most important rule in this codebase:

> **The UI and the MCP server both call one shared service layer. Neither one
> contains any repository or review logic of its own.**

```
   ┌────────────────────────────┐         ┌───────────────────────────────┐
   │  Renderer (Chromium)       │         │  MCP server (its own process) │
   │  React + SWR               │         │  stdio JSON-RPC               │
   │                            │         │                               │
   │  window.gitwarren.*        │         │  repository + review tools    │
   └────────────┬───────────────┘         └───────────────┬───────────────┘
                │ contextBridge                           │
                │ ipcRenderer.invoke                       │ direct import
   ┌────────────▼───────────────┐                         │
   │  Main process              │                         │
   │  src/main/ipc.ts           │                         │
   │  (thin delegation only)    │                         │
   └────────────┬───────────────┘                         │
                │                                          │
                └──────────────┬───────────────────────────┘
                               ▼
              ┌──────────────────────────────────────┐
              │  src/core/services/                  │
              │  repositories.ts · reviews.ts        │
              │  validation · path resolution ·      │
              │  duplicate rules · error semantics   │
              └───────┬───────────────────┬──────────┘
                      ▼                   ▼
            ┌──────────────────┐  ┌──────────────────┐
            │ SQLite (WAL)     │  │ git (subprocess) │
            │ durable facts    │  │ live state only  │
            └──────────────────┘  └──────────────────┘
```

The two surfaces are not identical in *reach*: the review service's
`commits` and `diff` reads are wired to the UI only, because an agent can read
the repository with git directly. The rule is that neither surface implements
logic of its own, not that every function must be exposed to both.

Three properties fall out of this shape:

**No drift between surfaces.** `src/main/ipc.ts` is a set of one-line
delegations, and each MCP tool is a thin wrapper. The service re-parses its own
input with the zod schema from `src/shared/schemas.ts` rather than trusting the
caller, so a rule added there applies to the UI, the IPC layer and the agent
tools simultaneously. It is not possible for an agent to write something the UI
would have rejected.

**Two processes, one database.** The GUI and the MCP server are separate OS
processes sharing one SQLite file. Hence WAL journalling and a busy timeout (see
`src/core/db/client.ts`). Changes made by an agent show up in the UI on the next
refresh; the window revalidates when it regains focus.

**`src/core` never imports `electron`.** That is what lets the MCP process reuse
it. It also means the application-data directory is computed by the same
platform-aware function in both processes (`src/core/paths.ts`) rather than one
using Electron's `app.getPath('userData')` and the other guessing.

### Why IPC and not a local HTTP server

The renderer reaches the main process over Electron's context bridge, not over
`fetch` to `127.0.0.1`. A local HTTP server would add a port to allocate and
discover, a listening socket other software on the machine could talk to, and a
startup ordering problem — in exchange for nothing this app needs. SWR is used
exactly as it would be with HTTP; only the fetcher differs.

There is no authentication anywhere. This is a local, single-user app; the MCP
transport is a pipe owned by the agent the user launched, and there is no
network surface to authenticate.

### Error handling across the boundary

Errors cannot cross `ipcRenderer.invoke` intact — Electron stringifies them and
the type is lost. Every handler returns an `IpcResult<T>` envelope instead, and
the preload script rebuilds a real `AppError` on the renderer side. That is what
lets a form show *"This folder is not inside a git repository"* underneath the
path input rather than a generic banner. The MCP tools map the same errors to
`CODE: message` tool errors so agents can branch on the code.

### Attribution

Comments carry an author; nothing else does. The rule that makes it trustworthy
is that **the author is an argument to the service, never a field in the
payload**:

```ts
commentsService.createThread(input, actor)   // actor supplied by the surface
```

`main/ipc.ts` passes `HUMAN_AUTHOR` and nothing else can, because typing into the
app is the only way to reach an IPC channel. `mcp/server.ts` passes an agent
author built from the connection. No caller can name itself by putting an author
in the request body — there is nowhere in the input schemas to put one. See
[Who wrote what](#who-wrote-what).

---

## Reviews

A review is two refs and a title. Everything else on the screen is computed from
git when you look at it.

```
  reviews table                    read live, never stored
  ┌──────────────────┐             ┌───────────────────────────────┐
  │ repository_id    │             │ merge base of the two refs    │
  │ base_ref  "main" │  ──────►    │ commits in base..head         │
  │ head_ref  "feat" │             │ diff from the merge base      │
  │ title            │             │ which worktree holds head     │
  │ description      │             │ that worktree's dirty state   │
  │ status           │             └───────────────────────────────┘
  └──────────────────┘
```

### Why the refs are stored and the commits are not

Pinning the resolved shas at creation time would be the obvious thing to do, and
it would defeat the feature. A review is meant to *follow* its branch: you open
one, keep working, and the review shows the work as it stands. That includes work
that is not committed at all, which no sha could ever refer to.

The cost is that a review can stop resolving — someone deletes the branch. That
is treated as a state to render, not an error: the review row survives, the tab
says which ref went missing, and you can repoint it.

### Merge-base, like a pull request

The *files changed* tab shows `base...head` — what head added since the two
diverged — rather than the literal difference between the endpoints. So commits
that landed on `main` after you branched do not show up as reversals in your
review. The *commits* tab lists the same range, `base..head`.

### A ref against itself

The two endpoints may be the same ref. That is not an empty review: the merge
base of a ref with itself is its own tip, so the diff is exactly what the
worktree holds that has not been committed — the review you want when you just
wrote the code and want a second pair of eyes before it becomes a commit. Such a
review has no commits by definition, is titled *Uncommitted work on `<ref>`* by
default, and is drawn with one endpoint rather than an arrow between two.

### Finding uncommitted work

This is the part that needs care, because **the repository row points at one
directory and the work under review is often in another**. A branch checked out
in a linked worktree has its uncommitted state there, not in the main checkout.

So every read starts with `git worktree list --porcelain`, which enumerates the
main checkout and every linked worktree from *any* of them — it does not matter
which one was added to GitWarren. The worktree whose branch matches the review's
head ref is the one whose `git status` and working-tree diff get read. If no
worktree has that branch checked out, the review quietly falls back to committed
work only and says so.

Given the head's worktree, the diff is `git diff <merge-base>` run **inside it**,
with no second endpoint — which compares the merge base against the working tree,
so committed, staged and unstaged changes all arrive in one patch.

The files-changed tab offers three views of that, and the difference between
them is only which commit the diff is taken against:

| View | Command | What you see |
| --- | --- | --- |
| Committed | `git diff <merge-base> <head>` | the branch as it would arrive if pushed |
| All | `git diff <merge-base>` in the worktree | that, plus everything uncommitted |
| Uncommitted | `git diff <head>` in the worktree | only the edit being made right now |

The third exists for the case where you are making a small change on top of a
long-lived branch and want to see just that change. It is the same view a review
of a ref against itself gives — the merge base of a ref with itself *is* its own
tip — reached without repointing the review's endpoints and back again. It needs
a worktree holding the head; without one it shows nothing rather than silently
widening back out to the whole branch.

Untracked files are handled separately: they are listed with
`git ls-files --others --exclude-standard` (so `.gitignore` still applies) and
rendered as whole-file additions. The tempting alternative — staging them into a
scratch index with `GIT_INDEX_FILE` — would write blobs into the user's object
database just to draw a screen, and this app only ever reads.

The switch at the top of the tab turns all of that off, leaving the committed
diff. It is view state, not part of the review: whether you want to read the
branch as it sits on disk or as it would arrive if pushed is a per-visit
question.

### Reading the diff

`git diff` output is parsed once, in `src/core/diff-parser.ts`, into files,
hunks and numbered lines. Two details that a naive line-splitter gets wrong and
this one does not: paths are taken from the `---`/`+++` and `rename from`/`to`
lines rather than the ambiguous `diff --git a/x b/x` line, and a pure rename
carries no hunks at all yet still has to name both paths. Very large files are
clipped for rendering but still report their true add/delete counts.

### Navigating a large diff

Files changed carries three things a long diff needs, all of them optional and
none of them costing anything until used:

- **A file tree** down the left, folded so a lone directory collapses into its
  parent (`renderer/src` on one row). Clicking a file scrolls to it, and the row
  for whatever is nearest the top of the page stays highlighted as you scroll.
  The toggle beside it is remembered across restarts.
- **Unfolding the lines between the hunks**, the way GitHub does. `git diff`
  prints three lines of context, so most of a file is not on screen; the
  expanders in the gutter reveal twenty lines at a time or the whole run, and
  **Expand all lines** in the file header opens every gap at once. Unfolded
  lines are ordinary context rows — a comment can be left on one exactly as on
  any other line.

A `@@` header announces a break in the file, so it is drawn only while there is
still a break to announce. A folded gap carries the header on its own expander
row, the way GitHub puts the unfold controls there; unfold that gap and the
header goes with it, because the code now runs continuously into the hunk and a
divider across continuous code is a false statement about the file. Expand
everything and the file reads top to bottom with no markers in it at all.

The same rule removes the header from the top of a hunk that starts at line 1,
which is every new file and every deleted one: there is nothing above it to be
separated from. What keeps a header is a real break with no expander to mark it
— which happens in the files the diff cannot unfold at all (binary, clipped),
where it is the only thing saying two lines are not adjacent.
`continuesFromAbove` in `shared/diff-gaps.ts` decides this, using the same
off-by-one convention for empty ranges as the gap arithmetic beside it.
- **Copy path** and **open in your editor**, per file.
- **Back to top**, once you are a screen or so down. It is app-wide rather than
  a diff feature, but the diff is where the scrollbar gets small enough to
  matter. Two details: the whole app scrolls inside `<main>` rather than the
  window, so the button acts on that element (`window.scrollTo` would do
  nothing at all here); and the trip is animated only when it is short enough
  to follow — smooth-scrolling the length of a large diff takes seconds and
  reads as the app hanging, so past five thousand pixels it simply jumps.

Every icon-only control carries a real tooltip rather than a `title` attribute
(`components/ui/tooltip.tsx`). The browser decides when to show a `title` —
usually a second or more after the pointer stops — it cannot be styled, and it
never appears for keyboard users at all; a button whose whole meaning is its
label cannot afford any of that. One `TooltipProvider` at the root groups them,
so the first tooltip waits and moving along a row of buttons then shows each
immediately. `title` is still used for *supplementary* text: the full path
behind a truncated one, the meaning of a badge.

The unfolding costs one read of the whole file, taken the first time the
reviewer asks and reused for every later expansion of the same file. It is
deliberately not a line-range API: a range per click would be a git process per
click, and reading the file once is also the only way to know where it *ends*,
which no hunk header can say. The read follows whichever view of the changes is
on screen, because context taken from another version of the file would not line
up with the hunks it sits between. `src/shared/diff-gaps.ts` holds the arithmetic that
decides where the hidden runs are and which line number each unfolded line gets;
it is pure, and unit-tested against the shapes that get this wrong — a diff that
does not start at line 1, and git's off-by-one convention for an empty range.

### Marking a file reviewed

Each file header carries a **Reviewed** checkbox, and `v` ticks off whichever
file you are on. A ticked file folds away, the file tree marks it and dims it,
and the header counts how many of them are done — so a long diff shrinks to what
is still unread as you work through it.

The mark has to stop being true when the file changes, or the list would claim
someone had read code that did not exist when they looked at it. So what is
stored is not a flag but a digest of the diff that was on screen at the time
(`shared/diff-digest.ts`, a cyrb53 fingerprint of the path, the change status
and every line of every hunk). A mark counts only while the file still hashes to
the same value; when it does not, the tick clears itself and the file is
labelled **Changed since reviewed** — which is more useful than silently
unticking it, because it points at the one file that moved after being read.

Two consequences fall out of that rather than needing code of their own. Flipping
*include uncommitted* is a different diff, so a file read in one setting is not
ticked in the other. And reverting a change restores the mark, because the file
hashes the same way it did before.

The comparison runs in the renderer, against the diff being rendered, and the
main process only stores digests: the "include uncommitted" switch means one
review has two diffs at once, and a mark resolved against the one you are not
looking at would be answering a question nobody asked. The rows live in
`reviewed_files`, keyed by review and path, and go with the review when it is
deleted. There is no MCP tool for them — an agent claiming a human has read a
file would make the only honest signal on the screen worthless.

### Opening a file in an editor

`system.editors()` probes for VS Code, Cursor, Windsurf, Zed, Sublime Text and
the JetBrains launcher, once per run: the application bundle in the usual
locations, and the command on `PATH`. Whatever is found is offered in a picker
next to the diff, and the choice is kept in `localStorage` — a preference of the
person, not a fact about the review, and this app has no settings screen to put
it on.

Opening prefers the URL scheme the application registered for itself
(`vscode://file/…:12`), which carries the line number and works whether or not
the user ever installed the shell command; the CLI is the fallback, and the
platform's default handler for the file is the fallback to that.

Set `GITWARREN_EDITOR` to override, either with an id from the list above or
with a command template:

```sh
GITWARREN_EDITOR='emacsclient +{line} {file}'
```

The file is resolved inside the worktree that holds the head branch, not
necessarily the directory the repository was added from — the same rule the rest
of the app follows. A file that exists only in a commit has nothing to open, and
says so.

---

## Development setup

Requirements: the Node in [`.nvmrc`](.nvmrc) (**24.20.0**, which ships npm
11.19) and **git on your PATH** (GitWarren shells out to your own git rather
than bundling one). With `nvm` or `fnm` the version is picked up automatically
on `cd`; `engines` in `package.json` sets the floor at Node 24.20 / npm 11.10
and `.npmrc` sets `engine-strict`, so an older toolchain fails loudly instead
of quietly writing a lockfile CI cannot install.

```bash
npm install          # also rebuilds native deps for Electron
npm run dev          # start the app with hot reload
```

Other scripts:

| Command | Does |
| --- | --- |
| `npm run dev` | Run the app in development with HMR |
| `npm run build` | Typecheck, then build main / preload / renderer / MCP / daemon |
| `npm test` | Integration tests against a real SQLite file and real `git` |
| `npm run typecheck` | `tsc --noEmit` for both the Node and web projects |
| `npm run lint` | ESLint (type-aware) |
| `npm run db:generate` | Regenerate migrations after editing the Drizzle schema |
| `npm run mcp:dev` | Run the MCP server from source against your dev database |
| `npm run serve:dev` | Run the headless daemon from source, protocol on stdin/stdout |
| `npm run package` | Build installers for the current platform, no publish |
| `npm run release` | Build **and publish** to GitHub Releases |

The tests create throwaway git repositories in a temp directory and point the
app at a temp data directory via `GITWARREN_DATA_DIR`, so they never touch your
real database.

---

## Project layout

```
src/
├── shared/            Imported by every process. No Node-only APIs.
│   ├── schemas.ts       zod schemas — the source of truth for validation
│   ├── git.ts           read-only git shapes (types, not schemas — see below)
│   ├── actors.ts        who wrote a comment; Human vs "<tool> (AI)"
│   ├── comment-anchors.ts  re-finding a comment's lines after the branch moves
│   ├── diff-gaps.ts     where a diff's hidden lines are, for unfolding them
│   ├── validation.ts    one zod-error → AppError conversion, used everywhere
│   ├── errors.ts        AppError + the error-code vocabulary
│   ├── routes.ts        the hash grammar, as data — parsed by three processes
│   ├── deep-link.ts     gitwarren:// URL ⇄ Route, the hostile-input boundary
│   ├── link-port.ts     41427: the one port every install agrees on
│   ├── rpc.ts           the message protocol — requests, responses, events
│   └── api.ts           IPC channel names and the bridge's type
│
├── core/              The shared service layer. Never imports electron.
│   ├── paths.ts         per-platform data directory (+ env override)
│   ├── instance.ts      this install's id, minted once into the data directory
│   ├── daemon-runtime.ts  who owns this machine right now, for other processes
│   ├── rpc/             the dispatcher, and one carrier per way of asking
│   ├── git-exec.ts      the one place `git` is spawned
│   ├── git.ts           live repository state; root resolution
│   ├── git-compare.ts   worktrees, refs, commits, diffs, dirty state
│   ├── diff-parser.ts   unified diff → files/hunks/lines
│   ├── attachment-ingest.ts  rewrite a body's local image paths to tokens
│   ├── db/              drizzle schema, client (WAL), migration resolution
│   └── services/        repositories.ts, reviews.ts, comments.ts,
│                        attachments.ts — the one implementation of each
│                        operation
│
├── main/              Electron main process
│   ├── index.ts         window lifecycle
│   ├── ipc.ts           thin delegations to core/services
│   ├── attachment-protocol.ts  serves gitwarren:// attachment images
│   ├── deep-link.ts     receives gitwarren:// URLs from the OS
│   ├── link-server.ts   the loopback page holding the "Open GitWarren" button
│   ├── updater.ts       electron-updater wiring
│   ├── editors.ts       finds the user's code editor and opens a file in it
│   ├── tray.ts          the menu bar / notification area item: Open, Quit
│   ├── login-item.ts    start at login, per platform; opt-in
│   ├── start-hidden.ts  whether this launch should come up without a window
│   └── mcp-launch.ts    maintains ~/.gitwarren/bin/gitwarren-mcp
│
├── preload/           The only bridge into the renderer
├── daemon/            The core with a pipe instead of a window
│   ├── serve.ts         argv, signals, exit code → out/daemon/serve.cjs
│   └── daemon.ts        opens the database, picks a carrier
├── mcp/               stdio MCP server
│   ├── server.ts        tool definitions
│   ├── gui-link.ts      the `guiUrl` on every review and comment payload —
│   │                    always a link, whether or not the app is running
│   └── identity.ts      naming an agent from its MCP handshake
└── renderer/          React app (no Node access)
    └── src/
        ├── assets/          logo.png, inlined as a data: URI by the CSP
        ├── components/      markdown.tsx + ui/ (shadcn-style, on Base UI)
        ├── features/        repositories/, reviews/, comments/, agent/,
        │                     settings/
        └── lib/             api access, error helpers, hash router
```

`shared/schemas.ts` holds zod schemas; `shared/git.ts` holds plain types. The
rule dividing them: **zod is for values that cross a trust boundary** — anything
a caller supplies that the service must not believe. Git output is produced by
reading the disk and flows one way out to the UI, so a runtime schema for it
would be ceremony with no payoff.

Outside `src/`, `gitwarren-logo.png` in the repository root is the 1710px master
of the logo. The two files that are actually used are cut from it and should be
recut from it rather than from each other:

| File | Size | Used for |
| --- | --- | --- |
| `build/icon.png` | 1024px, artwork inset to 860px | electron-builder renders the `.icns`, `.ico` and Linux icons from it; `main/index.ts` also hands it to `BrowserWindow` so Linux windows have an icon at all. The inset is the padding the macOS icon grid expects — without it the Dock icon sits noticeably larger than its neighbours. |
| `src/renderer/src/assets/logo.png` | 128px, no padding | The app header, and the image at the top of this README. |

The repository root is also the plugin, so the manifests that install GitWarren
into an agent sit beside the source rather than under it. Three plugin formats
and a registry entry, because no two families of tool read the same manifest:

| File | Read by |
| --- | --- |
| `.claude-plugin/marketplace.json` | Claude Code, as the marketplace `/plugin marketplace add klarluft/gitwarren-app` adds — this repository, listing exactly one plugin: itself. |
| `.claude-plugin/plugin.json` | Claude Code, as that plugin's manifest. |
| `.mcp.json` | Claude Code, for the server the plugin carries. Names `packaging/plugin/start.mjs` through `${CLAUDE_PLUGIN_ROOT}`. |
| `plugin.json` | Codex, Cursor, VS Code and Kiro, through the shared [Agent Plugins](https://agent-plugins.org) manifest. |
| `mcp.json` | The server entry beside it, for those same tools. Names `npx -y gitwarren mcp --serve` — the published package rather than a path, since they install from the repository without leaving a checkout behind to point at. |
| `gemini-extension.json` | Gemini CLI, for `gemini extensions install`. Carries its own copy of that same command. |
| `server.json` | The [MCP registry](https://registry.modelcontextprotocol.io), as `io.github.klarluft/gitwarren`. Published by the release workflow, after the npm package. |

What those manifests point at is the plugin itself — three files a person
notices, and one that does the starting:

| Path | What |
| --- | --- |
| `skills/gitwarren/SKILL.md` | The note that teaches the agent one habit — open a review when a task that changed code is done, hand over the link, read the comments before the next task. Also what `npx skills add klarluft/gitwarren-app` installs on its own. |
| `commands/gitwarren.md` | `/gitwarren`, which opens the review on demand. |
| `agents/gitwarren-reviewer.md` | The reviewer that reads a change with git and leaves its findings as line comments, attributed as machine-written. |
| `packaging/plugin/start.mjs` | The starter behind `.mcp.json`: the launcher of a GitWarren that is *listening* on this machine if there is one, `npx gitwarren mcp --serve` if there is not. Its header has the reasoning. |

Each of those manifests insists on carrying its own `version`, and none can
point at `package.json` instead, so `scripts/sync-plugin-versions.mjs` copies
the number into all five. It runs from the `version` script on `npm version`,
and `--check` is the CI gate for the hand-edited case.

[Installing it as a plugin](#installing-it-as-a-plugin) has the install lines
and the rest of the reasoning.

---

## Data storage

One SQLite file in the OS application-data directory:

| Platform | Location |
| --- | --- |
| macOS | `~/Library/Application Support/GitWarren/gitwarren.db` |
| Windows | `%APPDATA%\GitWarren\gitwarren.db` |
| Linux | `~/.config/GitWarren/gitwarren.db` (or `$XDG_CONFIG_HOME`) |

Set **`GITWARREN_DATA_DIR`** to override it — used by the tests, and handy for
trying things against a scratch database.

Alongside the database, in the same directory, is `attachments/` — images
copied in from comments, named by the sha256 of their contents and sharded a
directory deep (`attachments/ab/abc….png`). It is the only other thing GitWarren
writes.

Connection settings, all in `src/core/db/client.ts`:

- `journal_mode = WAL` — the GUI can read while the MCP server writes
- `busy_timeout = 5000` — wait out a brief lock instead of failing
- `synchronous = NORMAL` — the recommended durability level under WAL
- `foreign_keys = ON`

### What is and is not stored

Five tables.

**`repositories`** — `id`, `path` (canonical repository root, UNIQUE), `name`,
`createdAt`, `updatedAt`.

**`reviews`** — `id`, `repositoryId`, `title`, `description`, `baseRef`,
`headRef`, `status`, `createdAt`, `updatedAt`, `closedAt`. Deleting a repository
cascades to its reviews; they are meaningless without it.

**`comment_threads`** — `id`, `reviewId`, then the anchor: `filePath`, `side`,
`line`, `anchorText`, `anchorSha`. All five are null together for a review-level
thread and set together for a line comment. Plus `resolvedAt`, `resolvedBy`,
`createdAt`, `updatedAt`. Cascades from `reviews`.

**`comments`** — `id`, `threadId`, `authorKind`, `authorName`, `authorLabel`,
`authorSession`, `body`, `createdAt`, `updatedAt`. Cascades from
`comment_threads`.

Authorship is denormalised onto every comment row rather than pointing at a
users table, and there will not be a users table. An author here is not an
account but a description of where a message came from — the person at the
keyboard, or a named agent process that has since exited. Copying the label onto
the row keeps that description true forever, which a foreign key to a mutable
identity would not.

**`attachments`** — `sha` (PRIMARY KEY), `ext`, `mimeType`, `byteSize`, `width`,
`height`, `originalName`, `createdAt`. See [Images in
comments](#images-in-comments). Note what it does *not* have: a foreign key to
the comment it belongs to. The body text is the only record of which images a
comment uses, and unreferenced rows are collected by a sweep at startup — so
deleting an image from a comment is just deleting it from the text.

**Not** stored: branch, existence, resolved commits, diffs, or anything else git
owns. Those are read on demand every time they are displayed. Caching them would
mean showing a branch name that stopped being true the moment you switched
branches in a terminal — and for reviews it would break the feature outright,
since a review is supposed to track uncommitted work that no sha can name.

### The duplicate rule

When you add a path, the service runs `git rev-parse --show-toplevel` on it and
stores the **repository root**, then canonicalises that with
`fs.realpath.native` — which resolves symlinks *and* reports true on-disk casing
on macOS and Windows. So `/work/app`, `/work/app/src/lib` and `/WORK/APP` all
collapse to one row, backed by a UNIQUE index as the final guard.

---

## Database migrations

Migrations are generated files, committed to the repo, and applied
automatically the first time either process opens the database — so the MCP
server is equally safe to start first.

```bash
# after editing src/core/db/schema.ts
npm run db:generate
```

**Making this work in the packaged app** is the part that usually breaks.
Drizzle's migrator reads `.sql` files from a folder at runtime, but the app's
source lives inside `app.asar`. So `drizzle/` is copied to the app's resources
directory via `extraResources`, and `src/core/db/migrations.ts` resolves it in
this order:

1. `GITWARREN_MIGRATIONS_DIR` if set
2. `process.resourcesPath/drizzle` — the packaged location
3. walking up from the working directory — the dev location

Each candidate is validated by checking for `meta/_journal.json`, so the dev
fallback cannot accidentally match in a packaged app. This path is verified: the
packaged MCP server runs migrations correctly when started from a directory with
no source tree anywhere above it.

---

## Agent access (MCP)

The MCP server exposes seventeen tools, all backed by the same services the UI
uses:

| Tool | Notes |
| --- | --- |
| `list_repositories` | Includes live git state. Read-only. |
| `get_repository` | By id. Read-only. |
| `add_repository` | `path` may be any directory inside the working tree. `name` defaults to the folder name. |
| `update_repository` | Rename, and/or repoint at a moved working copy. |
| `remove_repository` | Stops tracking only — never touches the working copy. |
| `list_reviews` | Filterable by `repositoryId` and `status`. Read-only. |
| `get_review` | By id, with its repository attached. Read-only. |
| `create_review` | Both refs must exist and share history. `title` defaults to `"<head> into <base>"`. |
| `update_review` | Title, description, endpoints, or open/closed. |
| `remove_review` | Deletes the review record only. |
| `agent_identity` | How this session's comments will be attributed. Optionally sets a session `label`. |
| `list_review_comments` | Every thread, with messages, authors, resolved `attachments`, and where each one lands in the current diff. Read-only. |
| `add_review_comment` | Opens a thread. Omit `filePath`/`line` for a review-level comment. Local image paths in the body are copied in and rewritten. |
| `reply_to_review_comment` | Adds a message to an existing thread. Same image handling as above. |
| `resolve_review_comment` | Marks a thread settled, or reopens it. |
| `update_review_comment` | Edits one message. Own comments only. |
| `delete_review_comment` | Deletes one message; the thread goes too if it was the last. |

Every result above that carries a review or a comment also carries a `guiUrl`
that opens it in the app — see
[Linking the user back into the app](#linking-the-user-back-into-the-app).

**There is deliberately no `get_review_diff` or `list_review_commits`**, even
though the service layer produces both for the UI. An agent pointed at these
repositories can run `git log` and `git diff` itself, against the real working
tree, with whatever options the task needs — a tool returning a second-hand copy
would be a lossier version of data the agent already has. What GitWarren
uniquely holds is the *discussion* around the code, which is what the comment
tools carry.

Failures come back as tool errors prefixed with the code
(`NOT_A_GIT_REPOSITORY`, `DUPLICATE_REPOSITORY`, `PATH_NOT_FOUND`, `NOT_FOUND`,
`INVALID_INPUT`, `FORBIDDEN`, `GIT_UNAVAILABLE`), so an agent can react to the
kind of failure rather than parsing prose.

### Linking the user back into the app

Every payload that carries a review or a comment also carries a **`guiUrl`** —
an address that opens the app on exactly that review, and on the commented line
where there is one. It is there so an agent can end its turn with a link instead
of "I've left three comments on review 4, have a look".

It is **never null**. It used to be, whenever the app was not running, because
the port it named was one the OS had handed that particular launch. But a
`guiUrl` outlives the call that made it — pasted into a chat, left in a commit
message, read on Thursday — so deciding at mint time that the user has nothing
to open it with is a guess about a moment that has not happened yet, and it was
wrong in the ordinary case: the user closes the window, the agent works for
twenty minutes, the user opens it again. A dead link costs one refused
connection in a browser. A null cost an agent telling the user there was
nothing to click. The tool descriptions now say what a refused connection means
instead.

The URL names the instance that minted it, in the fragment:

```
http://127.0.0.1:41427/#h=<instance-id>/review/4/conversation
```

which the page turns into `gitwarren://<instance-id>/review/4/conversation`.
That is what lets a link resolve on whichever GitWarren the user clicked from —
the app can tell its own review 4 from another machine's. If the install it
names is a host this one knows, the link opens *that* machine's review — the
host segment travels with it, so this install's review 4 is never reachable by
a link that meant another machine's.

If the install it names is one this GitWarren has never been told about, the
screen says so and offers to fix it: it names the machine the link came from,
looks for it on your tailnet, and — when it is there — adds it and opens the
review you clicked. When it is somewhere only SSH or WSL reaches, the Hosts
screen remembers what you were opening and offers the way back once the machine
is in the list.

When the page at that address is served by a daemon rather than by the app —
`gitwarren serve`, or the plugin's `gitwarren mcp --serve` — the link also
carries that launch's token:

```
http://127.0.0.1:41427/?token=<token>#h=<instance-id>/review/4/conversation
```

The web view is behind the token (see
[The token, and why nothing is copied](#the-token-and-why-nothing-is-copied)),
and a link without it lands on a page saying so. A person who typed `serve` has
the token on their terminal; the person an agent's plugin is serving has it in
a log they will never read. So the MCP server reads it from the same 0600 file
`gitwarren open` does and puts it in the link. The handler exchanges it for the
session cookie and takes it back out of the address bar, and the route survives
in the fragment. The app's own link page needs no token, so links minted while
the app owns the port are unchanged. A link from before a daemon restart
carries a token that no longer exists, and the newest link is the one that
works.

The link is a chain of three hops, and each one is load-bearing:

```
http://127.0.0.1:52413/#review/4/files/src%2Fapp.ts/head/42   ← what the agent prints
        │  terminal linkifies it, and clicking opens the browser
        ▼
a one-page server inside the GUI, serving an "Open GitWarren" button
        │  the user clicks it
        ▼
gitwarren://review/4/files/src%2Fapp.ts/head/42                ← OS protocol handler
```

**Why not hand out the `gitwarren://` URL directly?** Terminals linkify `http`
and almost none of them linkify a custom scheme, so the agent would be printing
text the user has to copy by hand.

**Why a button rather than a redirect?** Two reasons. Browsers refuse scripted
navigation to a custom scheme — but more importantly, the click is what makes
the window actually come forward. Windows grants foreground rights only to the
process that *is* foreground or that launched the one asking, so an Electron app
woken by a background HTTP request cannot raise itself: `win.focus()` flashes the
taskbar and stops there ([electron#2867](https://github.com/electron/electron/issues/2867)).
GNOME's Mutter demotes self-requested activation in much the same way. A protocol
launch from the browser the user just clicked in inherits the right on all three
platforms. So the third hop is not an inefficiency to optimise away — it is the
only hop that works.

A consequence worth keeping: **the loopback server never takes an action.** It
answers every request with the same static page and has no other endpoint. That
is a property to defend rather than an accident of it being small — anything on
loopback is reachable by every process on the machine and by whatever web page
the user has open next, so an endpoint here that mutated state, read a
repository or drove IPC would be a capability handed out to the whole world. It
validates the `Host` header, and the route it is linking to never even reaches
it: that rides in the URL fragment, which browsers do not send.

The port is **41427**, fixed, on `127.0.0.1` and never `0.0.0.0`. It used to be
whatever the OS handed out (`listen(0)`), written to a runtime file for the MCP
server to read — which meant a link could only be minted while the app was
running, and only for this machine. Neither survives contact with a second
machine: a link written on one is read on another, and a link left in a comment
on Tuesday is clicked on Thursday. So the port is a constant every install
agrees on (`src/shared/link-port.ts`, chosen in spike S6 for being outside every
default ephemeral range, absent from `/etc/services`, and not on Chromium's
restricted-port list), and `guiUrl` no longer depends on anything being up.

If something else holds 41427, the app starts anyway and says which port and
why; links are still handed out, because they name the same port on every
machine and must not depend on this one's luck. The *Agent access* panel shows
the warning.

`daemon-runtime.json` in the data directory still records who owns this machine
— instance id, pid, link port, and whether the owner is the GUI or a daemon —
but nothing needs it to build a link any more. Readers treat it as a hint and
never as a fact: a crash leaves it behind, so the pid is checked with
`process.kill(pid, 0)` before it is believed, and it is re-read on every call
rather than cached.

The incoming URL is **parsed to a `Route` before anything acts on it**, never
forwarded as a string, using the same grammar the hash router uses
(`shared/routes.ts`). Comment bodies are agent-writable, so this parser will
one day receive `gitwarren://review/../../../../etc/passwd`; anything it does not
recognise degrades to the home screen. It is the same whitelist-not-filter
reasoning as `main/attachment-protocol.ts`. Note that `gitwarren:` is registered
twice over, for two unrelated mechanisms — an OS protocol handler and Chromium's
`protocol.handle` for attachment images. They coexist because they answer to
different hosts: `review` and `attachment`, each ignoring the other's.

### Who wrote what

Comments from the UI are `Human`. Comments over MCP are `<tool> (AI)`. The
question that shapes the design is where `<tool>` comes from — and the answer is
**not** "the agent tells us".

Asking an agent to name itself does not survive contact with reality: the same
Claude Code install would introduce itself as *Claude*, *claude-code*, *Claude
Code* and *Claude Opus* across four sessions, and a thread with four names for
one participant is worse than a thread with none.

So the name is taken from the MCP handshake instead. Every client sends
`clientInfo: { name, version }` in `initialize`, before any tool runs, and the
SDK keeps it (`Server.getClientVersion()`). That value is chosen by the *tool*
rather than by the model driving it, which is exactly the property needed:

```
initialize { clientInfo: { name: "claude-code" } }   →   "Claude Code (AI)"
initialize { clientInfo: { name: "codex-cli"   } }   →   "Codex (AI)"
initialize { clientInfo: { name: "opencode"    } }   →   "opencode (AI)"
```

`mcp/identity.ts` maps the known clients to names their users would recognise.
An unknown client is not lumped in with the rest — it is title-cased and used as
is (`some-new-agent` → `Some New Agent`), which still identifies that tool
consistently across all of its own sessions. A client that sends no `clientInfo`
at all becomes plain `AI`, so the one guarantee the UI makes — a machine-written
comment is always marked as one — holds even there.

**Telling two sessions of the same tool apart.** stdio gives one server *process*
per client session, so the process is the session: an 8-character id is minted at
startup and stamped on everything that session writes. That keeps two concurrent
Claude Code sessions distinct in the database with no cooperation from either.
A session id is not a *name*, though, so an agent may also set a short label for
itself — `auth-refactor`, `perf-pass` — which is remembered for the rest of the
session and renders as `Claude Code · auth-refactor (AI)`. This is the one
self-reported piece, and it is fine that it is: it is a nickname for a session,
not a claim about identity, and the tool name underneath it is still the
handshake's. It can also be pinned per-project in the server config with
`GITWARREN_AGENT_LABEL`.

**Editing.** The person at the keyboard may edit or delete anything — it is their
app. An agent is held to its own tool's messages. That asymmetry is not security
(there is no attacker in this model); it is the difference between an agent
fixing its own typo and an agent quietly rewriting someone else's review.

### Comments on code that keeps moving

A review follows its refs rather than pinning a sha, so the diff a comment was
written against is not the diff the next visitor sees. GitHub avoids this by
pinning each comment to a commit; GitWarren cannot, because following the branch
is the point of the app.

Instead, each line comment stores the **text** of the line as well as its number,
and the anchor is re-derived on every read (`shared/comment-anchors.ts`). The
rule is to trust the text over the number — a line number is a position in a
document that keeps being rewritten:

| State | Meaning | Where it shows |
| --- | --- | --- |
| `anchored` | The stored line still holds the text it was commented on. | Inline, at that line. |
| `moved` | The text is now at a different line. | Inline, at its new line, badged *moved*. |
| `outdated` | The text is not in this diff at all. | Listed above the file, badged *outdated*. |

`outdated` covers both "the code was rewritten under it" and "the comment was
left on a line the diff never showed" — an agent commenting on an unchanged part
of a file, say. Both mean the same thing to a reader, so both are kept and shown
out of line rather than dropped. Where several identical lines match (a lone `}`),
the nearest to the original position wins; a near miss inside the right file
beats losing the comment.

The same function runs in both surfaces. The renderer anchors against the diff
already on screen — which matters, because each view of the changes is a
genuinely different diff with different line numbers — and
`list_review_comments` anchors against a diff it reads itself, so an agent and
the screen never disagree about where a comment sits.

### Comments on a block of lines

Press the `+` in the gutter and drag down it, or shift-click a second line, to
comment on several lines at once. Agents get the same thing by passing
`startLine` to `add_review_comment`.

A range is stored as `startLine` plus `line`, where **`line` is the last line**
— and that asymmetry is the design. Only one end carries an anchor text, and the
rest of the range follows it by keeping the span the same length. Re-finding
both ends independently would let a range quietly grow, shrink or invert when
one of them matched somewhere unhelpful, and a comment that claims to cover code
it was never about is worse than one sitting a line off. A range of one line is
normalised to no range at all, so nothing downstream has to compare the two
numbers to find out whether a comment is about a block.

The diff marks every line a range covers with a bar in the gutter, and the
thread itself renders under the last line — where the eye already is after
dragging down to it.

### Getting from the conversation back to the code

Clicking a thread's file header in *Conversation* opens *Files changed*
scrolled to that line, with the line marked for a couple of seconds. The target
goes in the hash (`#/reviews/3/files/src%2Fapp.ts/head/42`), so it is a location
like any other: it survives a reload and the back button works.

The line in the URL is the **resolved** one, not the stored one — the
conversation tab has already anchored the thread against the diff it is
displaying, so a comment that has moved still lands on the code it is about. A
thread whose line is gone from the diff falls back to scrolling to the file's
card, which is where such a thread is listed.

### Images in comments

Comment bodies and review descriptions are **markdown** — GitHub-flavoured, so
tables, task lists, strikethrough and autolinks all work. The composer has the
usual Write/Preview tabs and a formatting toolbar, and the preview renders
through the same component the posted comment does, so it cannot drift.

Two things are deliberately not rendered. **Raw HTML** is not, which is why
there is no sanitiser anywhere in this app — react-markdown does not render
embedded HTML unless asked, so there is nothing to misconfigure. And **remote
images** are not: an `https://` image renders as a link, and the renderer's CSP
has no remote `img-src`. Both exist because a comment here may have been written
by an agent that just read untrusted content out of the repository under review,
and it is stored and replayed into the window every time someone opens it.

Images that *are* rendered come from the app's own store:

```
  body        ![dropdown behind modal](gitwarren://attachment/abc….png)
                                       └──────────────┬──────────────┘
  disk        <dataDir>/attachments/ab/abc….png       │  opaque token
  renderer    <img src="gitwarren://…">  ─────────────┘  custom protocol
  agent       attachments[].path  →  /Users/…/attachments/ab/abc….png
```

**Humans** paste, drop or pick an image; it is copied in and the markdown is
inserted at the cursor. **Agents** write a file to disk and reference it as an
ordinary markdown image — the path is rewritten to a token when the comment is
saved. They cannot upload: base64 in a tool call means *emitting* over half a
million characters for a 400KB screenshot, so a path is the only workable
currency. In the other direction, every comment carries a resolved `attachments`
array whose `path` is a real file, which an agent reads with the tools it
already has. That is why there is no `get_attachment` tool — a path is strictly
more reliable than an MCP `ImageContent` block, whose delivery varies by client.

The bytes are copied rather than referenced because **a discussion has to
outlive the file it is about**: `/tmp` gets purged, `test-results/` is wiped at
the start of every Playwright run, and a pasted screenshot has no path at all.
It is the same reason `anchorSnapshot` exists. Files are content-addressed by
sha256, which makes ingest idempotent — necessary, since the GUI and the MCP
server are separate processes that can ingest the same image at once.

Two details are load-bearing and easy to get wrong. The rewrite **parses** the
markdown rather than pattern-matching it, so an agent's example image inside a
fenced code block is not silently ingested. And it **splices** the original
string by node offset rather than re-serialising the parsed tree, so a body
comes back byte-identical apart from its URLs — a round trip through mdast would
quietly renormalise an author's bullet markers and fenced code.

A path that does not resolve is left in the text as written and the comment
saves anyway, on the same principle the composer already applies to humans: the
comment is worth more than the link.

### Installing it as a plugin

The repository root is also a plugin, in three formats at once, so one address
installs GitWarren into whichever agent a person uses:

| Agent | How |
| --- | --- |
| Claude Code | `/plugin marketplace add klarluft/gitwarren-app`, then `/plugin install gitwarren@gitwarren` |
| Codex, Cursor, VS Code, Kiro | The same repository, from each tool's plugin screen, through the shared [Agent Plugins](https://agent-plugins.org) manifest |
| Gemini CLI | `gemini extensions install https://github.com/klarluft/gitwarren-app` |
| Any agent, the note alone | `npx skills add klarluft/gitwarren-app` |

What the plugin carries, beyond the server: `skills/gitwarren/SKILL.md`, the
note that teaches the agent one habit — open a review when a task that changed
code is done and hand over the link, read the review's comments before the next
task, reply in the thread and resolve what was fixed — and the rules around it;
`commands/review.md`, a `/gitwarren:review` command that opens the review on
demand;
and `agents/gitwarren-reviewer.md`, a reviewer that reads a change with git and
leaves its findings as line comments in the review, attributed as
machine-written, next to yours.

**Which GitWarren answers.** The plugin carries no GitWarren of its own.
Claude Code's entry runs `packaging/plugin/start.mjs`, which asks whether a
GitWarren is *listening* on the machine. If one is, it runs the launcher that
GitWarren wrote, so links open there. If not, it runs `npx gitwarren mcp
--serve`: the published package, with the review page switched on, so a link
the agent hands out opens even on a machine with nothing else installed. The
other formats name that command directly. "Listening" rather than "installed",
because an installed-but-closed app would leave the agent handing out dead
links; the starter's header has the reasoning.

**The Node on the PATH has to be 22.14 or newer.** `better-sqlite3` ships a
prebuilt binary built against Node-API 10, and under an older Node - any 22.x
before 22.14, which has Node-API 9 - it loads and then segfaults on the first
database read, which an agent reports as "server failed to connect" and
nothing more. Claude Code runs the plugin with the first `node` on the PATH,
so a shell whose default Node is old fails even on a machine that also has a
new one. The starter checks the Node-API version before spawning anything and
says which Node it found and which it needs; `gitwarren mcp` checks the same
and then opens the addon once in a child process before loading the server, so
any other crash is a sentence rather than a silence. The npm package's
`engines` says the same minimum.

`gitwarren mcp` is the server by name, and `--serve` is the page beside it: the
same `--listen` a person gets from `gitwarren serve`, loopback and token-gated,
for exactly as long as the agent keeps the server running. It defers to a
running app or `serve` the way `serve` does, and it exits when the agent's pipe
closes, so no page outlives the session that started it.

The server is also listed in the [MCP registry](https://registry.modelcontextprotocol.io)
as `io.github.klarluft/gitwarren`, from `server.json` at the repository root,
published by the release workflow after the npm package. The directories that
copy from the registry list it from there.

### Pointing an agent at it by hand

Without the plugin, or for a harness that only speaks MCP:
**GitWarren shows you the exact configuration for your install** — open the
*Agent access* page (the card on the home screen, or `g a`) and copy the prompt
at the top of it. The browser shell has the same page, and on a machine with no
screen `gitwarren agent-setup` prints the same words. The paths depend on where
GitWarren was installed, so prefer one of those over the notes below.

### One command, everywhere

GitWarren maintains a launcher at a path that is the same on every machine:

| | |
| --- | --- |
| macOS, Linux | `~/.gitwarren/bin/gitwarren-mcp` |
| Windows | `%USERPROFILE%\\.gitwarren\\bin\\gitwarren-mcp.cmd` |

It takes no arguments and needs no environment, and the app rewrites it
whenever the install moves — after an update, after dragging the app to a
different folder, after switching between a packaged build and a source
checkout. So an agent config that names it keeps working, and the *Agent
access* page leads with a sentence you paste into whatever agent you use
rather than with JSON you paste into a file:

> Set up the GitWarren MCP server for yourself. It speaks MCP over stdio and is
> started with the command `~/.gitwarren/bin/gitwarren-mcp` (no arguments, no
> environment). Register it under the name "gitwarren" in your own MCP
> configuration, then call its `agent_identity` tool to confirm it works.

Agents know their own configuration format better than a page can. What they
need from us is a stable command.

To configure it by hand instead, that command is all an entry needs. Three
formats cover every harness we know of, and the page generates all three from
the launcher path (`gitwarren agent-setup --manual` prints them too):

```json
{
  "mcpServers": {
    "gitwarren": { "command": "/Users/you/.gitwarren/bin/gitwarren-mcp" }
  }
}
```

for Claude Code, Cursor, Windsurf and Gemini CLI; the same object called
`servers` for VS Code; and TOML for Codex:

```toml
[mcp_servers.gitwarren]
command = "/Users/you/.gitwarren/bin/gitwarren-mcp"
```

On Windows, double every backslash in that TOML string — `\U` is a real escape,
so a path pasted raw parses into a different one rather than into an error.

### What the launcher wraps

Two lines around the app's own Electron binary in Node mode. That is
deliberate: `better-sqlite3` is a native addon that must be loaded by a runtime
whose ABI it matches, and it has to resolve out of the app's unpacked
`node_modules`. Using the bundled binary satisfies both, and means **no Node
installation is required**.

An AppImage is the interesting case, and the reason this path exists at all: it
re-mounts itself at a new `/tmp/.mount_*` directory on every launch, so nothing
inside it is worth writing down. Its one stable path is the `.AppImage` file,
which AppRun exports as `APPIMAGE` and whose mount point it exports as
`APPDIR`, so the launcher names the former and finds the server through the
latter at run time. Nothing needs extracting.

From a source checkout, `npm run mcp:dev` runs the same server against your dev
database.

The app does not need to be running for the MCP server to work — both open the
same database independently, and an agent gets a working `guiUrl` either way.

---

## The `gitwarren` command line

The same GitWarren, with a browser tab for a shell. One binary, a handful of
subcommands, and no Electron anywhere in it.

```bash
# Run it now
gitwarren serve [--open]        # run GitWarren in this terminal and print its URL; Ctrl-C stops it
gitwarren open [link]           # open the running GitWarren in your browser

# Keep it running
gitwarren service install       # run GitWarren in the background, from now and at every login
gitwarren service uninstall     # stop that, and remove the login item
gitwarren service status        # what is running, and where the data is

# Let a coding agent in
gitwarren agent-setup           # print the one sentence to give an agent so it can reach this GitWarren

# Keep it current, or remove it
gitwarren update                # move to the newest release (--check only says whether there is one)
gitwarren doctor                # check every path GitWarren asks another program to run; --fix repoints stale ones
gitwarren uninstall             # remove GitWarren from this machine; reviews stay unless --data says otherwise

gitwarren serve --stdio         # answer GitWarren's protocol on stdin/stdout (what another machine spawns)
```

It exists for two audiences that the app cannot serve. Someone who will not
install an Electron app gets the identical renderer in a tab — every line is
shared, the shell is not. And a machine with no screen at all — a VPS, a WSL
distro, a box an agent works on — gets the daemon and the MCP server, which is
what [Your other machines](#your-other-machines) is built on.

### Which command you want

Three things a person wants from it, and one command for each. They are
independent: none of them requires another to have been run first.

- **Use it now.** `gitwarren serve` runs GitWarren in the terminal until Ctrl-C,
  and prints the URL. `--open` opens it as well; `gitwarren open` in another
  terminal does the same later.
- **Have it always there.** `gitwarren service install` registers a login item
  — a LaunchAgent on macOS, a `systemd --user` unit on Linux, an at-logon task
  on Windows — and starts it now, so `gitwarren open` and the links an agent
  hands you always have something to open. `gitwarren service uninstall` undoes
  it.
- **Let an agent in.** `gitwarren agent-setup` prints the sentence to paste into
  Claude Code, Codex or any other MCP client. The MCP server is part of every
  install and reads the same SQLite file the browser view does, so an agent can
  open and comment on reviews whether or not GitWarren is being served — what
  serving adds is that the `guiUrl` an agent hands back opens in a browser.

All three write the same two files, `~/.gitwarren/bin/gitwarren` and
`~/.gitwarren/bin/gitwarren-mcp`, the first time they run; see
[`service install`](#service-install) for what they are.

### Four ways to install it

| | |
| --- | --- |
| `brew install klarluft/tap/gitwarren-cli` | Pours the self-contained tarball. Brings its own Node, so nothing on the machine can upgrade out from under the native addon. macOS and Linux. |
| `curl -fsSL https://gitwarren.com/install.sh \| sh` | The same tarball, without Homebrew — for a Linux box or a Mac with nothing on it. Unpacks into `~/.gitwarren/daemon/<version>/` and writes `~/.gitwarren/bin/gitwarren`, which is the layout the app itself produces when it installs onto a host over SSH, so either can upgrade what the other installed. Add `~/.gitwarren/bin` to `PATH`. `packaging/install.sh` is the script; `GITWARREN_VERSION` pins a release. |
| `npx gitwarren` | Uses the Node you already have (22.14 or newer; the SQLite prebuild needs Node-API 10); `better-sqlite3` arrives as an ordinary dependency. The Windows answer, and about 700 KB. |
| The release tarball | `gitwarren-daemon-<v>-<target>.tar.gz`, unpacked anywhere and run as `bin/gitwarren`. What the two rows above and the SSH installer all use. |

The formula is `gitwarren-cli` and the cask stays `gitwarren`. The tokens differ
so `brew install klarluft/tap/gitwarren` keeps meaning the app; the *binary* is
called `gitwarren` in all four.

### Updating, and removing it again

```bash
gitwarren update            # move to the newest release
gitwarren update --check    # only say what is installed and what is newest
gitwarren doctor            # is every path GitWarren hands out still pointing at something?
gitwarren uninstall         # remove it; add --data to take the reviews too
```

**One of the four installs is ours to replace, and `update` says so about the
other three.** An install under `~/.gitwarren/daemon/<version>/` — what
`install.sh` writes, and what the app installs onto another machine over SSH —
is versioned, has a stable launcher in front of it and no package manager with
an opinion about it, so `gitwarren update` does the whole thing: downloads the
release, checks it against the sha256 the release published in its own Homebrew
formula, unpacks beside the destination and renames into place, has the *new*
binary write the launchers (which is also the proof that it runs here),
restarts the background service if one is running, and deletes the version it
replaced. A Homebrew, npm or npx copy belongs to its package manager; `update`
names `brew upgrade gitwarren-cli` rather than writing over a Cellar, and
`--check` still tells that user a new release exists. See `src/cli/layout.ts`,
which is the file that decides which case this is.

Two things `update` does that re-running `install.sh` does not, and did not:
the background daemon that is *already running* is restarted, rather than
serving the old code until the next login; and the version it replaced is
removed, rather than left in `~/.gitwarren/daemon` forever at ~45 MB a time.

**`uninstall` removes only what it can attribute to this install.** The login
item, the launchers in `~/.gitwarren/bin` that name *this* install, and
`~/.gitwarren/daemon` when GitWarren put it there. It prints the plan and asks
before doing any of it — `--yes` to skip the question, and `--yes` is required
when nothing is attached to the terminal. Reviews are kept unless `--data` is
given. A launcher belonging to another GitWarren — commonly the desktop app,
which writes `gitwarren-mcp` too — is named and left alone, because removing it
would take agent access away from an install the user never touched.
Afterwards it lists the agent configs that may still name the launcher, which
is the one part of this no command can do for you.

**`doctor` is for the failure with no symptom on this machine.** A launcher
whose target is gone — after `brew uninstall`, after an AppImage is deleted,
after a checkout is rebuilt elsewhere — still sits there, and the only thing
anyone sees is their agent reporting *MCP server failed to connect*, in another
product, with nothing naming the cause. `gitwarren doctor` reads every path
GitWarren asks another program to run, marks the broken ones with `!` and exits
non-zero so a script can be what notices; `--fix` rewrites a stale launcher to
name this install. It never deletes: a file at a launcher path that GitWarren
did not write is reported and left where it is.

`gitwarren service uninstall` remains the smaller command — it stops the
background GitWarren and removes the login item, and leaves everything else. To
remove a Homebrew install the command is still `brew uninstall gitwarren-cli`;
run `gitwarren uninstall` first and it will take the launchers and the login
item with it, which `brew` does not know about.

### The token, and why nothing is copied

`gitwarren serve` binds `127.0.0.1` only and mints a token for that launch,
which it writes to `web-token` in the data directory at mode 0600 and prints in
the URL. `gitwarren open` reads that file and hands the whole URL to the
browser, which swaps it for a `SameSite=Strict` cookie on the first request. A
token is never copied by a person, never persisted across a launch, and
revoking it is quitting the process. See `src/core/web/token.ts`.

`gitwarren open` also takes a link — either a `gitwarren://` deep link or the
`http://127.0.0.1:41427/#h=…` URL an agent hands out — and lands on that review
rather than the home screen. The argument is parsed to a route and written back
out from that, so nothing typed on a command line is pasted into a URL that is
then handed to the operating system.

### `service install`

Two things, and only the second is about logging in:

1. **The launchers.** `~/.gitwarren/bin/gitwarren` and `~/.gitwarren/bin/gitwarren-mcp`,
   at the paths the rest of GitWarren already names — the Agent Access page
   prints the second as a command to paste, and the app spawns the first over ssh as
   `~/.gitwarren/bin/gitwarren serve --stdio`. Rerunning after an update points
   them at the install that ran last. `gitwarren serve` and `gitwarren
   agent-setup` write the same two files when they are missing, the way the app
   writes the MCP one on every launch, so nobody has to ask for a login item to
   get an agent working.
2. **The login item.** A LaunchAgent on macOS, a `systemd --user` unit on Linux,
   an at-logon Scheduled Task on Windows — each running `gitwarren serve
   --listen`, and each started right away as well as at the next login.
   `--no-login-item` writes the launchers and stops, which is what a headless
   host wants and what the SSH installer and `install.sh` ask for.

Nothing restarts a dead daemon, deliberately. `serve --listen` has a refusal it
is *meant* to exit on — a data directory has one owner, so it stands aside when
the app is running — and under launchd's `KeepAlive` or systemd's `Restart=`
that refusal becomes a process respawning every ten seconds for as long as
GitWarren is open. See `src/cli/units.ts`.

The launcher scripts name absolute paths for the migrations folder and the web
build rather than inheriting them. Both have a fallback relative to the working
directory, and a login item does not have one — launchd starts a job in `/`.
That is resolved once, at install time, while the answer is still knowable; see
`src/cli/install.ts`.

---

## Your other machines

A repository lives on one machine, and so does its review. GitWarren does not
copy either. What it does instead is reach the machine the code is already on,
run the same review there, and render it in the window in front of you.

Five rules hold this together, and every screen below follows from them:

1. **A host owns its repositories.** SQLite, git and the MCP server for a repo
   live on the machine that repo is on. Reviews never move.
2. **Nothing syncs.** The window is a view onto hosts. It caches nothing across
   a disconnect, and a machine that is offline is shown as offline rather than
   as its last known state.
3. **One protocol, several carriers.** The same requests, responses and events
   run unchanged over a child-process pipe, `wsl.exe`, `ssh`, or a WebSocket.
4. **Links resolve where they are clicked.** A loopback link names the host in
   its fragment and opens on whichever GitWarren you clicked from. A tailnet URL
   is offered in addition, but only while that host is actually listening.
5. **Agents never cross the network.** MCP stays on stdio, local to its host,
   reading real paths. The daemon exists for the human elsewhere, not for the
   agent next to the code.

**Other machines** is where all of it is driven, in both directions: the
machines this one reaches, and whether this one can be reached back.

### Over SSH

Add a machine you can already reach over `ssh` — a VPS, a build box, a PC's WSL
distro — and GitWarren installs itself there over the same connection. The host
needs nothing but git: the daemon tarball ships a Node binary of its own, so
there is no runtime to install and nothing to keep up to date by hand. It is
fetched from the GitHub release by the machine you are sitting at and streamed
down the pipe.

Nothing is left running. `ssh host gitwarren serve --stdio` is spawned on
demand, and a connection pool hangs up after ten idle minutes rather than
holding a socket open to every machine you own.

### WSL, from the Windows app

A WSL distro is a host like any other, reached over `wsl.exe` instead of `ssh`.
The Windows app lists the distributions on the machine and installs into the one
you pick, running as that distribution's own default user.

Windows-native repositories stay first class — most Windows developers do not
run WSL, and agents have run natively there since late 2025. A Windows path and
a WSL path are *different machines*, so a WSL path offered as a local repository
is refused rather than read through `\\wsl$`, which is the wrong architecture
even on the days it works.

### On your tailnet

Turn on **Reachable on your tailnet** and GitWarren runs `tailscale serve` in
front of the loopback port. Every request then has to carry a Tailscale login
equal to this machine's owner; anything else is refused before it reaches the
dispatcher. There is no pairing token, and nothing is exposed to the internet —
`funnel` is deliberately not used.

Your other machines find this one by themselves: peers from
`tailscale status --json` are probed, and the ones that answer are proposed as
hosts with the instance id they reported. Manual entry stays for everything
else. A machine added twice under two names is recognised as one machine,
because the identity that settles it is the instance id rather than the address
you typed.

A listening host is also the only kind that can **push**. Comments and reviews
arrive as events the moment they are written — including writes from an agent,
which pokes the owner of its data directory over the port it already publishes.
A host reached over SSH or `wsl.exe` has no process of its own to push from, so
there the 15-second poll is still the floor. It is the floor everywhere: a lost
event costs seconds, never correctness.

### The phone

Nothing was built for it. The web view is reachable at the host's `webUrl` from
any device on the tailnet, and `tailscale serve` supplies the identity, so there
is no token to get onto a phone. Below `lg` the files list and the diff become
separate screens and the composer sits above the keyboard.

MCP results carry that `webUrl` alongside the always-present loopback `guiUrl`
whenever the host is listening — so a link an agent prints can be opened on the
machine you are holding, not only the one it ran on.

The full design, its spikes and the outcome of every milestone are in
[docs/across-hosts.md](docs/across-hosts.md).

---

## Release process

Artifacts and the update manifest are published to **GitHub Releases**
(`klarluft/gitwarren-app`, configured in `electron-builder.yml`).

```bash
# 1. Bump the version. electron-builder reads it from package.json,
#    and it becomes the version electron-updater compares against.
npm version patch          # or minor / major — creates a commit and a tag
#    The `version` script copies the number into the plugin manifests at the
#    repository root, so that one commit says the version everywhere it appears.

# 2. Verify before shipping.
npm run lint && npm test

# 3. Build and publish.
export GH_TOKEN=<a token with `repo` scope>
npm run release            # electron-builder --publish always

# 4. Push the tag.
git push --follow-tags
```

`npm run release` runs the typecheck, builds all four bundles, packages the
installers, and uploads them plus the manifests to a GitHub release for the
current tag. The release is created as a **draft** — publish it in the GitHub UI
when you are ready, and that is the moment clients begin to see the update.

The same workflow publishes the npm package, and after it the server's entry in
the [MCP registry](https://registry.modelcontextprotocol.io) from `server.json`
at the repository root, for stable tags only. Both use the job's OIDC token; no
secret is involved.

Publishing a *stable* release also fans out to two other places, both on the
`release: published` event: `deploy-site.yml` rebuilds gitwarren.com so its
download buttons point at the new assets, and `homebrew-tap.yml` asks
[klarluft/homebrew-tap](https://github.com/klarluft/homebrew-tap) to move its
cask to the new version and checksums. The tap needs a `HOMEBREW_TAP_TOKEN`
secret for that nudge to be immediate; without one it still catches the
release on its own schedule within a few hours.

### Prereleases

A tag carrying a prerelease component — `v0.1.7-beta.3` — is a build for
testers, and the pipeline keeps it away from everyone else. The draft is
created `--prerelease`, and both fan-outs above decline to run for one: the
website goes on advertising the newest stable release, and the Homebrew cask
stays where it is.

That flag is load-bearing. GitHub's `/releases/latest` skips a prerelease, and
that endpoint is what electron-updater asks on behalf of every install running
a stable version — `allowPrerelease` is derived from the *installed* version,
so a 0.1.6 install never looks at a beta. Nothing else in the release says so:
the update manifests inside a beta are still named `latest.yml`, because
electron-builder derives no channel for the GitHub provider. Clear the flag,
or tick *Set as the latest release* while publishing, and every stable install
takes the beta on its next six-hourly check.

So publish one explicitly rather than through the UI's defaults:

```bash
gh release edit v0.1.7-beta.3 --draft=false --prerelease --latest=false
```

Testers keep updating among themselves from there — a beta install looks for
`beta-mac.yml`, gets a 404, and falls back to the `latest.yml` in the same
release — and each one moves to the next stable release on its own, with no
reinstall, as long as that version is higher than the beta they are on.

To build without publishing (for local testing):

```bash
npm run package        # installers into release/<version>/
npm run package:dir    # unpacked app only, much faster
```

### What gets produced

| Platform | Artifacts |
| --- | --- |
| Windows | `GitWarren-<v>-x64.exe`, `-arm64.exe` (NSIS), `.blockmap` each, `latest.yml` |
| macOS | `-arm64.dmg`, `-x64.dmg`, `-arm64.zip`, `-x64.zip`, `.blockmap` each, `latest-mac.yml` |
| Linux | `-x86_64.AppImage`, `-arm64.AppImage`, `latest-linux.yml`, `latest-linux-arm64.yml` |
| Any host | `gitwarren-daemon-<v>-{linux,darwin}-{x64,arm64}.tar.gz` |
| Homebrew | `gitwarren-cli.rb`, the formula with this release's four checksums |
| npm | `gitwarren@<v>`, published from `out/npm` by trusted publishing |

The `.blockmap` files are what make updates differential: electron-updater
compares block hashes with the installed version and downloads only the changed
ranges.

The macOS **zip is required** — electron-updater reads the zip, not the dmg.
Dropping that target still produces a working installer but silently breaks
auto-update.

The **daemon tarballs** are not installers and electron-updater ignores them.
Each carries a Node binary, the CLI and MCP bundles, the one matching
`better_sqlite3.node`, the migrations and the web build — about 40 MB, and
enough to run GitWarren on a box with nothing installed on it. They are built by
the `daemon` job in `release.yml` from `scripts/build-daemon-tarball.mjs`, on
one runner for all four targets, and nothing in them is compiled: better-sqlite3
ships a prebuild for each, and the Node binaries are downloaded.

There is no Windows tarball, on purpose. A `.tar.gz` is not how anything is
installed there, and both audiences are already served — a desktop user installs
the app, and someone who wants the command line has `npx gitwarren`.

Their **file names are a contract**. A GitWarren installing a daemon on a
remote host runs `uname -sm` there, maps the answer to one of the four targets,
and fetches `gitwarren-daemon-<version>-<target>.tar.gz` from the release by URL
— one request, no listing and no search. The Homebrew formula names the same
URLs. Renaming them breaks both.

The **npm package** carries no credential to publish it. The `daemon` job asks
GitHub for an OIDC token, npm trades that for a credential good for minutes, and
the exchange also produces a provenance attestation — so there is no `NPM_TOKEN`
in this repository's secrets and there is not meant to be one. The trust is
configured on the package at npmjs.com against this repository and the
*filename* `release.yml`, which is the one thing to remember: renaming that
workflow breaks publishing, and it fails as an authentication error rather than
as a name mismatch.

`gitwarren@0.1.7` was published by hand, because a trusted publisher can only be
configured on a package that already exists and npm has no pre-registration for
one that does not. Nothing else will be.

The **Homebrew formula** is rendered by `scripts/build-homebrew-formula.mjs`
from `packaging/homebrew/gitwarren-cli.rb` in the same job that builds the
tarballs, hashing the exact files it is about to upload, and attached to the
release as `gitwarren-cli.rb`. The tap copies that file rather than computing
anything of its own — a tap that hashed the release separately could hash it
before an asset was re-uploaded, and the result is `SHA256 mismatch` on a user's
machine with nothing on either end to say why.

Cross-building for every platform from one machine is not reliable (Windows
code signing and macOS notarization both need their own host). Run the release
on each platform, or in a CI matrix, and publish to the same tag.

---

## Auto-update

Behaviour: check on launch and every 6 hours, download in the background without
asking, apply on the next restart. The only UI is a quiet banner once a version
is staged, offering an immediate restart. Doing nothing is also fine — it
applies on the next quit either way. A failed check never interrupts the
session; the app keeps running on the current version and retries later.

`src/main/updater.ts` sets `autoDownload` and `autoInstallOnAppQuit` explicitly.
Both are library defaults, but they *are* the requirement, so they should not be
silently inherited.

Auto-update is inert when `app.isPackaged` is false, so development builds don't
try to reach GitHub on every launch.

### Why these targets

| Platform | Target | Silent update |
| --- | --- | --- |
| Windows | NSIS, **per-user** (`perMachine: false`, `oneClick: true`) | ✅ |
| macOS | zip (feed) + dmg (distribution) | ✅ |
| Linux | AppImage | ✅ |
| Linux | deb / rpm | ❌ — needs `apt`/`dnf` and a sudo prompt |

The Windows install is **per-user**, which is what keeps updates free of UAC
prompts. A per-machine install writes to `Program Files` and every update would
raise an elevation dialog — which would defeat "silent" entirely.

`deleteAppDataOnUninstall` is off, so uninstalling does not throw away the
user's repository list.

---

## Code signing and notarization

**Not required for local development builds.** Unsigned builds run fine on your
own machine; electron-builder logs `skipped macOS application code signing` and
carries on.

They *are* required before distributing to anyone else — and specifically,
**auto-update on macOS will not work unsigned**, because Squirrel.Mac validates
the code signature of the downloaded build before swapping it in.

The hardened runtime is already enabled, with entitlements in
`build/entitlements.mac.plist` covering what this app actually needs: JIT for
V8, library validation disabled (the app spawns `git`, and agents spawn the
bundled MCP server), and user-selected file access for repositories on any
volume. `notarize: true` is set in `electron-builder.yml`, which stays inert
until both a signature and Apple credentials exist — see *How the switches
interact* below.

### macOS: one-time setup

Everything here happens once per developer account, not once per release. It
needs a paid Apple Developer Program membership ($99/year).

**1. Create the Developer ID Application certificate.**

This is the certificate for apps distributed outside the Mac App Store. Note
that only the **Account Holder** can create one under an organization
membership — a plain Admin cannot, and the certificate type simply will not
appear in the list for them.

Do this through the developer portal rather than through Xcode. Xcode's
*Settings → Accounts → Manage Certificates* is fewer clicks, but it never asks
which sub-CA to issue under and has been observed picking the legacy one — see
*Check which sub-CA issued it* below, which is worth reading before you start
rather than after.

1. Open **Keychain Access → Certificate Assistant → Request a Certificate From
   a Certificate Authority**. (This works with only the Command Line Tools
   installed; Xcode is not needed for any of it.)
2. Enter your Apple ID email and a common name, leave *CA Email Address* empty,
   choose **Saved to disk** and tick **Let me specify key pair information**.
3. Key size 2048 bits, algorithm RSA. Save the `.certSigningRequest`.
4. Go to [developer.apple.com/account/resources/certificates](https://developer.apple.com/account/resources/certificates),
   press **+**, choose **Developer ID Application**, and upload the request.
   Pick the *G2 Sub-CA* profile type when asked.
5. Download the resulting `.cer` and double-click it to install into the login
   keychain.

The private key never leaves your Mac — Apple only ever sees the request. That
also means **Apple cannot re-issue this key if you lose it**, so export the
`.p12` described under *CI secrets* below and keep a copy somewhere durable. An
account is limited to five Developer ID Application certificates, and each is
valid for five years when issued under the current sub-CA.

Confirm the result:

```bash
security find-identity -v -p codesigning
# 1) ABC123... "Developer ID Application: Klarluft B.V. (XXXXXXXXXX)"
#    1 valid identities found
```

The parenthesised code is the **Team ID**. It is also on
[developer.apple.com/account](https://developer.apple.com/account) under
*Membership details*.

**Check which sub-CA issued it.** Apple's original *Developer ID Certification
Authority* intermediate expires on **1 February 2027**, and a leaf certificate
cannot outlive its issuer — so a certificate issued under it is silently
truncated to whatever remains of that date instead of running the full five
years. The *G2 Sub-CA* exists to replace it:

```bash
security find-certificate -c "Developer ID Application" -p |
  openssl x509 -noout -issuer -dates
```

An expiry of exactly `Feb  1 22:12:15 2027 GMT` means the legacy sub-CA issued
it, whatever the portal appeared to offer. Create a fresh one under **G2
Sub-CA** and retire the short one as described below. Note that an account is
limited to five Developer ID Application certificates and a retired one still
occupies a slot until it expires, so it is worth getting this right rather than
iterating.

**If the new certificate shows up as invalid, the intermediate is missing.**
macOS ships the original Developer ID intermediate but not necessarily the G2
one, and a certificate whose chain cannot be completed is not counted as a
valid identity — so `security find-identity -v` stays silent about it while
`security find-identity` (no `-v`) lists it happily. That difference is the
diagnosis:

```bash
security find-identity -p codesigning        # lists it
security find-identity -v -p codesigning     # does not
```

Install the missing link from [Apple's certificate authority
page](https://www.apple.com/certificateauthority/):

```bash
curl -O https://www.apple.com/certificateauthority/DeveloperIDG2CA.cer
security add-certificates -k ~/Library/Keychains/login.keychain-db DeveloperIDG2CA.cer
```

It grants no new trust — the intermediate is itself issued by Apple Root CA,
which macOS already trusts. It only supplies the link needed to build the chain.

**Do not leave both certificates in the keychain.** Their common names are
identical, so `codesign` cannot tell them apart and refuses to guess:

```
Developer ID Application: ... : ambiguous (matches "Developer ID Application: ..."
and "Developer ID Application: ..." in .../login.keychain-db)
```

That is a build failure, not a silent wrong choice — and pinning
`mac.identity` to a SHA-1 hash does not avoid it, because electron-builder
resolves the hash and then passes `codesign` the *name*. Once the replacement
is confirmed working, delete the old certificate and its private key:

```bash
security delete-identity -Z <sha-1 of the old certificate> ~/Library/Keychains/login.keychain-db
```

**Retiring is all you can do — a Developer ID certificate cannot be revoked
from the portal.** App Store certificates have a *Revoke* button; Developer ID
certificates deliberately do not, because revocation invalidates every app ever
signed with that certificate, timestamps included. It is reserved for a
*compromised* private key and has to be arranged with Apple Product Security by
email. Deleting the key you no longer want is not that situation: with the key
gone the certificate cannot sign anything, and it simply expires on schedule.

**2. Create an app-specific password for notarization.**

Notarization uploads the build to Apple and cannot use your ordinary password
under two-factor auth. At [appleid.apple.com](https://appleid.apple.com) →
*Sign-In and Security → App-Specific Passwords*, generate one and keep the
`xxxx-xxxx-xxxx-xxxx` string.

An App Store Connect API key works instead, via `APPLE_API_KEY`,
`APPLE_API_KEY_ID` and `APPLE_API_ISSUER`. It is the better choice for a shared
CI account, because it is scoped and revocable without touching a person's
Apple ID; the app-specific password is fewer steps for a single developer.

### Building a signed release locally

electron-builder finds the certificate in the login keychain on its own.
Notarization needs the credentials in the environment:

```bash
export APPLE_ID="you@example.com"
export APPLE_APP_SPECIFIC_PASSWORD="xxxx-xxxx-xxxx-xxxx"
export APPLE_TEAM_ID="XXXXXXXXXX"

npm run package
```

Storing the password in the keychain instead keeps it out of the shell history
and out of a dotfile:

```bash
xcrun notarytool store-credentials gitwarren \
  --apple-id "you@example.com" \
  --team-id "XXXXXXXXXX" \
  --password "xxxx-xxxx-xxxx-xxxx"

export APPLE_KEYCHAIN_PROFILE=gitwarren
npm run package
```

Expect the run to take noticeably longer than an unsigned one. Apple's
notarization service usually answers within a few minutes, but it queues, and
each architecture is submitted separately. The log lines to look for are
`signing  file=release/.../GitWarren.app  identityName=Developer ID
Application: ...`, then `notarization successful`. Stapling happens
automatically after that, so the finished app validates on the user's machine
without a network round-trip.

### Verifying a signed build

Worth doing once, on the first signed release, rather than discovering a
problem from a user:

```bash
APP="release/0.1.0/mac-arm64/GitWarren.app"

# The signature is intact and covers every nested binary.
codesign --verify --deep --strict --verbose=2 "$APP"

# Signed by the right authority, with the hardened runtime on.
codesign -dv --verbose=4 "$APP" 2>&1 | grep -E 'Authority|TeamIdentifier|flags'
# Authority=Developer ID Application: Klarluft B.V. (XXXXXXXXXX)
# TeamIdentifier=XXXXXXXXXX
# flags=0x10000(runtime)

# The notarization ticket is stapled to the bundle.
xcrun stapler validate "$APP"

# What Gatekeeper will decide on the user's machine.
spctl -a -vvv -t install "$APP"
# source=Notarized Developer ID
```

`source=Notarized Developer ID` is the line that matters. Anything else — most
often `source=Unnotarized Developer ID` — means the signature landed but the
notarization did not, and the download will still be refused.

### CI secrets

The release workflow reads five optional secrets. Setting them switches the
GitHub Actions build from ad-hoc to properly signed and notarized; leaving them
unset keeps the existing unsigned behaviour.

Export the certificate *with its private key* from Keychain Access — select the
**Developer ID Application** entry under *My Certificates*, right-click →
*Export*, choose **Personal Information Exchange (.p12)**, and set a password.
Then:

**Pipe the values in; do not paste them.** A base64 `.p12` runs to several
thousand characters, and many terminals silently truncate a paste of that size
into an interactive prompt. The result is a secret that looks set and fails
much later as `MAC verification failed during PKCS12 import (wrong password?)`
— which reads as a password problem when the certificate is what got cut short.

The certificate pair has to be stored as one verified unit, so
[`scripts/set-signing-secrets.sh`](scripts/set-signing-secrets.sh) does it:

```bash
./scripts/set-signing-secrets.sh ~/Documents/certificate.p12
```

It prompts for the password without echoing it, refuses to store anything
unless that password actually opens the `.p12` *and* a private key is inside,
and then sets both secrets from exactly those bytes. The remaining three are
short enough to paste at a prompt, which also keeps them out of shell history:

```bash
gh secret set APPLE_ID                    # you@example.com
gh secret set APPLE_APP_SPECIFIC_PASSWORD # xxxx-xxxx-xxxx-xxxx
gh secret set APPLE_TEAM_ID               # XXXXXXXXXX
```

Setting the pair by hand is where this goes wrong, in two ways that produce an
identical error. A base64 `.p12` runs to several thousand characters and many
terminals silently truncate a paste that long, and `echo "$pw" | gh secret set`
stores the trailing newline as part of the password. Both surface much later as
`MAC verification failed during PKCS12 import (wrong password?)`, which reads
as a bad certificate rather than a badly stored one. If you do set them by
hand, pipe the base64 from the file and use `printf '%s'` rather than `echo`.

The release workflow checks that `CSC_LINK` and `CSC_KEY_PASSWORD` agree before
it builds anything, so a mistake here surfaces in seconds with a message naming
the cause rather than several minutes in. The secret names are unchanged, and
`scripts/set-signing-secrets.sh` still sets them.

**The Apple certificate never reaches a Windows runner.** electron-builder's
Windows packager reads `CSC_LINK` and `CSC_KEY_PASSWORD` too, and handed the
Apple pair it tries to Authenticode-sign an `.exe` with a Developer ID
certificate, failing with `Cannot extract publisher name from code signing
certificate`. Windows carries no certificate of its own to confuse matters:
it signs through Azure Artifact Signing, which keeps the private key, so the
only Windows secrets are the three `AZURE_*` credentials. See *Windows* below.

**On macOS the workflow builds the keychain itself** and never exports
`CSC_LINK`. Setting it would send electron-builder down its own
`createKeychain` path, which imports the certificate and then runs

```
security set-key-partition-list -S apple-tool:,apple: -s -k <password>
```

passing the *certificate* password to a flag that means the *keychain*
password — the keychain's own password is a random value generated a few lines
earlier in `app-builder-lib/out/codeSign/macCodeSign.js` and never reused
there. macOS rejects it and the build dies several minutes in with

```
security: SecKeychainUnlock: The user name or passphrase you entered is not correct.
```

which reads as a bad certificate even when the certificate is perfectly good.
It is unchanged as of electron-builder 26.16.0, so upgrading is not the fix.
The *Import the Apple certificate into a keychain* step therefore creates,
unlocks and populates a temporary keychain itself — passing the keychain
password where it belongs — verifies a Developer ID Application identity
actually landed in it, and hands electron-builder `CSC_KEYCHAIN`, which
`macPackager` consults only when `CSC_LINK` is absent. That step also does the
`CSC_LINK`/`CSC_KEY_PASSWORD` agreement check, so a badly stored secret still
surfaces in seconds rather than several minutes in.

The signing secrets go through `$GITHUB_ENV` rather than a step-level `env:`
block. An absent secret is not an unset variable in GitHub Actions — it is an
empty string, and a step-level `env:` would override what the export step
writes. The loop in *Export the signing secrets that exist* skips empty values
so the unset case stays genuinely unset, which is what lets every credential
here be optional.

### How the switches interact

Three independent things decide what a macOS build comes out as, which is why
none of them has to be toggled per build:

| Certificate | Apple credentials | Result |
| --- | --- | --- |
| absent | either way | ad-hoc signed by `scripts/adhoc-sign.mjs`, not notarized |
| present | absent | signed, `skipped macOS notarization` warning, not notarized |
| present | present | signed, notarized, stapled |

Notarization is only attempted after a real signature succeeds, so `notarize:
true` is harmless on a machine with no certificate — the code path is never
reached. `scripts/adhoc-sign.mjs` stands down as soon as `CSC_KEYCHAIN` or
`CSC_LINK` is set, or a Developer ID identity is in the keychain, so it never
fights with the real signature.

Windows has one switch rather than three, and it is the presence of
`AZURE_CLIENT_ID`:

| Azure credentials | Result |
| --- | --- |
| absent | unsigned installer, no `azureSignOptions` passed, build succeeds |
| present | Authenticode-signed and timestamped by Azure Artifact Signing |

### Windows

Windows signs through [Azure Artifact
Signing](https://azure.microsoft.com/en-us/products/artifact-signing) — the
service Microsoft renamed from Trusted Signing in 2026 — at $9.99/month for up
to 5,000 signatures.

The alternative was an EV certificate. Since June 2023 an OV code-signing key
must live on a hardware token or an HSM, which means a courier, a physical
device, and no clean way to sign from a CI runner. A managed service keeps the
key on Microsoft's side and authenticates with an ordinary client secret, so a
GitHub Actions runner can sign without anything being mailed anywhere.

Eligibility used to be the obstacle: the service was limited to US and Canadian
organizations with three or more years of trading history. At GA in 2026 that
opened to EU, UK and several other organizations and the history requirement was
dropped, which is what made this route possible for a Dutch B.V. Individual
developers are still US/Canada only, so this runs through Klarluft B.V. as an
organization.

**The resources**, all under contact@klarluft.com:

| Thing | Value |
| --- | --- |
| Tenant | `01a162b2-9903-4fcc-ba5b-324524440547` (NL) |
| Subscription | `da65adba-22ab-436a-9f62-66d82c862188` |
| Signing account | `klarluft-bv`, resource group `klarluft-signing`, North Europe |
| Endpoint | `https://neu.codesigning.azure.net/` |
| Certificate profile | `klarluft-public-trust` (Public Trust) |
| Certificate subject | `CN=Klarluft B.V., O=Klarluft B.V., L=Rotterdam, S=Zuid-Holland, C=NL` |

**The secrets** are `AZURE_TENANT_ID`, `AZURE_CLIENT_ID` and
`AZURE_CLIENT_SECRET`, belonging to the `gitwarren-release-signing` app
registration. It holds the *Artifact Signing Certificate Profile Signer* role
scoped to the certificate profile rather than to the whole account, so adding a
second profile later does not silently widen what this credential can sign.
electron-builder picks the three up through Azure's `EnvironmentCredential`.

**The client secret expires.** It was issued on 12 September 2026 with a
two-year life, so it lapses around September 2028. The failure mode is a
release build dying at the signing step with an authentication error and
nothing in the repository explaining why, so it is worth a calendar entry.
Rotate it with

```bash
az ad app credential reset --id <appId> --years 2 --query password -o tsv \
  | gh secret set AZURE_CLIENT_SECRET
```

piping it straight into `gh` so the value is never displayed or written to
disk.

**Certificates last three days.** This is not a misconfiguration — Artifact
Signing issues short-lived certificates and rotates them continuously. It is
also why the RFC3161 timestamp is load-bearing rather than optional: the
timestamp proves the binary was signed while its certificate was valid, so the
signature stays good long after that certificate expires. Without one every
build would stop verifying within 72 hours. electron-builder defaults to
Microsoft's `http://timestamp.acs.microsoft.com`; leave it alone.

**`publisherName` must equal the certificate's common name exactly.**
`verifyUpdateCodeSignature` defaults to true, so electron-updater checks every
downloaded update against that string. A mismatch produces an app that installs
perfectly and then silently refuses every auto-update — worse than shipping
unsigned, and invisible until users stop receiving releases. Read it back from
Azure rather than retyping it:

```bash
az rest --method get --url "https://management.azure.com/subscriptions/da65adba-22ab-436a-9f62-66d82c862188/resourceGroups/klarluft-signing/providers/Microsoft.CodeSigning/codeSigningAccounts/klarluft-bv/certificateProfiles/klarluft-public-trust?api-version=2024-09-30-preview" \
  --query "properties.certificates[0].subjectName" -o tsv
```

**The signing configuration is not in `electron-builder.yml`.** It is passed by
the *Build and publish* step of `release.yml` instead. `winPackager` switches to
the Azure signing manager the moment `win.azureSignOptions` exists and never
checks whether credentials are present, so putting it in the config file would
make every unsigned local Windows build fail at the signing step. Passing it
from the workflow keeps `npm run package` working on a developer's machine with
no Azure access at all.

Signing also only runs on a Windows runner: electron-builder drives it through
the `TrustedSigning` PowerShell module, which it installs into the runner's
`CurrentUser` scope on first use. The release matrix already builds Windows on
`windows-latest`, so this costs nothing.

**SmartScreen reputation still has to accrue.** These are OV-class
certificates, so the *"Windows protected your PC"* warning fades as downloads
accumulate against the publisher rather than disappearing with the first signed
release. Only an EV certificate buys immediate clearance. Updates were never
affected either way — electron-updater verifies the sha512 from the manifest,
not a signature.

### Linux

AppImage needs no signing.

### Releasing before the certificates exist

The release pipeline is complete without any of the above. Every signing secret
is optional, so a tag pushed with none of them set still produces installers for
all three platforms — each platform simply comes out unsigned. That property is
worth preserving deliberately rather than by accident: it is why the Windows
signing configuration is passed from the workflow instead of living in
`electron-builder.yml`, where its mere presence would make an uncredentialled
build fail.

What each platform costs while unsigned:

| Platform | Installs? | Auto-updates? |
| --- | --- | --- |
| Linux | Yes, unchanged | Yes, unchanged |
| Windows | Yes, past a SmartScreen warning | Yes |
| macOS | Yes, past a manual Gatekeeper override | **No** |

Linux is unaffected — an AppImage is never signed. Windows shows *"Windows
protected your PC"* until SmartScreen has built reputation against the
publisher, but installs and updates work throughout. Note that signing alone
does not clear that warning immediately: with an OV-class certificate, which is
what Artifact Signing issues, reputation accrues over downloads.

macOS is the one that is genuinely degraded, in two ways. Gatekeeper refuses a
downloaded build that is not notarized, and the user has to allow it explicitly
in **System Settings → Privacy & Security**, where a *GitWarren was blocked*
row appears after the first launch attempt. Right-click → Open no longer works
as a bypass; Apple removed that in macOS Sequoia. Stripping the quarantine
attribute by hand does the same thing:

```bash
xattr -d com.apple.quarantine /Applications/GitWarren.app
```

Both are fine for a developer trying the app deliberately, and both are far too
much to ask of anyone else.

The second cost is the one to plan around: **auto-update does not work at all on
an unsigned macOS build**, so anyone who installs one is on a dead-end version.
They will not be moved forward by the updater and will have to download the
first signed release by hand. Publishing unsigned macOS artifacts as a
pre-release, rather than as a headline version, keeps that population small.

A local build runs with none of this friction, because a bundle you produced
yourself carries no `com.apple.quarantine` attribute and Gatekeeper is never
consulted. That is why `npm run package` output opens by double-clicking while
the same file downloaded from a release does not.

`afterPack` runs [`scripts/adhoc-sign.mjs`](scripts/adhoc-sign.mjs), which
ad-hoc signs macOS builds whenever no Developer ID is present. This is not a
substitute for signing — Gatekeeper still refuses the download — but it changes
*how* it refuses. Packaging invalidates the seal on the linker signature
Electron ships with, and macOS reports a bundle whose seal does not match as
**damaged**, which reads as malware rather than as the ordinary unidentified
developer users know how to allow. Re-signing ad-hoc makes the signature
self-consistent again, so the refusal is the honest one and the Privacy &
Security override works.

---

## Social preview

The card GitHub shows when this repository is unfurled — in Slack, on X, on
LinkedIn, in iMessage — is `docs/social-preview.png`.

It is **not** picked up from the repository automatically. GitHub has no API
for it, so it is uploaded by hand, once, and then stays put:

**Settings → General → Social preview → Edit → Upload an image.**

GitHub asks for 1280×640 and rejects anything over 1 MB.

To change it, edit the design in `scripts/build-social-preview.mjs` and
re-render:

```bash
node scripts/build-social-preview.mjs
```

That writes every variant to `screenshots-out/` (gitignored) and copies the one
named by `CHOSEN` to `docs/social-preview.png`. The upload is still manual.

The script renders HTML in headless Chrome at 2× and downsamples, so the type
is supersampled rather than aliased. The palette and the five vendored fonts in
`scripts/social-preview/fonts/` are the site's, so the card and
[gitwarren.com](https://gitwarren.com) stay the same brand. Note that the site
builds its own Open Graph image separately, by cropping the hero screenshot —
these two are unrelated and both need updating if the branding moves.

---

## Known limitations

- **git must be installed** and on the PATH. GitWarren shells out to it rather
  than bundling an implementation. If it is missing, the app says so explicitly
  (`GIT_UNAVAILABLE`) instead of showing an empty list.
- **Repository state is read serially per refresh.** Each repository costs a few
  `git` subprocess calls. They run in parallel across repositories, but a list of
  many hundreds on a slow or networked filesystem will feel it.
- **No file watching.** Git state refreshes when the window regains focus or you
  press refresh, not the instant you switch branches elsewhere. The commit and
  diff reads go further and do *not* refresh on focus — re-running a diff every
  time you alt-tab would spawn git processes behind your back — so those tabs
  have an explicit refresh button.
- **Comment threads have no unread state.** The tab shows how many are
  unresolved, not how many are new since you last looked, so a reply an agent
  left overnight is not distinguishable from one you have already read.
- **Comment anchors are matched on exact line text.** Reindenting a line or
  changing its whitespace moves it out of `anchored` even though the code is
  unchanged. A trimmed comparison would handle that, at the cost of matching
  lines that differ only in indentation — which in a diff is a real difference.
- **Agent names are only as consistent as the client's `clientInfo`.** A client
  that changes the name it sends between versions will appear as two
  participants, and there is no way to merge them after the fact.
- **Live updates need a machine that is listening.** An agent's comment appears
  the moment it is written when GitWarren is running on the machine that owns
  the review — locally, or on a host reached over the tailnet. A host reached
  over SSH or `wsl.exe` has no process of its own to push from, so there the
  window still finds out on its next poll (every 15s) or when it regains focus.
  The poll is the floor everywhere: a lost update costs seconds, never
  correctness.
- **A host is only greyed if something has asked it something recently.** The
  connection pool hangs up after ten idle minutes, so a machine that goes away
  having been untouched for longer is noticed the next time you look at it
  rather than the moment it goes. Keeping a socket open to every host would mean
  connecting to every machine you own, which is the thing the pool exists to
  avoid.
- **A link from an unknown machine can only be resolved for you on a tailnet.**
  When a link names a GitWarren you have not added, the screen offers to find
  and add that machine — but finding it is a tailnet probe, so a machine you
  reach over SSH or `wsl.exe` cannot be offered that way. The screen names the
  instance id and sends you to the Hosts screen, which remembers what you were
  opening and offers the way back once the machine is in the list.
- **On Linux, `tailscale serve` needs to be allowed to run.** It refuses without
  root unless `sudo tailscale set --operator=$USER` has been run once; GitWarren
  reports what Tailscale said rather than silently failing to turn the switch on.
  macOS and Windows both apply it as the ordinary user, so this is a Linux-only
  step.
  HTTPS is a tailnet-wide setting: with it off, your machines are reachable over
  plain HTTP inside the tailnet, which WireGuard is encrypting either way.
- **Repo-relative images are not rendered.** `![](docs/arch.png)` in a comment
  stays as written rather than resolving against the repository — it needs a
  second protocol host and repository context threaded into the renderer. Such a
  URL is left alone rather than copied into the attachment store, since a
  committed file is git's and reading it live is the rule everywhere else here.
- **Remote images are shown as links, never inlined**, and raw HTML in markdown
  is not rendered at all. Both are deliberate; see
  [Images in comments](#images-in-comments).
- **Fenced code in comments is not syntax highlighted**, and neither Mermaid nor
  any other diagram syntax is rendered.
- **SVG cannot be attached.** It is a script-bearing document rather than a
  raster image, so only PNG, JPEG, GIF and WebP are accepted, up to 10 MB.
- **Orphaned attachments are collected at startup, not immediately.** An image
  pasted into a composer that is then abandoned sits on disk until the next
  launch of the GUI. The sweep runs only there, never in the MCP server, which
  may be one of several concurrent processes.
- **Diffs are unified, not side-by-side**, and have no syntax highlighting or
  word-level intra-line highlighting.
- **Large diffs are clipped.** A file's patch stops rendering past 4,000 lines
  and untracked files over 512 KB are listed without content, though the
  add/delete counts stay honest. Commit lists stop at 500.
- **Uncommitted work is read from one worktree** — the one whose branch matches
  the review's head ref. If the same branch is somehow checked out in two places,
  the first one `git worktree list` reports wins.
- **Submodules are not descended into.** A dirty submodule shows as a changed
  entry, not as the changes inside it.
- **macOS auto-update requires signing** (see above). Unsigned builds install
  and run, but will not self-update.
- **The renderer bundle is ~1 MB** unminified-by-dependency-count (React, Base
  UI, zod). It loads from disk, so this costs startup milliseconds rather than
  bandwidth, and has not been optimised.
- **Editing a repository's path** is allowed and re-validated, but there is no
  detection of a repository having moved — you have to notice the *Folder
  missing* badge and repoint it yourself.

---

## Contributing

Contributions are welcome. Anything larger than a bug fix starts as a discussion
in
[Ideas](https://github.com/klarluft/gitwarren-app/discussions/categories/ideas),
so the shape can be agreed before you spend time on it; once it is settled it
becomes an issue. See [CONTRIBUTING.md](CONTRIBUTING.md) for the development
workflow, the two design constraints that changes need to respect, and what a
good pull request looks like here.

Before a first contribution can be merged you will be asked to sign the
[Contributor License Agreement](CLA.md). A bot handles it on your pull request;
it takes about ten seconds and only happens once. The CLA keeps copyright in the
codebase in one place, which is what makes it possible to offer GitWarren under
a commercial licence alongside the GPL, or to change licence later, without
having to track down every past contributor. You keep full ownership of your
work and can use it elsewhere however you like.

---

## Support and privacy

Questions, ideas and setups worth copying go to
[Discussions](https://github.com/klarluft/gitwarren-app/discussions) —
[Q&A](https://github.com/klarluft/gitwarren-app/discussions/categories/q-a) if
you are stuck on something,
[Ideas](https://github.com/klarluft/gitwarren-app/discussions/categories/ideas)
for a feature, and
[Show and tell](https://github.com/klarluft/gitwarren-app/discussions/categories/show-and-tell)
for an agent or remote-machine arrangement other people should steal. Bugs you
can describe — what GitWarren does, and when — go to
[issues](https://github.com/klarluft/gitwarren-app/issues). Anything you would
rather not post publicly goes to **contact@klarluft.com**.

GitWarren keeps everything on your machine: reviews live in one SQLite file in
your application-data directory, the diff is read from your git worktree, and
there is no account and no telemetry. The desktop app's one outbound request is
the auto-update check against this repository's GitHub Releases. The website's
[privacy policy](https://gitwarren.com/privacy/) covers gitwarren.com itself.

## License

GitWarren is free software, licensed under the **GNU General Public License,
version 3 or (at your option) any later version**. The full text is in
[LICENSE](LICENSE).

In short: you may use, study, modify and redistribute it, including
commercially. If you distribute a modified version, or a program that
incorporates this one, you must release that under the GPL as well and make the
source available. That reciprocity is the point — it keeps GitWarren and
anything built on it open.

The copyright is held by **Klarluft B.V.** (Rotterdam, The Netherlands · KVK
86875590), and every contribution is covered by the [CLA](CLA.md). Because the
copyright sits in one place rather than being spread across contributors, a
licence other than the GPL — for embedding GitWarren in a closed-source product,
for instance — can be granted on request: email **contact@klarluft.com**.

[Michal Wrzosek](https://github.com/michal-wrzosek) (<michal@wrzosek.pl>) is the
creator of GitWarren and currently its main maintainer.

```
Copyright © 2026 Klarluft B.V.

This program is free software: you can redistribute it and/or modify it under
the terms of the GNU General Public License as published by the Free Software
Foundation, either version 3 of the License, or (at your option) any later
version.

This program is distributed in the hope that it will be useful, but WITHOUT ANY
WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A
PARTICULAR PURPOSE. See the GNU General Public License for more details.

You should have received a copy of the GNU General Public License along with
this program. If not, see <https://www.gnu.org/licenses/>.
```