Concepts & first principles
The whole tool is four ideas: frame, stack, trunk, and one commit per frame. Everything else falls out of those.
Frame
A frame is a single layer of work. It maps 1:1 to a real git branch. There's no
virtual-branch magic; if you git branch, you'll see it.
The rule: each frame has exactly one commit ahead of its parent. Why one? Because:
- Reviewers see a single conceptual change per frame.
- Rebasing the stack is trivial — replay one commit per frame, not a chain of fixups.
- "Squashing" is a no-op: each frame already is a squashed commit.
Enforcement comes from two places:
giff commitrefuses to add a second commit; suggestsgiff newor--amend.- A
pre-commithook (auto-installed) blocks plaingit commitfrom doing the same.
Stack
A stack is an ordered set of frames where each one is built on top of another. In the simplest case it's a chain:
flowchart BT main(["main<br/><span style='color:#86868b;font-size:11px'>trunk</span>"]) base["<b>feat/auth-base</b><br/><span style='color:#86868b;font-size:12px'>PR #42 → main</span>"] tokens["<b>feat/auth-tokens</b><br/><span style='color:#86868b;font-size:12px'>PR #43 → feat/auth-base</span>"] mw["<b>feat/auth-middleware</b><br/><span style='color:#86868b;font-size:12px'>PR #44 → feat/auth-tokens</span>"]:::brand main --> base --> tokens --> mw classDef brand fill:#ff0035,stroke:#ff0035,color:#ffffff;
The root frame targets the trunk. Every other frame targets the frame below it. When a
reviewer looks at PR #43 on GitHub, they see only the diff between feat/auth-base
and feat/auth-tokens — one layer at a time.
Trees
Stacks aren't always linear. A frame can have multiple children — that makes the stack a tree, not a chain:
flowchart BT main(["main<br/><span style='color:#86868b;font-size:11px'>trunk</span>"]) root["<b>feat/root</b><br/><span style='color:#86868b;font-size:12px'>PR #1 → main</span>"] left["<b>feat/branch-a</b><br/><span style='color:#86868b;font-size:12px'>PR #2 → feat/root</span>"] right["<b>feat/branch-b</b><br/><span style='color:#86868b;font-size:12px'>PR #3 → feat/root</span>"] main --> root root --> left root --> right
Operations stay sensible on trees:
giff nexton a frame with multiple children opens a TUI picker.giff logrenders the tree with├─/└─connectors.giff stack landrequires exactly one root (otherwise "the bottom" is ambiguous).giff stack reorderis linear-only; trees usedrop/squashinstead.
Trunk
The trunk is the base branch your stacks land into — usually main or
master. Default is configured in ~/.config/giff/config.toml:
[defaults] trunk = "main"
Each stack stores its own trunk in .git/stacked.toml, so different stacks in the same
repo can target different bases (e.g. a release branch).
Lifecycle
The full life of a stacked feature looks like this:
flowchart LR publish["<b>publish</b><br/><span style='color:#86868b;font-size:12px'>create frame +<br/>commit changes</span>"] push["<b>push</b><br/><span style='color:#86868b;font-size:12px'>open / update<br/>PRs on GitHub</span>"] sync["<b>sync</b><br/><span style='color:#86868b;font-size:12px'>rebase onto<br/>fresh trunk</span>"] land["<b>land</b><br/><span style='color:#86868b;font-size:12px'>merge bottom,<br/>promote rest</span>"]:::brand publish --> push --> sync --> land classDef brand fill:#ff0035,stroke:#ff0035,color:#ffffff;
- publish — stage changes, run
giff publish "msg". A frame is born with one commit. - push —
giff pushopens or updates a PR for every frame. Each PR'sbaseis the frame below; the bottom targets trunk. - sync — somebody else merges to
main.giff syncpulls trunk and rebases the whole stack onto it. If a frame was merged via the GitHub web UI, sync detects it and retargets its children. - land — once the bottom PR is approved,
giff stack landmerges it via the GitHub API and retargets the (now-orphaned) child PRs to trunk.
Metadata: where it lives
Two files, plus PR descriptions on GitHub.
.git/stacked.toml — local, per-repo
Holds the list of stacks, frames, parent pointers, and PR numbers. Lives inside .git/, so git ignores it automatically — never committed, never pushed.
[[stacks]] id = "a1b2c3..." name = "auth-refactor" trunk = "main" [[stacks.frames]] id = "f1..." branch = "feat/auth-base" pr_number = 42 [[stacks.frames]] id = "f2..." branch = "feat/auth-tokens" parent = "f1..." pr_number = 43
Embedded JSON in PR descriptions
Every PR giff push creates carries a small fenced JSON block at the end of the
description:
Part 2/3 of stack `auth-refactor`.
```giff
{"stack_id":"a1b2c3","frame_id":"f2","parent_frame_id":"f1","position":2,"total":3}
```This is what lets the web dashboard and the runner reconstruct
the stack from GitHub alone — no shared local file needed. Don't delete the block manually;
giff push will rewrite it.
Other state files
.git/giff_sync_resume.json— written whengiff synchits a rebase conflict. Holds the list of frames still to rebase. Deleted ongiff sync --continuesuccess..git/hooks/pre-commit— auto-installed hook enforcing one commit per frame.~/.config/giff/config.toml— global config (token, base URL, defaults).
Reconciliation
PRs sometimes get merged outside giff — say, by clicking the green Merge button on github.com. When that happens the local store is briefly stale: it still thinks the merged frame exists. Reconciliation is the process of detecting these merges and updating local state plus the PR bases on GitHub.
Two reconcilers, both safe to run concurrently:
- CLI:
giff syncqueries GitHub for each tracked PR's status, prunes merged frames, retargets children's bases on GitHub, and rebases the rest locally. - Runner (optional): a service that runs the same logic continuously via webhooks + polling, plus an explicit retry queue when API calls fail.
Both ultimately call the same kind of API (get_pr, update_pr) and both
are idempotent. Running them at the same time is safe — the second one's reconcile is a no-op.
The runner — optional, but useful
When you don't run giff sync for a few hours, your local store drifts from reality.
That's fine — sync re-converges. But until you sync, no automation happens. The runner closes
that gap by running the reconciliation continuously, with two signal sources:
- Webhooks — the primary path. GitHub fires
pull_request/pull_request_reviewevents at a URL you register; the runner verifies the HMAC signature, refreshes the affected PR snapshot, and reconciles. - Polling — every 15 minutes by default, as a safety net for missed deliveries.
It can also auto-merge the bottom frame of any single-root stack once GitHub reports it mergeable. See install for the full setup.