Skip to content

Commit c833116

Browse files
committed
docs: add ARCHITECTURE.md, update README.md
1 parent 4794712 commit c833116

2 files changed

Lines changed: 232 additions & 4 deletions

File tree

ARCHITECTURE.md

Lines changed: 185 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,185 @@
1+
# Architecture: Data Storage
2+
3+
gh-stack minimizes state. It stores only the metadata it needs, using standard Git facilities—`git-config` and plain files in `.git/`—rather than maintaining its own object store or custom database. This is a deliberate trade-off: simplicity and speed over robustness and history.
4+
5+
Everything lives inside `.git/`, so nothing is committed to your repository or shared between clones.
6+
7+
## Storage Locations
8+
9+
gh-stack uses three storage mechanisms:
10+
11+
- **`.git/config`** — Stack metadata (persistent). Git config key-value pairs.
12+
- **`.git/STACK_CASCADE_STATE`** — In-progress operation recovery. A single JSON file.
13+
- **`.git/stack-undo/`** — Undo snapshots. A directory of JSON files.
14+
15+
## Stack Metadata
16+
17+
The core data model lives in `.git/config` as standard Git configuration keys:
18+
19+
```ini
20+
[stack]
21+
trunk = main
22+
23+
[branch "feature-auth"]
24+
stackParent = main
25+
stackPR = 123
26+
stackForkPoint = abc123def456...
27+
```
28+
29+
### Keys
30+
31+
| Key | Value | Purpose |
32+
| ------------------------------ | ----------- | ---------------------------------------------------- |
33+
| `stack.trunk` | Branch name | The base branch for all stacks (usually `main`) |
34+
| `branch.<name>.stackParent` | Branch name | Parent branch in the stack hierarchy |
35+
| `branch.<name>.stackPR` | Integer | Associated GitHub PR number |
36+
| `branch.<name>.stackForkPoint` | Commit SHA | Where the branch originally diverged from its parent |
37+
38+
### When Metadata Is Written
39+
40+
| Command | Writes |
41+
| ----------------- | ---------------------------------------------------------------- |
42+
| `init` | `stack.trunk` |
43+
| `create`, `adopt` | `stackParent`, `stackForkPoint` |
44+
| `submit`, `link` | `stackPR` |
45+
| `sync` | Updates `stackParent`, `stackForkPoint` during rebase operations |
46+
| `orphan` | Removes `stackParent`, `stackPR`, `stackForkPoint` |
47+
| `unlink` | Removes `stackPR` |
48+
| `undo` | Restores any of the above from a snapshot |
49+
50+
### Trade-offs
51+
52+
**Why `git-config`?** It requires zero parsing code. Git's config machinery handles escaping, sections, quoting, and file locking. Reading and writing are single subprocess calls (`git config --get` / `git config key value`). Users can inspect and repair state with `git config --edit` or any text editor.
53+
54+
**What this costs us:**
55+
56+
- **No transactional updates.** Each `git config` call is independent. A crash mid-operation (say, between writing `stackParent` and `stackForkPoint`) could leave partial state. The undo system mitigates this.
57+
- **Not portable between clones.** `.git/config` is local. You cannot transfer stack relationships to another clone. In practice this hasn't mattered—stacks are a local workflow concern.
58+
- **Case normalization.** Git normalizes config keys to lowercase, so `stackParent` becomes `stackparent` in the file. The code handles this explicitly when listing tracked branches via `--get-regexp`.
59+
- **No caching.** Every read forks a `git config` subprocess. This is fast enough for interactive use, but means operations that read many branches (like `log`) make many subprocess calls.
60+
61+
## Cascade State
62+
63+
When a multi-branch rebase is interrupted by a conflict, gh-stack saves the operation state so you can resume with `gh stack continue` or cancel with `gh stack abort`.
64+
65+
**File:** `.git/STACK_CASCADE_STATE`
66+
**Format:** JSON (indented, human-readable)
67+
68+
```json
69+
{
70+
"current": "feature-b",
71+
"pending": ["feature-c", "feature-d"],
72+
"original_head": "abc123...",
73+
"operation": "submit",
74+
"update_only": false,
75+
"web": false,
76+
"push_only": false,
77+
"branches": ["feature-a", "feature-b", "feature-c", "feature-d"],
78+
"stash_ref": "def456..."
79+
}
80+
```
81+
82+
### Fields
83+
84+
| Field | Purpose |
85+
| --------------------------------- | -------------------------------------------------------------------------- |
86+
| `current` | Branch where the conflict occurred |
87+
| `pending` | Remaining branches to rebase |
88+
| `original_head` | HEAD before the operation started (for abort) |
89+
| `operation` | `"cascade"` or `"submit"` |
90+
| `stash_ref` | Commit hash of auto-stashed uncommitted changes |
91+
| `branches` | Full branch list (submit only; used to rebuild the set for push/PR phases) |
92+
| `update_only`, `web`, `push_only` | Submit-specific flags preserved across continue |
93+
94+
### Cascade State Lifecycle
95+
96+
1. **Created** when a rebase conflict interrupts `cascade`, `submit`, or `sync`.
97+
2. **Removed** before `continue` resumes (will be recreated if another conflict occurs).
98+
3. **Removed** on successful completion or `abort`.
99+
100+
### Cascade State Trade-offs
101+
102+
This is an ephemeral, single-operation state file. It is not designed to survive beyond the operation that created it.
103+
104+
- **Single-operation scope.** Only one cascade/submit/sync can be in progress at a time. The code checks for this file and refuses to start a new operation if one exists.
105+
- **Best-effort persistence.** Save errors are ignored (`//nolint:errcheck`) because if we can't save state, the user can still recover manually by aborting the rebase and re-running the command.
106+
107+
## Undo Snapshots
108+
109+
Before any destructive operation, gh-stack captures a snapshot of every affected branch's state. This provides multi-level undo without requiring a full state history.
110+
111+
**Directory:** `.git/stack-undo/`
112+
**Archive:** `.git/stack-undo/done/`
113+
**Format:** JSON files named `{timestamp}-{operation}.json`
114+
115+
```json
116+
{
117+
"timestamp": "2026-02-05T12:00:00Z",
118+
"operation": "cascade",
119+
"command": "gh stack cascade",
120+
"original_head": "abc123...",
121+
"stash_ref": "",
122+
"branches": {
123+
"feature-auth": {
124+
"sha": "def456...",
125+
"stack_parent": "main",
126+
"stack_pr": 123,
127+
"stack_fork_point": "789abc..."
128+
}
129+
},
130+
"deleted_branches": {}
131+
}
132+
```
133+
134+
### Snapshot Lifecycle
135+
136+
1. **Created** before destructive operations (`cascade`, `submit`, `sync`).
137+
2. **Used** by `undo`, which restores branch refs and config keys from the snapshot.
138+
3. **Archived** to `done/` after a successful undo.
139+
4. **Pruned** automatically: max 50 active snapshots and 50 archived. Oldest are removed first.
140+
141+
### Snapshot Trade-offs
142+
143+
- **Timestamp-based naming.** Filenames use nanosecond-precision timestamps to avoid collisions. Lexicographic sorting gives chronological ordering for free.
144+
- **Bounded growth.** Auto-pruning means no manual cleanup is needed, but also means very old snapshots silently disappear.
145+
- **No shared state.** Like everything else, snapshots are local to the clone.
146+
- **Captures the "before" picture only.** The snapshot records pre-operation state, not the operation itself. This is simpler than maintaining a full operation log, and in practice, "undo the last thing" is what you actually want.
147+
148+
## Data Flow
149+
150+
```mermaid
151+
flowchart TD
152+
subgraph commands [Commands]
153+
init[init]
154+
create[create / adopt]
155+
submit[submit / link]
156+
cascade[cascade / sync]
157+
undoCmd[undo]
158+
end
159+
160+
subgraph storage [".git/"]
161+
config["config — stack metadata"]
162+
state["STACK_CASCADE_STATE — operation recovery"]
163+
snapshots["stack-undo/ — undo history"]
164+
end
165+
166+
init -->|set trunk| config
167+
create -->|set parent, fork point| config
168+
submit -->|set PR number| config
169+
cascade -->|on conflict| state
170+
cascade -->|before start| snapshots
171+
undoCmd -->|restore from| snapshots
172+
undoCmd -->|restore| config
173+
```
174+
175+
## Implementation
176+
177+
| Concern | Package | Notes |
178+
| --------------------------- | ----------------- | -------------------------------------------------------- |
179+
| Stack metadata (git-config) | `internal/config` | Direct `exec.Command("git", "config", ...)` calls |
180+
| Cascade state | `internal/state` | `json.MarshalIndent` / `json.Unmarshal` + `os.WriteFile` |
181+
| Undo snapshots | `internal/undo` | Same JSON approach, plus directory listing and pruning |
182+
| Git operations | `internal/git` | Uses `safeexec.LookPath` with `sync.Once` caching |
183+
| Tree construction | `internal/tree` | Builds in-memory tree from config for traversal |
184+
185+
Note that `internal/config` and `internal/git` both execute Git subprocesses, but use slightly different patterns. `internal/git` uses `safeexec.LookPath` to prevent PATH injection (important on Windows); `internal/config` calls `exec.Command("git", ...)` directly. This is a known inconsistency.

README.md

Lines changed: 47 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -333,9 +333,9 @@ Snapshots are stored in `.git/stack-undo/` and archived to `.git/stack-undo/done
333333
334334
#### undo Flags
335335

336-
| Flag | Description |
337-
| ----------- | ---------------------------------------- |
338-
| `--force` | Skip confirmation prompt |
336+
| Flag | Description |
337+
| ----------- | -------------------------------------------- |
338+
| `--force` | Skip confirmation prompt |
339339
| `--dry-run` | Show what would be restored without doing it |
340340

341341
## How It Works
@@ -361,7 +361,32 @@ No remote service required. Your stack relationships stay with your repository.
361361

362362
### vs. Graphite
363363

364-
[Graphite](https://graphite.dev/) is a SaaS product with a polished CLI and web dashboard. It requires an account and stores stack metadata on their servers. **gh-stack** stores everything locally in `.git/config`—no account, no remote dependency.
364+
[Graphite](https://graphite.dev/) is a SaaS product with a polished CLI (`gt`) and web dashboard. It requires an account and stores stack metadata on their servers.
365+
366+
Graphite's CLI is significantly more feature-rich:
367+
368+
| Feature | Graphite (`gt`) | `gh-stack` |
369+
| ------------------------------------- | :-------------: | :--------: |
370+
| Create / track / untrack branches |||
371+
| View stack hierarchy |||
372+
| Sync with trunk |||
373+
| Create & update PRs |||
374+
| Conflict recovery (continue/abort) |||
375+
| Undo last operation |||
376+
| Stack navigation (up/down/top/bottom) |||
377+
| Move branch to new parent |||
378+
| Fold / split / reorder branches |||
379+
| Absorb changes into downstack |||
380+
| Amend / squash commits |||
381+
| Fetch teammate's stack |||
382+
| Freeze branch to prevent edits |||
383+
| Web dashboard & PR inbox |||
384+
| AI code review |||
385+
| Merge queue |||
386+
| Works offline (no account required) |||
387+
| Open source |||
388+
389+
If you want the kitchen sink—stack navigation, branch surgery, a web UI, AI reviews, merge queues—Graphite is hard to beat. If you want a lightweight, open-source tool that handles the core stacking workflow without an account or remote dependency, that's what **gh-stack** is for.
365390

366391
### vs. spr
367392

@@ -381,6 +406,24 @@ No remote service required. Your stack relationships stay with your repository.
381406

382407
**gh-stack** is narrower in scope: it tracks parent-child relationships between branches and helps you manage the resulting PRs. It doesn't modify how Git works—it just adds stack awareness on top.
383408

409+
### vs. git-spice
410+
411+
[git-spice](https://abhinav.github.io/git-spice/) is a stacking tool that stores all operational state as Git objects in a local ref (`refs/spice/data`). Every state mutation creates a new commit in that ref, giving you a full history of every change to your stack metadata—explorable with `git log --patch refs/spice/data`. It supports both GitHub and GitLab.
412+
413+
**gh-stack** stores stack metadata directly in `.git/config` as standard Git configuration keys, with operation recovery and undo handled by plain JSON files in `.git/`. This means:
414+
415+
- **Faster writes.** Setting a config key is a single `git config` subprocess call. Creating a Git object requires hashing, compressing, writing a blob, writing a tree, writing a commit, and updating the ref.
416+
- **Easier debugging.** You can inspect and repair state with `git config --edit` or a text editor. No need for `git cat-file` or `git log` on an internal ref.
417+
- **No state history.** **git-spice** gets a full audit log for free. **gh-stack** provides multi-level undo via separate snapshot files instead, which covers the common case (undoing the last operation) without the overhead.
418+
419+
See [ARCHITECTURE.md](ARCHITECTURE.md) for a detailed breakdown of **gh-stack**'s data storage approach.
420+
421+
## Project Scope
422+
423+
- **gh-stack** aims to be a minimal alternative to Graphite for those who do not need its full feature set
424+
- **gh-stack** wants to support only the minimum set of operations needed to manage stacked PRs
425+
- Being a [GitHub CLI][] extension, **gh-stack** will not support other Git hosting service
426+
384427
## Development
385428

386429
To build from source, you'll need Go 1.25+.

0 commit comments

Comments
 (0)