GitWarren
gitwarren.com — the official site, with downloads formacOS, Windows and Linux. On macOS there is also a Homebrew cask:
brew install --cask klarluft/tap/gitwarren
There is a command line too, which serves the same review UI in a browserinstead of an Electron window — for a machine that will not have the app on it,or one with no screen at all:
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. SeeThe gitwarren command line.
If you arrive from a coding agent, start there instead. The plugin brings theMCP server and a note that teaches the agent when to open a review and how toanswer your comments:
/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 pluginscreens. See 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 thatserves the same review UI into a browser tab. Same renderer either way; theshell is the only thing that differs. A machine with no screen at all — a VPS, aWSL distro, a box an agent works on — runs the headless half and is reviewedfrom somewhere else.
Reviews live on the machine the code is on, and stay there. GitWarren reachesyour other machines over SSH, over wsl.exe, or over your own tailnet, andnothing is replicated, relayed or stored anywhere but the computers you alreadyown. See Your other machines.
Built for the moment a coding agent — Claude Code, Codex, or anything else thatedits files on your disk — has just finished, and its work is sitting in yourworktree uncommitted. Read that diff here, on your own machine, before itbecomes a commit.
Tell GitWarren which local git repositories you care about, then open reviewsagainst them — a review is a comparison of two refs, presented the way a pullrequest is, with conversation, commits and files changed tabs.
The part that makes it worth having: a review can include work that has not beencommitted. If the branch you are reviewing is checked out in a worktree,GitWarren finds that worktree — wherever it is — and folds its staged, unstagedand untracked changes into the diff. You can review a change before it is acommit, which is exactly when review is most useful.
Nothing is cached: every branch name, commit and diff on screen is read from gitat the moment it is shown.
Local AI agents get the same capabilities through an MCP server over stdio, anda plugin puts it into Claude Code, Codex, Cursor, VS Code and Gemini CLI in oneline — see Agent access (MCP).
Contents
- Stack
- Architecture
- Reviews
- Navigating a large diff
- Development setup
- Project layout
- Data storage
- Database migrations
- Agent access (MCP)
- Installing it as a plugin
- The
gitwarrencommand line - Your other machines
- Linking the user back into the app
- Images in comments
- Release process
- Auto-update
- Code signing and notarization
- Social preview
- Known limitations
- Contributing
- Support and privacy
- 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 onWindows, and that is the requirementdeno desktopcould not meet. Everythingin the packaging setup below exists to serve it.
Base UI, not Radix. The components in
src/renderer/src/components/uifollow shadcn/ui conventions (CVA variants,cn()merging, the same propshapes) but are built on Base UI primitives. They were written for thisproject rather than pulled from the shadcn registry, because the registry'sdefault 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 onecontains 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'scommits and diff reads are wired to the UI only, because an agent can readthe repository with git directly. The rule is that neither surface implementslogic 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-linedelegations, and each MCP tool is a thin wrapper. The service re-parses its owninput with the zod schema from src/shared/schemas.ts rather than trusting thecaller, so a rule added there applies to the UI, the IPC layer and the agenttools simultaneously. It is not possible for an agent to write something the UIwould have rejected.
Two processes, one database. The GUI and the MCP server are separate OSprocesses sharing one SQLite file. Hence WAL journalling and a busy timeout (seesrc/core/db/client.ts). Changes made by an agent show up in the UI on the nextrefresh; the window revalidates when it regains focus.
src/core never imports electron. That is what lets the MCP process reuseit. It also means the application-data directory is computed by the sameplatform-aware function in both processes (src/core/paths.ts) rather than oneusing 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 overfetch to 127.0.0.1. A local HTTP server would add a port to allocate anddiscover, a listening socket other software on the machine could talk to, and astartup ordering problem — in exchange for nothing this app needs. SWR is usedexactly as it would be with HTTP; only the fetcher differs.
There is no authentication anywhere. This is a local, single-user app; the MCPtransport is a pipe owned by the agent the user launched, and there is nonetwork surface to authenticate.
Error handling across the boundary
Errors cannot cross ipcRenderer.invoke intact — Electron stringifies them andthe type is lost. Every handler returns an IpcResult<T> envelope instead, andthe preload script rebuilds a real AppError on the renderer side. That is whatlets a form show "This folder is not inside a git repository" underneath thepath input rather than a generic banner. The MCP tools map the same errors toCODE: message tool errors so agents can branch on the code.
Attribution
Comments carry an author; nothing else does. The rule that makes it trustworthyis that the author is an argument to the service, never a field in thepayload:
commentsService.createThread(input, actor) // actor supplied by the surface
main/ipc.ts passes HUMAN_AUTHOR and nothing else can, because typing into theapp is the only way to reach an IPC channel. mcp/server.ts passes an agentauthor built from the connection. No caller can name itself by putting an authorin the request body — there is nowhere in the input schemas to put one. SeeWho wrote what.
Reviews
A review is two refs and a title. Everything else on the screen is computed fromgit 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, andit would defeat the feature. A review is meant to follow its branch: you openone, keep working, and the review shows the work as it stands. That includes workthat 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. Thatis treated as a state to render, not an error: the review row survives, the tabsays 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 twodiverged — rather than the literal difference between the endpoints. So commitsthat landed on main after you branched do not show up as reversals in yourreview. 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 mergebase of a ref with itself is its own tip, so the diff is exactly what theworktree holds that has not been committed — the review you want when you justwrote the code and want a second pair of eyes before it becomes a commit. Such areview has no commits by definition, is titled Uncommitted work on <ref> bydefault, 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 onedirectory and the work under review is often in another. A branch checked outin a linked worktree has its uncommitted state there, not in the main checkout.
So every read starts with git worktree list --porcelain, which enumerates themain checkout and every linked worktree from any of them — it does not matterwhich one was added to GitWarren. The worktree whose branch matches the review'shead ref is the one whose git status and working-tree diff get read. If noworktree has that branch checked out, the review quietly falls back to committedwork 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 betweenthem 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 along-lived branch and want to see just that change. It is the same view a reviewof a ref against itself gives — the merge base of a ref with itself is its owntip — reached without repointing the review's endpoints and back again. It needsa worktree holding the head; without one it shows nothing rather than silentlywidening back out to the whole branch.
Untracked files are handled separately: they are listed withgit ls-files --others --exclude-standard (so .gitignore still applies) andrendered as whole-file additions. The tempting alternative — staging them into ascratch index with GIT_INDEX_FILE — would write blobs into the user's objectdatabase 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 committeddiff. It is view state, not part of the review: whether you want to read thebranch as it sits on disk or as it would arrive if pushed is a per-visitquestion.
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 andthis one does not: paths are taken from the ---/+++ and rename from/tolines rather than the ambiguous diff --git a/x b/x line, and a pure renamecarries no hunks at all yet still has to name both paths. Very large files areclipped 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 andnone of them costing anything until used:
- A file tree down the left, folded so a lone directory collapses into itsparent (
renderer/srcon one row). Clicking a file scrolls to it, and the rowfor 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 diffprints three lines of context, so most of a file is not on screen; theexpanders in the gutter reveal twenty lines at a time or the whole run, andExpand all lines in the file header opens every gap at once. Unfoldedlines are ordinary context rows — a comment can be left on one exactly as onany other line.
A @@ header announces a break in the file, so it is drawn only while there isstill a break to announce. A folded gap carries the header on its own expanderrow, the way GitHub puts the unfold controls there; unfold that gap and theheader goes with it, because the code now runs continuously into the hunk and adivider across continuous code is a false statement about the file. Expandeverything 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 beseparated 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 sameoff-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 thana diff feature, but the diff is where the scrollbar gets small enough tomatter. Two details: the whole app scrolls inside
<main>rather than thewindow, so the button acts on that element (window.scrollTowould donothing at all here); and the trip is animated only when it is short enoughto follow — smooth-scrolling the length of a large diff takes seconds andreads 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 itnever appears for keyboard users at all; a button whose whole meaning is itslabel 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 eachimmediately. title is still used for supplementary text: the full pathbehind a truncated one, the meaning of a badge.
The unfolding costs one read of the whole file, taken the first time thereviewer asks and reused for every later expansion of the same file. It isdeliberately not a line-range API: a range per click would be a git process perclick, 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 ison screen, because context taken from another version of the file would not lineup with the hunks it sits between. src/shared/diff-gaps.ts holds the arithmetic thatdecides 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 thatdoes 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 whicheverfile 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 whatis still unread as you work through it.
The mark has to stop being true when the file changes, or the list would claimsomeone had read code that did not exist when they looked at it. So what isstored 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 statusand every line of every hunk). A mark counts only while the file still hashes tothe same value; when it does not, the tick clears itself and the file islabelled Changed since reviewed — which is more useful than silentlyunticking 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. Flippinginclude uncommitted is a different diff, so a file read in one setting is notticked in the other. And reverting a change restores the mark, because the filehashes the same way it did before.
The comparison runs in the renderer, against the diff being rendered, and themain process only stores digests: the "include uncommitted" switch means onereview has two diffs at once, and a mark resolved against the one you are notlooking at would be answering a question nobody asked. The rows live inreviewed_files, keyed by review and path, and go with the review when it isdeleted. There is no MCP tool for them — an agent claiming a human has read afile 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 andthe JetBrains launcher, once per run: the application bundle in the usuallocations, and the command on PATH. Whatever is found is offered in a pickernext to the diff, and the choice is kept in localStorage — a preference of theperson, not a fact about the review, and this app has no settings screen to putit on.
Opening prefers the URL scheme the application registered for itself(vscode://file/…:12), which carries the line number and works whether or notthe user ever installed the shell command; the CLI is the fallback, and theplatform's default handler for the file is the fallback to that.
Set GITWARREN_EDITOR to override, either with an id from the list above orwith a command template:
GITWARREN_EDITOR='emacsclient +{line} {file}'
The file is resolved inside the worktree that holds the head branch, notnecessarily the directory the repository was added from — the same rule the restof the app follows. A file that exists only in a commit has nothing to open, andsays so.
Development setup
Requirements: the Node in .nvmrc (24.20.0, which ships npm11.19) and git on your PATH (GitWarren shells out to your own git ratherthan bundling one). With nvm or fnm the version is picked up automaticallyon cd; engines in package.json sets the floor at Node 24.20 / npm 11.10and .npmrc sets engine-strict, so an older toolchain fails loudly insteadof quietly writing a lockfile CI cannot install.
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 theapp at a temp data directory via GITWARREN_DATA_DIR, so they never touch yourreal 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. Therule dividing them: zod is for values that cross a trust boundary — anythinga caller supplies that the service must not believe. Git output is produced byreading the disk and flows one way out to the UI, so a runtime schema for itwould be ceremony with no payoff.
Outside src/, gitwarren-logo.png in the repository root is the 1710px masterof the logo. The two files that are actually used are cut from it and should berecut 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 GitWarreninto an agent sit beside the source rather than under it. Three plugin formatsand 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 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, 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 personnotices, 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 canpoint at package.json instead, so scripts/sync-plugin-versions.mjs copiesthe 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 has the install linesand 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 fortrying things against a scratch database.
Alongside the database, in the same directory, is attachments/ — imagescopied in from comments, named by the sha256 of their contents and sharded adirectory deep (attachments/ab/abc….png). It is the only other thing GitWarrenwrites.
Connection settings, all in src/core/db/client.ts:
journal_mode = WAL— the GUI can read while the MCP server writesbusy_timeout = 5000— wait out a brief lock instead of failingsynchronous = NORMAL— the recommended durability level under WALforeign_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 repositorycascades 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-levelthread 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 fromcomment_threads.
Authorship is denormalised onto every comment row rather than pointing at ausers table, and there will not be a users table. An author here is not anaccount but a description of where a message came from — the person at thekeyboard, or a named agent process that has since exited. Copying the label ontothe row keeps that description true forever, which a foreign key to a mutableidentity would not.
attachments — sha (PRIMARY KEY), ext, mimeType, byteSize, width,height, originalName, createdAt. See Images incomments. Note what it does not have: a foreign key tothe comment it belongs to. The body text is the only record of which images acomment uses, and unreferenced rows are collected by a sweep at startup — sodeleting an image from a comment is just deleting it from the text.
Not stored: branch, existence, resolved commits, diffs, or anything else gitowns. Those are read on demand every time they are displayed. Caching them wouldmean showing a branch name that stopped being true the moment you switchedbranches 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 andstores the repository root, then canonicalises that withfs.realpath.native — which resolves symlinks and reports true on-disk casingon macOS and Windows. So /work/app, /work/app/src/lib and /WORK/APP allcollapse to one row, backed by a UNIQUE index as the final guard.
Database migrations
Migrations are generated files, committed to the repo, and appliedautomatically the first time either process opens the database — so the MCPserver is equally safe to start first.
# 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'ssource lives inside app.asar. So drizzle/ is copied to the app's resourcesdirectory via extraResources, and src/core/db/migrations.ts resolves it inthis order:
GITWARREN_MIGRATIONS_DIRif setprocess.resourcesPath/drizzle— the packaged location- walking up from the working directory — the dev location
Each candidate is validated by checking for meta/_journal.json, so the devfallback cannot accidentally match in a packaged app. This path is verified: thepackaged MCP server runs migrations correctly when started from a directory withno source tree anywhere above it.
Agent access (MCP)
The MCP server exposes seventeen tools, all backed by the same services the UIuses:
| 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 guiUrlthat opens it in the app — seeLinking the user back into the app.
There is deliberately no get_review_diff or list_review_commits, eventhough the service layer produces both for the UI. An agent pointed at theserepositories can run git log and git diff itself, against the real workingtree, with whatever options the task needs — a tool returning a second-hand copywould be a lossier version of data the agent already has. What GitWarrenuniquely holds is the discussion around the code, which is what the commenttools 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 thekind 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 linewhere there is one. It is there so an agent can end its turn with a link insteadof "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, becausethe port it named was one the OS had handed that particular launch. But aguiUrl outlives the call that made it — pasted into a chat, left in a commitmessage, read on Thursday — so deciding at mint time that the user has nothingto open it with is a guess about a moment that has not happened yet, and it waswrong in the ordinary case: the user closes the window, the agent works fortwenty minutes, the user opens it again. A dead link costs one refusedconnection in a browser. A null cost an agent telling the user there wasnothing to click. The tool descriptions now say what a refused connection meansinstead.
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. A link naming aninstall this one is not lands on the home screen rather than opening the localreview with that number. If the install it names is a host this one knows, thelink opens that machine's review instead — the host segment travels with it,so this install's review 4 is never reachable by a link that meant anothermachine's.
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 alsocarries 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 (seeThe token, and why nothing is copied),and a link without it lands on a page saying so. A person who typed serve hasthe token on their terminal; the person an agent's plugin is serving has it ina log they will never read. So the MCP server reads it from the same 0600 filegitwarren open does and puts it in the link. The handler exchanges it for thesession cookie and takes it back out of the address bar, and the route survivesin the fragment. The app's own link page needs no token, so links minted whilethe app owns the port are unchanged. A link from before a daemon restartcarries a token that no longer exists, and the newest link is the one thatworks.
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 httpand almost none of them linkify a custom scheme, so the agent would be printingtext the user has to copy by hand.
Why a button rather than a redirect? Two reasons. Browsers refuse scriptednavigation to a custom scheme — but more importantly, the click is what makesthe window actually come forward. Windows grants foreground rights only to theprocess that is foreground or that launched the one asking, so an Electron appwoken by a background HTTP request cannot raise itself: win.focus() flashes thetaskbar and stops there (electron#2867).GNOME's Mutter demotes self-requested activation in much the same way. A protocollaunch from the browser the user just clicked in inherits the right on all threeplatforms. So the third hop is not an inefficiency to optimise away — it is theonly hop that works.
A consequence worth keeping: the loopback server never takes an action. Itanswers every request with the same static page and has no other endpoint. Thatis a property to defend rather than an accident of it being small — anything onloopback is reachable by every process on the machine and by whatever web pagethe user has open next, so an endpoint here that mutated state, read arepository or drove IPC would be a capability handed out to the whole world. Itvalidates the Host header, and the route it is linking to never even reachesit: 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 bewhatever the OS handed out (listen(0)), written to a runtime file for the MCPserver to read — which meant a link could only be minted while the app wasrunning, and only for this machine. Neither survives contact with a secondmachine: a link written on one is read on another, and a link left in a commenton Tuesday is clicked on Thursday. So the port is a constant every installagrees on (src/shared/link-port.ts, chosen in spike S6 for being outside everydefault ephemeral range, absent from /etc/services, and not on Chromium'srestricted-port list), and guiUrl no longer depends on anything being up.
If something else holds 41427, the app starts anyway and says which port andwhy; links are still handed out, because they name the same port on everymachine and must not depend on this one's luck. The Agent access panel showsthe 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 andnever as a fact: a crash leaves it behind, so the pid is checked withprocess.kill(pid, 0) before it is believed, and it is re-read on every callrather than cached.
The incoming URL is parsed to a Route before anything acts on it, neverforwarded as a string, using the same grammar the hash router uses(shared/routes.ts). Comment bodies are agent-writable, so this parser willone day receive gitwarren://review/../../../../etc/passwd; anything it does notrecognise degrades to the home screen. It is the same whitelist-not-filterreasoning as main/attachment-protocol.ts. Note that gitwarren: is registeredtwice over, for two unrelated mechanisms — an OS protocol handler and Chromium'sprotocol.handle for attachment images. They coexist because they answer todifferent hosts: review and attachment, each ignoring the other's.
Who wrote what
Comments from the UI are Human. Comments over MCP are <tool> (AI). Thequestion that shapes the design is where <tool> comes from — and the answer isnot "the agent tells us".
Asking an agent to name itself does not survive contact with reality: the sameClaude Code install would introduce itself as Claude, claude-code, ClaudeCode and Claude Opus across four sessions, and a thread with four names forone participant is worse than a thread with none.
So the name is taken from the MCP handshake instead. Every client sendsclientInfo: { name, version } in initialize, before any tool runs, and theSDK keeps it (Server.getClientVersion()). That value is chosen by the toolrather 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 asis (some-new-agent → Some New Agent), which still identifies that toolconsistently across all of its own sessions. A client that sends no clientInfoat all becomes plain AI, so the one guarantee the UI makes — a machine-writtencomment is always marked as one — holds even there.
Telling two sessions of the same tool apart. stdio gives one server processper client session, so the process is the session: an 8-character id is minted atstartup and stamped on everything that session writes. That keeps two concurrentClaude 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 foritself — auth-refactor, perf-pass — which is remembered for the rest of thesession and renders as Claude Code · auth-refactor (AI). This is the oneself-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 thehandshake's. It can also be pinned per-project in the server config withGITWARREN_AGENT_LABEL.
Editing. The person at the keyboard may edit or delete anything — it is theirapp. 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 agentfixing 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 waswritten against is not the diff the next visitor sees. GitHub avoids this bypinning each comment to a commit; GitWarren cannot, because following the branchis 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). Therule is to trust the text over the number — a line number is a position in adocument 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 wasleft on a line the diff never showed" — an agent commenting on an unchanged partof a file, say. Both mean the same thing to a reader, so both are kept and shownout 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 filebeats losing the comment.
The same function runs in both surfaces. The renderer anchors against the diffalready on screen — which matters, because each view of the changes is agenuinely different diff with different line numbers — andlist_review_comments anchors against a diff it reads itself, so an agent andthe 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, tocomment on several lines at once. Agents get the same thing by passingstartLine 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 therest of the range follows it by keeping the span the same length. Re-findingboth ends independently would let a range quietly grow, shrink or invert whenone of them matched somewhere unhelpful, and a comment that claims to cover codeit was never about is worse than one sitting a line off. A range of one line isnormalised to no range at all, so nothing downstream has to compare the twonumbers 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 thethread itself renders under the last line — where the eye already is afterdragging down to it.
Getting from the conversation back to the code
Clicking a thread's file header in Conversation opens Files changedscrolled to that line, with the line marked for a couple of seconds. The targetgoes in the hash (#/reviews/3/files/src%2Fapp.ts/head/42), so it is a locationlike any other: it survives a reload and the back button works.
The line in the URL is the resolved one, not the stored one — theconversation tab has already anchored the thread against the diff it isdisplaying, so a comment that has moved still lands on the code it is about. Athread whose line is gone from the diff falls back to scrolling to the file'scard, which is where such a thread is listed.
Images in comments
Comment bodies and review descriptions are markdown — GitHub-flavoured, sotables, task lists, strikethrough and autolinks all work. The composer has theusual Write/Preview tabs and a formatting toolbar, and the preview rendersthrough the same component the posted comment does, so it cannot drift.
Two things are deliberately not rendered. Raw HTML is not, which is whythere is no sanitiser anywhere in this app — react-markdown does not renderembedded HTML unless asked, so there is nothing to misconfigure. And remoteimages are not: an https:// image renders as a link, and the renderer's CSPhas no remote img-src. Both exist because a comment here may have been writtenby 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 
└──────────────┬──────────────┘
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 isinserted at the cursor. Agents write a file to disk and reference it as anordinary markdown image — the path is rewritten to a token when the comment issaved. They cannot upload: base64 in a tool call means emitting over half amillion characters for a 400KB screenshot, so a path is the only workablecurrency. In the other direction, every comment carries a resolved attachmentsarray whose path is a real file, which an agent reads with the tools italready has. That is why there is no get_attachment tool — a path is strictlymore reliable than an MCP ImageContent block, whose delivery varies by client.
The bytes are copied rather than referenced because a discussion has tooutlive the file it is about: /tmp gets purged, test-results/ is wiped atthe 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 bysha256, which makes ingest idempotent — necessary, since the GUI and the MCPserver are separate processes that can ingest the same image at once.
Two details are load-bearing and easy to get wrong. The rewrite parses themarkdown rather than pattern-matching it, so an agent's example image inside afenced code block is not silently ingested. And it splices the originalstring by node offset rather than re-serialising the parsed tree, so a bodycomes back byte-identical apart from its URLs — a round trip through mdast wouldquietly renormalise an author's bullet markers and fenced code.
A path that does not resolve is left in the text as written and the commentsaves anyway, on the same principle the composer already applies to humans: thecomment 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 addressinstalls 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 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, thenote that teaches the agent one habit — open a review when a task that changedcode is done and hand over the link, read the review's comments before the nexttask, 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 ondemand;and agents/gitwarren-reviewer.md, a reviewer that reads a change with git andleaves its findings as line comments in the review, attributed asmachine-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 aGitWarren is listening on the machine. If one is, it runs the launcher thatGitWarren wrote, so links open there. If not, it runs npx gitwarren mcp --serve: the published package, with the review page switched on, so a linkthe agent hands out opens even on a machine with nothing else installed. Theother formats name that command directly. "Listening" rather than "installed",because an installed-but-closed app would leave the agent handing out deadlinks; the starter's header has the reasoning.
The Node on the PATH has to be 22.14 or newer. better-sqlite3 ships aprebuilt binary built against Node-API 10, and under an older Node - any 22.xbefore 22.14, which has Node-API 9 - it loads and then segfaults on the firstdatabase read, which an agent reports as "server failed to connect" andnothing 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 anew one. The starter checks the Node-API version before spawning anything andsays which Node it found and which it needs; gitwarren mcp checks the sameand then opens the addon once in a child process before loading the server, soany other crash is a sentence rather than a silence. The npm package'sengines says the same minimum.
gitwarren mcp is the server by name, and --serve is the page beside it: thesame --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 arunning app or serve the way serve does, and it exits when the agent's pipecloses, so no page outlives the session that started it.
The server is also listed in the MCP registryas io.github.klarluft/gitwarren, from server.json at the repository root,published by the release workflow after the npm package. The directories thatcopy 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 theAgent access page (the card on the home screen, or g a) and copy the promptat the top of it. The browser shell has the same page, and on a machine with noscreen gitwarren agent-setup prints the same words. The paths depend on whereGitWarren 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 itwhenever the install moves — after an update, after dragging the app to adifferent folder, after switching between a packaged build and a sourcecheckout. So an agent config that names it keeps working, and the Agentaccess page leads with a sentence you paste into whatever agent you userather than with JSON you paste into a file:
Set up the GitWarren MCP server for yourself. It speaks MCP over stdio and isstarted with the command
~/.gitwarren/bin/gitwarren-mcp(no arguments, noenvironment). Register it under the name "gitwarren" in your own MCPconfiguration, then call itsagent_identitytool to confirm it works.
Agents know their own configuration format better than a page can. What theyneed from us is a stable command.
To configure it by hand instead, that command is all an entry needs. Threeformats cover every harness we know of, and the page generates all three fromthe launcher path (gitwarren agent-setup --manual prints them too):
{
"mcpServers": {
"gitwarren": { "command": "/Users/you/.gitwarren/bin/gitwarren-mcp" }
}
}
for Claude Code, Cursor, Windsurf and Gemini CLI; the same object calledservers for VS Code; and TOML for Codex:
[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 isdeliberate: better-sqlite3 is a native addon that must be loaded by a runtimewhose ABI it matches, and it has to resolve out of the app's unpackednode_modules. Using the bundled binary satisfies both, and means no Nodeinstallation is required.
An AppImage is the interesting case, and the reason this path exists at all: itre-mounts itself at a new /tmp/.mount_* directory on every launch, so nothinginside it is worth writing down. Its one stable path is the .AppImage file,which AppRun exports as APPIMAGE and whose mount point it exports asAPPDIR, so the launcher names the former and finds the server through thelatter at run time. Nothing needs extracting.
From a source checkout, npm run mcp:dev runs the same server against your devdatabase.
The app does not need to be running for the MCP server to work — both open thesame 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 ofsubcommands, and no Electron anywhere in it.
# 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
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 notinstall an Electron app gets the identical renderer in a tab — every line isshared, the shell is not. And a machine with no screen at all — a VPS, a WSLdistro, a box an agent works on — gets the daemon and the MCP server, which iswhat Your other machines is built on.
Which command you want
Three things a person wants from it, and one command for each. They areindependent: none of them requires another to have been run first.
- Use it now.
gitwarren serveruns GitWarren in the terminal until Ctrl-C,and prints the URL.--openopens it as well;gitwarren openin anotherterminal does the same later. - Have it always there.
gitwarren service installregisters a login item— a LaunchAgent on macOS, asystemd --userunit on Linux, an at-logon taskon Windows — and starts it now, sogitwarren openand the links an agenthands you always have something to open.gitwarren service uninstallundoesit. - Let an agent in.
gitwarren agent-setupprints the sentence to paste intoClaude Code, Codex or any other MCP client. The MCP server is part of everyinstall and reads the same SQLite file the browser view does, so an agent canopen and comment on reviews whether or not GitWarren is being served — whatserving adds is that theguiUrlan 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; seeservice 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 differso brew install klarluft/tap/gitwarren keeps meaning the app; the binary iscalled gitwarren in all four.
To remove a Homebrew install, brew uninstall gitwarren-cli; a script install,rm -rf ~/.gitwarren. Run gitwarren service uninstall first if a login itemwas registered. Neither touches the reviews, which live in the data directorygitwarren service status prints.
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 inthe URL. gitwarren open reads that file and hands the whole URL to thebrowser, which swaps it for a SameSite=Strict cookie on the first request. Atoken is never copied by a person, never persisted across a launch, andrevoking it is quitting the process. See src/core/web/token.ts.
gitwarren open also takes a link — either a gitwarren:// deep link or thehttp://127.0.0.1:41427/#h=… URL an agent hands out — and lands on that reviewrather than the home screen. The argument is parsed to a route and written backout from that, so nothing typed on a command line is pasted into a URL that isthen handed to the operating system.
service install
Two things, and only the second is about logging in:
- The launchers.
~/.gitwarren/bin/gitwarrenand~/.gitwarren/bin/gitwarren-mcp,at the paths the rest of GitWarren already names — the Agent Access pageprints 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 pointsthem at the install that ran last.gitwarren serveandgitwarren agent-setupwrite the same two files when they are missing, the way the appwrites the MCP one on every launch, so nobody has to ask for a login item toget an agent working. - The login item. A LaunchAgent on macOS, a
systemd --userunit on Linux,an at-logon Scheduled Task on Windows — each runninggitwarren serve --listen, and each started right away as well as at the next login.--no-login-itemwrites the launchers and stops, which is what a headlesshost wants and what the SSH installer andinstall.shask for.
Nothing restarts a dead daemon, deliberately. serve --listen has a refusal itis meant to exit on — a data directory has one owner, so it stands aside whenthe app is running — and under launchd's KeepAlive or systemd's Restart=that refusal becomes a process respawning every ten seconds for as long asGitWarren is open. See src/cli/units.ts.
The launcher scripts name absolute paths for the migrations folder and the webbuild rather than inheriting them. Both have a fallback relative to the workingdirectory, 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; seesrc/cli/install.ts.
Your other machines
A repository lives on one machine, and so does its review. GitWarren does notcopy 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:
- A host owns its repositories. SQLite, git and the MCP server for a repolive on the machine that repo is on. Reviews never move.
- Nothing syncs. The window is a view onto hosts. It caches nothing acrossa disconnect, and a machine that is offline is shown as offline rather thanas its last known state.
- One protocol, several carriers. The same requests, responses and eventsrun unchanged over a child-process pipe,
wsl.exe,ssh, or a WebSocket. - Links resolve where they are clicked. A loopback link names the host inits fragment and opens on whichever GitWarren you clicked from. A tailnet URLis offered in addition, but only while that host is actually listening.
- 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 theagent next to the code.
Other machines is where all of it is driven, in both directions: themachines 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 WSLdistro — and GitWarren installs itself there over the same connection. The hostneeds nothing but git: the daemon tarball ships a Node binary of its own, sothere is no runtime to install and nothing to keep up to date by hand. It isfetched from the GitHub release by the machine you are sitting at and streameddown the pipe.
Nothing is left running. ssh host gitwarren serve --stdio is spawned ondemand, and a connection pool hangs up after ten idle minutes rather thanholding 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 oneyou pick, running as that distribution's own default user.
Windows-native repositories stay first class — most Windows developers do notrun WSL, and agents have run natively there since late 2025. A Windows path anda WSL path are different machines, so a WSL path offered as a local repositoryis refused rather than read through \\wsl$, which is the wrong architectureeven on the days it works.
On your tailnet
Turn on Reachable on your tailnet and GitWarren runs tailscale serve infront of the loopback port. Every request then has to carry a Tailscale loginequal to this machine's owner; anything else is refused before it reaches thedispatcher. 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 fromtailscale status --json are probed, and the ones that answer are proposed ashosts with the instance id they reported. Manual entry stays for everythingelse. 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 addressyou typed.
A listening host is also the only kind that can push. Comments and reviewsarrive 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, sothere the 15-second poll is still the floor. It is the floor everywhere: a lostevent costs seconds, never correctness.
The phone
Nothing was built for it. The web view is reachable at the host's webUrl fromany device on the tailnet, and tailscale serve supplies the identity, so thereis no token to get onto a phone. Below lg the files list and the diff becomeseparate screens and the composer sits above the keyboard.
MCP results carry that webUrl alongside the always-present loopback guiUrlwhenever the host is listening — so a link an agent prints can be opened on themachine you are holding, not only the one it ran on.
The full design, its spikes and the outcome of every milestone are indocs/across-hosts.md.
Release process
Artifacts and the update manifest are published to GitHub Releases(klarluft/gitwarren-app, configured in electron-builder.yml).
# 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 theinstallers, and uploads them plus the manifests to a GitHub release for thecurrent tag. The release is created as a draft — publish it in the GitHub UIwhen 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 inthe MCP registry from server.jsonat the repository root, for stable tags only. Both use the job's OIDC token; nosecret is involved.
Publishing a stable release also fans out to two other places, both on therelease: published event: deploy-site.yml rebuilds gitwarren.com so itsdownload buttons point at the new assets, and homebrew-tap.yml asksklarluft/homebrew-tap to move itscask to the new version and checksums. The tap needs a HOMEBREW_TAP_TOKENsecret for that nudge to be immediate; without one it still catches therelease on its own schedule within a few hours.
Prereleases
A tag carrying a prerelease component — v0.1.7-beta.3 — is a build fortesters, and the pipeline keeps it away from everyone else. The draft iscreated --prerelease, and both fan-outs above decline to run for one: thewebsite goes on advertising the newest stable release, and the Homebrew caskstays where it is.
That flag is load-bearing. GitHub's /releases/latest skips a prerelease, andthat endpoint is what electron-updater asks on behalf of every install runninga 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, becauseelectron-builder derives no channel for the GitHub provider. Clear the flag,or tick Set as the latest release while publishing, and every stable installtakes the beta on its next six-hourly check.
So publish one explicitly rather than through the UI's defaults:
gh release edit v0.1.7-beta.3 --draft=false --prerelease --latest=false
Testers keep updating among themselves from there — a beta install looks forbeta-mac.yml, gets a 404, and falls back to the latest.yml in the samerelease — and each one moves to the next stable release on its own, with noreinstall, as long as that version is higher than the beta they are on.
To build without publishing (for local testing):
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-updatercompares block hashes with the installed version and downloads only the changedranges.
The macOS zip is required — electron-updater reads the zip, not the dmg.Dropping that target still produces a working installer but silently breaksauto-update.
The daemon tarballs are not installers and electron-updater ignores them.Each carries a Node binary, the CLI and MCP bundles, the one matchingbetter_sqlite3.node, the migrations and the web build — about 40 MB, andenough to run GitWarren on a box with nothing installed on it. They are built bythe daemon job in release.yml from scripts/build-daemon-tarball.mjs, onone runner for all four targets, and nothing in them is compiled: better-sqlite3ships a prebuild for each, and the Node binaries are downloaded.
There is no Windows tarball, on purpose. A .tar.gz is not how anything isinstalled there, and both audiences are already served — a desktop user installsthe app, and someone who wants the command line has npx gitwarren.
Their file names are a contract. A GitWarren installing a daemon on aremote 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 sameURLs. Renaming them breaks both.
The npm package carries no credential to publish it. The daemon job asksGitHub for an OIDC token, npm trades that for a credential good for minutes, andthe exchange also produces a provenance attestation — so there is no NPM_TOKENin this repository's secrets and there is not meant to be one. The trust isconfigured on the package at npmjs.com against this repository and thefilename release.yml, which is the one thing to remember: renaming thatworkflow breaks publishing, and it fails as an authentication error rather thanas a name mismatch.
[email protected] was published by hand, because a trusted publisher can only beconfigured on a package that already exists and npm has no pre-registration forone that does not. Nothing else will be.
The Homebrew formula is rendered by scripts/build-homebrew-formula.mjsfrom packaging/homebrew/gitwarren-cli.rb in the same job that builds thetarballs, hashing the exact files it is about to upload, and attached to therelease as gitwarren-cli.rb. The tap copies that file rather than computinganything of its own — a tap that hashed the release separately could hash itbefore an asset was re-uploaded, and the result is SHA256 mismatch on a user'smachine with nothing on either end to say why.
Cross-building for every platform from one machine is not reliable (Windowscode signing and macOS notarization both need their own host). Run the releaseon 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 withoutasking, apply on the next restart. The only UI is a quiet banner once a versionis staged, offering an immediate restart. Doing nothing is also fine — itapplies on the next quit either way. A failed check never interrupts thesession; 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 besilently inherited.
Auto-update is inert when app.isPackaged is false, so development builds don'ttry 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 UACprompts. A per-machine install writes to Program Files and every update wouldraise an elevation dialog — which would defeat "silent" entirely.
deleteAppDataOnUninstall is off, so uninstalling does not throw away theuser's repository list.
Code signing and notarization
Not required for local development builds. Unsigned builds run fine on yourown machine; electron-builder logs skipped macOS application code signing andcarries on.
They are required before distributing to anyone else — and specifically,auto-update on macOS will not work unsigned, because Squirrel.Mac validatesthe code signature of the downloaded build before swapping it in.
The hardened runtime is already enabled, with entitlements inbuild/entitlements.mac.plist covering what this app actually needs: JIT forV8, library validation disabled (the app spawns git, and agents spawn thebundled MCP server), and user-selected file access for repositories on anyvolume. notarize: true is set in electron-builder.yml, which stays inertuntil both a signature and Apple credentials exist — see How the switchesinteract below.
macOS: one-time setup
Everything here happens once per developer account, not once per release. Itneeds 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. Notethat only the Account Holder can create one under an organizationmembership — a plain Admin cannot, and the certificate type simply will notappear in the list for them.
Do this through the developer portal rather than through Xcode. Xcode'sSettings → Accounts → Manage Certificates is fewer clicks, but it never askswhich sub-CA to issue under and has been observed picking the legacy one — seeCheck which sub-CA issued it below, which is worth reading before you startrather than after.
- Open Keychain Access → Certificate Assistant → Request a Certificate Froma Certificate Authority. (This works with only the Command Line Toolsinstalled; Xcode is not needed for any of it.)
- 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.
- Key size 2048 bits, algorithm RSA. Save the
.certSigningRequest. - Go to developer.apple.com/account/resources/certificates,press +, choose Developer ID Application, and upload the request.Pick the G2 Sub-CA profile type when asked.
- Download the resulting
.cerand double-click it to install into the loginkeychain.
The private key never leaves your Mac — Apple only ever sees the request. Thatalso 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. Anaccount is limited to five Developer ID Application certificates, and each isvalid for five years when issued under the current sub-CA.
Confirm the result:
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 ondeveloper.apple.com/account underMembership details.
Check which sub-CA issued it. Apple's original Developer ID CertificationAuthority intermediate expires on 1 February 2027, and a leaf certificatecannot outlive its issuer — so a certificate issued under it is silentlytruncated to whatever remains of that date instead of running the full fiveyears. The G2 Sub-CA exists to replace it:
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 issuedit, whatever the portal appeared to offer. Create a fresh one under G2Sub-CA and retire the short one as described below. Note that an account islimited to five Developer ID Application certificates and a retired one stilloccupies a slot until it expires, so it is worth getting this right rather thaniterating.
If the new certificate shows up as invalid, the intermediate is missing.macOS ships the original Developer ID intermediate but not necessarily the G2one, and a certificate whose chain cannot be completed is not counted as avalid identity — so security find-identity -v stays silent about it whilesecurity find-identity (no -v) lists it happily. That difference is thediagnosis:
security find-identity -p codesigning # lists it
security find-identity -v -p codesigning # does not
Install the missing link from Apple's certificate authoritypage:
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 areidentical, 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 pinningmac.identity to a SHA-1 hash does not avoid it, because electron-builderresolves the hash and then passes codesign the name. Once the replacementis confirmed working, delete the old certificate and its private key:
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 revokedfrom the portal. App Store certificates have a Revoke button; Developer IDcertificates deliberately do not, because revocation invalidates every app eversigned with that certificate, timestamps included. It is reserved for acompromised private key and has to be arranged with Apple Product Security byemail. Deleting the key you no longer want is not that situation: with the keygone 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 passwordunder two-factor auth. At appleid.apple.com →Sign-In and Security → App-Specific Passwords, generate one and keep thexxxx-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 sharedCI account, because it is scoped and revocable without touching a person'sApple 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:
export APPLE_ID="[email protected]"
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 historyand out of a dotfile:
xcrun notarytool store-credentials gitwarren \
--apple-id "[email protected]" \
--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'snotarization service usually answers within a few minutes, but it queues, andeach architecture is submitted separately. The log lines to look for aresigning file=release/.../GitWarren.app identityName=Developer ID Application: ..., then notarization successful. Stapling happensautomatically after that, so the finished app validates on the user's machinewithout a network round-trip.
Verifying a signed build
Worth doing once, on the first signed release, rather than discovering aproblem from a user:
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 — mostoften source=Unnotarized Developer ID — means the signature landed but thenotarization did not, and the download will still be refused.
CI secrets
The release workflow reads five optional secrets. Setting them switches theGitHub Actions build from ad-hoc to properly signed and notarized; leaving themunset keeps the existing unsigned behaviour.
Export the certificate with its private key from Keychain Access — select theDeveloper 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 severalthousand characters, and many terminals silently truncate a paste of that sizeinto an interactive prompt. The result is a secret that looks set and failsmuch 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, soscripts/set-signing-secrets.sh does it:
./scripts/set-signing-secrets.sh ~/Documents/certificate.p12
It prompts for the password without echoing it, refuses to store anythingunless that password actually opens the .p12 and a private key is inside,and then sets both secrets from exactly those bytes. The remaining three areshort enough to paste at a prompt, which also keeps them out of shell history:
gh secret set APPLE_ID # [email protected]
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 anidentical error. A base64 .p12 runs to several thousand characters and manyterminals silently truncate a paste that long, and echo "$pw" | gh secret setstores the trailing newline as part of the password. Both surface much later asMAC verification failed during PKCS12 import (wrong password?), which readsas a bad certificate rather than a badly stored one. If you do set them byhand, 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 beforeit builds anything, so a mistake here surfaces in seconds with a message namingthe cause rather than several minutes in. The secret names are unchanged, andscripts/set-signing-secrets.sh still sets them.
The Apple certificate never reaches a Windows runner. electron-builder'sWindows packager reads CSC_LINK and CSC_KEY_PASSWORD too, and handed theApple pair it tries to Authenticode-sign an .exe with a Developer IDcertificate, 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 theonly Windows secrets are the three AZURE_* credentials. See Windows below.
On macOS the workflow builds the keychain itself and never exportsCSC_LINK. Setting it would send electron-builder down its owncreateKeychain 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 keychainpassword — the keychain's own password is a random value generated a few linesearlier in app-builder-lib/out/codeSign/macCodeSign.js and never reusedthere. 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 keychainpassword where it belongs — verifies a Developer ID Application identityactually landed in it, and hands electron-builder CSC_KEYCHAIN, whichmacPackager consults only when CSC_LINK is absent. That step also does theCSC_LINK/CSC_KEY_PASSWORD agreement check, so a badly stored secret stillsurfaces 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 anempty string, and a step-level env: would override what the export stepwrites. The loop in Export the signing secrets that exist skips empty valuesso the unset case stays genuinely unset, which is what lets every credentialhere be optional.
How the switches interact
Three independent things decide what a macOS build comes out as, which is whynone 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 neverreached. scripts/adhoc-sign.mjs stands down as soon as CSC_KEYCHAIN orCSC_LINK is set, or a Developer ID identity is in the keychain, so it neverfights with the real signature.
Windows has one switch rather than three, and it is the presence ofAZURE_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 ArtifactSigning — theservice Microsoft renamed from Trusted Signing in 2026 — at $9.99/month for upto 5,000 signatures.
The alternative was an EV certificate. Since June 2023 an OV code-signing keymust live on a hardware token or an HSM, which means a courier, a physicaldevice, and no clean way to sign from a CI runner. A managed service keeps thekey on Microsoft's side and authenticates with an ordinary client secret, so aGitHub Actions runner can sign without anything being mailed anywhere.
Eligibility used to be the obstacle: the service was limited to US and Canadianorganizations with three or more years of trading history. At GA in 2026 thatopened to EU, UK and several other organizations and the history requirement wasdropped, which is what made this route possible for a Dutch B.V. Individualdevelopers are still US/Canada only, so this runs through Klarluft B.V. as anorganization.
The resources, all under [email protected]:
| 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 andAZURE_CLIENT_SECRET, belonging to the gitwarren-release-signing appregistration. It holds the Artifact Signing Certificate Profile Signer rolescoped to the certificate profile rather than to the whole account, so adding asecond 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 atwo-year life, so it lapses around September 2028. The failure mode is arelease build dying at the signing step with an authentication error andnothing in the repository explaining why, so it is worth a calendar entry.Rotate it with
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 todisk.
Certificates last three days. This is not a misconfiguration — ArtifactSigning issues short-lived certificates and rotates them continuously. It isalso why the RFC3161 timestamp is load-bearing rather than optional: thetimestamp proves the binary was signed while its certificate was valid, so thesignature stays good long after that certificate expires. Without one everybuild would stop verifying within 72 hours. electron-builder defaults toMicrosoft'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 everydownloaded update against that string. A mismatch produces an app that installsperfectly and then silently refuses every auto-update — worse than shippingunsigned, and invisible until users stop receiving releases. Read it back fromAzure rather than retyping it:
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 bythe Build and publish step of release.yml instead. winPackager switches tothe Azure signing manager the moment win.azureSignOptions exists and neverchecks whether credentials are present, so putting it in the config file wouldmake every unsigned local Windows build fail at the signing step. Passing itfrom the workflow keeps npm run package working on a developer's machine withno Azure access at all.
Signing also only runs on a Windows runner: electron-builder drives it throughthe TrustedSigning PowerShell module, which it installs into the runner'sCurrentUser scope on first use. The release matrix already builds Windows onwindows-latest, so this costs nothing.
SmartScreen reputation still has to accrue. These are OV-classcertificates, so the "Windows protected your PC" warning fades as downloadsaccumulate against the publisher rather than disappearing with the first signedrelease. Only an EV certificate buys immediate clearance. Updates were neveraffected 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 secretis optional, so a tag pushed with none of them set still produces installers forall three platforms — each platform simply comes out unsigned. That property isworth preserving deliberately rather than by accident: it is why the Windowssigning configuration is passed from the workflow instead of living inelectron-builder.yml, where its mere presence would make an uncredentialledbuild 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 "Windowsprotected your PC" until SmartScreen has built reputation against thepublisher, but installs and updates work throughout. Note that signing alonedoes not clear that warning immediately: with an OV-class certificate, which iswhat Artifact Signing issues, reputation accrues over downloads.
macOS is the one that is genuinely degraded, in two ways. Gatekeeper refuses adownloaded build that is not notarized, and the user has to allow it explicitlyin System Settings → Privacy & Security, where a GitWarren was blockedrow appears after the first launch attempt. Right-click → Open no longer worksas a bypass; Apple removed that in macOS Sequoia. Stripping the quarantineattribute by hand does the same thing:
xattr -d com.apple.quarantine /Applications/GitWarren.app
Both are fine for a developer trying the app deliberately, and both are far toomuch to ask of anyone else.
The second cost is the one to plan around: auto-update does not work at all onan 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 thefirst signed release by hand. Publishing unsigned macOS artifacts as apre-release, rather than as a headline version, keeps that population small.
A local build runs with none of this friction, because a bundle you producedyourself carries no com.apple.quarantine attribute and Gatekeeper is neverconsulted. That is why npm run package output opens by double-clicking whilethe same file downloaded from a release does not.
afterPack runs scripts/adhoc-sign.mjs, whichad-hoc signs macOS builds whenever no Developer ID is present. This is not asubstitute for signing — Gatekeeper still refuses the download — but it changeshow it refuses. Packaging invalidates the seal on the linker signatureElectron ships with, and macOS reports a bundle whose seal does not match asdamaged, which reads as malware rather than as the ordinary unidentifieddeveloper users know how to allow. Re-signing ad-hoc makes the signatureself-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, onLinkedIn, in iMessage — is docs/social-preview.png.
It is not picked up from the repository automatically. GitHub has no APIfor 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 andre-render:
node scripts/build-social-preview.mjs
That writes every variant to screenshots-out/ (gitignored) and copies the onenamed 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 typeis supersampled rather than aliased. The palette and the five vendored fonts inscripts/social-preview/fonts/ are the site's, so the card andgitwarren.com stay the same brand. Note that the sitebuilds 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 ratherthan 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
gitsubprocess calls. They run in parallel across repositories, but a list ofmany hundreds on a slow or networked filesystem will feel it. - No file watching. Git state refreshes when the window regains focus or youpress refresh, not the instant you switch branches elsewhere. The commit anddiff reads go further and do not refresh on focus — re-running a diff everytime you alt-tab would spawn git processes behind your back — so those tabshave an explicit refresh button.
- Comment threads have no unread state. The tab shows how many areunresolved, not how many are new since you last looked, so a reply an agentleft overnight is not distinguishable from one you have already read.
- Comment anchors are matched on exact line text. Reindenting a line orchanging its whitespace moves it out of
anchoredeven though the code isunchanged. A trimmed comparison would handle that, at the cost of matchinglines 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 clientthat changes the name it sends between versions will appear as twoparticipants, and there is no way to merge them after the fact. - Live updates need a machine that is listening. An agent's comment appearsthe moment it is written when GitWarren is running on the machine that ownsthe review — locally, or on a host reached over the tailnet. A host reachedover SSH or
wsl.exehas no process of its own to push from, so there thewindow 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, nevercorrectness. - A host is only greyed if something has asked it something recently. Theconnection pool hangs up after ten idle minutes, so a machine that goes awayhaving been untouched for longer is noticed the next time you look at itrather than the moment it goes. Keeping a socket open to every host would meanconnecting to every machine you own, which is the thing the pool exists toavoid.
- On Linux,
tailscale serveneeds to be allowed to run. It refuses withoutroot unlesssudo tailscale set --operator=$USERhas been run once; GitWarrenreports 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-onlystep.HTTPS is a tailnet-wide setting: with it off, your machines are reachable overplain HTTP inside the tailnet, which WireGuard is encrypting either way. - Repo-relative images are not rendered.
in a commentstays as written rather than resolving against the repository — it needs asecond protocol host and repository context threaded into the renderer. Such aURL is left alone rather than copied into the attachment store, since acommitted 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 markdownis not rendered at all. Both are deliberate; seeImages in comments.
- Fenced code in comments is not syntax highlighted, and neither Mermaid norany other diagram syntax is rendered.
- SVG cannot be attached. It is a script-bearing document rather than araster image, so only PNG, JPEG, GIF and WebP are accepted, up to 10 MB.
- Orphaned attachments are collected at startup, not immediately. An imagepasted into a composer that is then abandoned sits on disk until the nextlaunch of the GUI. The sweep runs only there, never in the MCP server, whichmay be one of several concurrent processes.
- Diffs are unified, not side-by-side, and have no syntax highlighting orword-level intra-line highlighting.
- Large diffs are clipped. A file's patch stops rendering past 4,000 linesand untracked files over 512 KB are listed without content, though theadd/delete counts stay honest. Commit lists stop at 500.
- Uncommitted work is read from one worktree — the one whose branch matchesthe review's head ref. If the same branch is somehow checked out in two places,the first one
git worktree listreports wins. - Submodules are not descended into. A dirty submodule shows as a changedentry, not as the changes inside it.
- macOS auto-update requires signing (see above). Unsigned builds installand run, but will not self-update.
- The renderer bundle is ~1 MB unminified-by-dependency-count (React, BaseUI, zod). It loads from disk, so this costs startup milliseconds rather thanbandwidth, and has not been optimised.
- Editing a repository's path is allowed and re-validated, but there is nodetection of a repository having moved — you have to notice the Foldermissing badge and repoint it yourself.
Contributing
Contributions are welcome. Anything larger than a bug fix starts as a discussioninIdeas,so the shape can be agreed before you spend time on it; once it is settled itbecomes an issue. See CONTRIBUTING.md for the developmentworkflow, the two design constraints that changes need to respect, and what agood pull request looks like here.
Before a first contribution can be merged you will be asked to sign theContributor License Agreement. A bot handles it on your pull request;it takes about ten seconds and only happens once. The CLA keeps copyright in thecodebase in one place, which is what makes it possible to offer GitWarren undera commercial licence alongside the GPL, or to change licence later, withouthaving to track down every past contributor. You keep full ownership of yourwork and can use it elsewhere however you like.
Support and privacy
Questions, ideas and setups worth copying go toDiscussions —Q&A ifyou are stuck on something,Ideasfor a feature, andShow and tellfor an agent or remote-machine arrangement other people should steal. Bugs youcan describe — what GitWarren does, and when — go toissues. Anything you wouldrather not post publicly goes to [email protected].
GitWarren keeps everything on your machine: reviews live in one SQLite file inyour application-data directory, the diff is read from your git worktree, andthere is no account and no telemetry. The desktop app's one outbound request isthe auto-update check against this repository's GitHub Releases. The website'sprivacy policy 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 inLICENSE.
In short: you may use, study, modify and redistribute it, includingcommercially. If you distribute a modified version, or a program thatincorporates this one, you must release that under the GPL as well and make thesource available. That reciprocity is the point — it keeps GitWarren andanything built on it open.
The copyright is held by Klarluft B.V. (Rotterdam, The Netherlands · KVK86875590), and every contribution is covered by the CLA. Because thecopyright sits in one place rather than being spread across contributors, alicence other than the GPL — for embedding GitWarren in a closed-source product,for instance — can be granted on request: email [email protected].
Michal Wrzosek ([email protected]) is thecreator 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/>.