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 commit refuses to add a second commit; suggests giff new or --amend.
  • A pre-commit hook (auto-installed) blocks plain git commit from 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;
A linear stack: each frame's PR targets the frame below.

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
A Y-shaped stack: feat/root has two parallel children.

Operations stay sensible on trees:

  • giff next on a frame with multiple children opens a TUI picker.
  • giff log renders the tree with ├─ / └─ connectors.
  • giff stack land requires exactly one root (otherwise "the bottom" is ambiguous).
  • giff stack reorder is linear-only; trees use drop / squash instead.

Trunk

The trunk is the base branch your stacks land into — usually main or master. Default is configured in ~/.config/giff/config.toml:

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;
The state of a stack across its life.
  1. publish — stage changes, run giff publish "msg". A frame is born with one commit.
  2. pushgiff push opens or updates a PR for every frame. Each PR's base is the frame below; the bottom targets trunk.
  3. sync — somebody else merges to main. giff sync pulls 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.
  4. land — once the bottom PR is approved, giff stack land merges 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.

toml
[[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 when giff sync hits a rebase conflict. Holds the list of frames still to rebase. Deleted on giff sync --continue success.
  • .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 sync queries 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_review events 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.

giff stack · open source · MIT Source