|
| 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. |
0 commit comments