Why is this considered "squash safe"?
how come this is "squash safe"?
It’s “squash-safe” because it does not depend on normal merge ancestry surviving.
In a stacked PR flow, the dangerous case is:
dev
└─ parent
└─ child
If parent is squash-merged into dev, then the commits from parent are now represented on dev by a new squash commit, not by the original parent commits. A normal git rebase child onto dev can get confused or replay too much, because Git history no longer has the parent branch’s commits as ancestors.
stack handles that explicitly:
-
It records an anchor per stack link.
When it tracks/infer links, it stores the merge-base between child and parent:const anchor = yield* git.base(branch, parent); ... stackLink({ branch, parent, anchor: anchor.value, ... })Source:
src/services/Stack.ts:572-579 -
When a parent lands, it backs up the old parent branch before deleting it.
Duringstack merge, it creates abackup/landed-...branch for the landed root:const name = `backup/landed-${stamp}-${target}`; ... yield* git.backup(target, name);Source:
src/services/Stack.ts:1651-1653,src/services/Stack.ts:1827-1829 -
It retargets children to the landed parent’s base.
Ifparentlands intodev, children that pointed atparentare retargeted todev:item.parent === target ? stackLink({ branch: item.branch, parent: root, anchor: item.anchor, ... })Source:
src/services/Stack.ts:1692-1707 -
It replays only the child’s novel commits.
The repair code finds the commit range to replay from the saved anchor/backup, then filters it withgit cherryso commits already represented on the new base are dropped:const commits = yield* git.commits(baseRef.value, link.branch); return yield* git.novel(onto, link.branch, commits);Source:
src/services/Stack.ts:950-955And
git.novelis implemented with:git cherry parent branchkeeping only
+commits:.filter((line) => line.startsWith("+ "))Source:
src/services/Git.ts:221-237 -
It reconstructs the branch by cherry-picking onto the new base, not by trusting old ancestry.
git checkout -B temp parent git cherry-pick --empty=drop ...commits git branch -f branch tempSource:
src/services/Git.ts:270-291 -
It uses squash merge on the code host.
GitHub:gh pr merge <pr> --squashSource:
src/services/code-host/GitHub.ts:163-170GitLab:
glab mr merge <mr> --squash --yesSource:
src/services/code-host/GitLab.ts:195-202
So “squash-safe” means: after a parent PR/MR is squash-merged and maybe its branch is deleted, stack still knows where the child’s own work began, can retarget the child, and can replay only the commits that are still novel relative to the new trunk.
It does not mean conflicts are impossible. It means the algorithm is designed for squash-merge workflows rather than assuming merge commits / original parent ancestry remain intact.
is that only if merged with their CLI? or merging via github PR would just work?
Not only if merged with stack / gh.
Merging via the GitHub PR UI should work if:
- the PR is squash-merged, and
- the stack had already been tracked/synced before the parent disappeared, so
stackhas the child’s stored anchor, and - after merging, you run:
git fetch --prune
stack sync
stack sync --apply
The important part is not “merged by the CLI”; it’s that stack has enough metadata to repair the descendants after the squash.
The CLI path gives you extra safety/convenience:
stack merge --applyalways invokes GitHub with--squash.- It backs up the local landed branch as
backup/landed-.... - It retargets children before merging.
- It immediately repairs descendants after the merge.
Source: GitHub merge uses squash in src/services/code-host/GitHub.ts:163-170, and the merge flow creates a backup/landed-... branch in src/services/Stack.ts:1651-1653, 1827-1829.
But the repair logic is also written to handle the “someone squash-merged the parent elsewhere and it’s gone now” case. There’s even a test for it:
sync uses stored child anchor after squash-merged parent is removed
In that test, the parent is gone, the child is now based on dev, and stack sync --apply replays only child-only, not the parent commits:
expect(seen).toContain("rebase child origin/dev child-only");
expect(seen).not.toContain("rebase child origin/dev parent-1,parent-2,child-only");
Source: tests/stack.test.ts:3050-3084.
The caveat: if you merge in the GitHub UI before stack has recorded the relationship/anchor, then it may not know where the child-only commits began. Also, if the parent branch remains around and the child PR still targets that parent branch, stack may reasonably think the stack is still intact rather than “parent landed, repair child onto trunk.”
So the practical answer:
# before merging stacked PRs
stack sync --apply
# then you can squash-merge in GitHub UI
# afterwards
git fetch --prune
stack sync --apply
Using stack merge --apply is just the more controlled path; it’s not required for squash-safety.
where does stack stores its state?
stack stores its metadata inside the repo’s git directory:
.git/stack/state.json
.git/stack/undo.json
More precisely, the CLI uses Git’s common git dir:
git rev-parse --git-common-dir
then joins:
stack/state.json
stack/undo.json
Source: src/cli.ts:310-331.
So in a normal repo, that means:
.git/stack/state.json # tracked stack links / anchors / PR numbers
.git/stack/undo.json # last mutation journal for stack undo
In a worktree setup, it uses the shared common git dir rather than the per-worktree .git file/dir.
The store implementation reads/writes those JSON files here:
read -> cfg.store
write -> cfg.store
readUndo -> cfg.journal
writeUndo -> cfg.journal
clearUndo -> remove cfg.journal
Source: src/services/Store.ts:69-90.
And the default config also shows the intended paths:
store: path.join(opts.root, ".git", "stack", "state.json")
journal: path.join(opts.root, ".git", "stack", "undo.json")
Source: src/services/Config.ts:53-54.
state.json is durable stack metadata. undo.json is just the most recent applied mutation journal used by:
stack history
stack undo --apply
TIL so it goes with pushes and doesn't depend on local file system state? git has like built-in generic storage? wth
No — important correction: it does not go with pushes.
stack’s state is local to your clone, stored under the Git directory:
$(git rev-parse --git-common-dir)/stack/state.json
$(git rev-parse --git-common-dir)/stack/undo.json
Usually that’s:
.git/stack/state.json
.git/stack/undo.json
But in a worktree it may be somewhere like:
/path/to/main/repo/.git/worktrees/... # per worktree
/path/to/main/repo/.git # common git dir
stack intentionally uses git rev-parse --git-common-dir, so all worktrees for the same clone share the same stack metadata. Source: src/cli.ts:310-331.
But that state is just plain JSON files in .git. It is not committed, not pushed, not fetched, and not shared with other clones.
You can inspect it with:
cat "$(git rev-parse --git-common-dir)/stack/state.json"
The “wth” part: Git doesn’t exactly have “generic pushed storage” here. It has a local Git directory, and tools can put their own files in it. stack does that. Source: src/services/Store.ts:69-90.
So the model is:
| Thing | Shared by push/fetch? | Notes |
|---|---|---|
| branch commits | yes | normal Git refs/objects |
| PR/MR target branches | yes-ish | from GitHub/GitLab API |
stack PR/MR blocks | yes | stored in PR/MR descriptions |
.git/stack/state.json | no | local clone metadata |
.git/stack/undo.json | no | local undo journal |
backup/landed-* branches | no by default | local branches unless manually pushed |
stack can re-infer a lot from GitHub/GitLab PR target branches via stack sync, but the local state.json is where it stores the exact stack links and anchors that make repair more reliable after squash merges.
So if Alice runs stack sync --apply, then Bob clones the repo fresh, Bob does not automatically get Alice’s .git/stack/state.json.
For best results, the person/environment maintaining the stack should run:
stack sync --apply
before landing parents, so that local clone has anchors recorded. If you want to move machines, you’d need to preserve/copy that .git/stack/state.json, or re-run stack sync while the parent branches still exist so it can infer the links again.
END OF POST