Why is this considered "squash safe"?

HEAD
bb2727bchore: version packages (#36)

how come this is "squash safe"?

Avatar of openai/gpt-5.5
GPT 5.5
commented

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:

  1. 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

  2. When a parent lands, it backs up the old parent branch before deleting it.
    During stack merge, it creates a backup/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

  3. It retargets children to the landed parent’s base.
    If parent lands into dev, children that pointed at parent are retargeted to dev:

    item.parent === target
      ? stackLink({
          branch: item.branch,
          parent: root,
          anchor: item.anchor,
          ...
        })
    

    Source: src/services/Stack.ts:1692-1707

  4. 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 with git cherry so 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-955

    And git.novel is implemented with:

    git cherry parent branch
    

    keeping only + commits:

    .filter((line) => line.startsWith("+ "))
    

    Source: src/services/Git.ts:221-237

  5. 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 temp
    

    Source: src/services/Git.ts:270-291

  6. It uses squash merge on the code host.
    GitHub:

    gh pr merge <pr> --squash
    

    Source: src/services/code-host/GitHub.ts:163-170

    GitLab:

    glab mr merge <mr> --squash --yes
    

    Source: 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.

Avatar of julianbenegas
Julian Benegas
commented

is that only if merged with their CLI? or merging via github PR would just work?

Avatar of openai/gpt-5.5
GPT 5.5
commented

Not only if merged with stack / gh.

Merging via the GitHub PR UI should work if:

  1. the PR is squash-merged, and
  2. the stack had already been tracked/synced before the parent disappeared, so stack has the child’s stored anchor, and
  3. 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 --apply always 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.

Avatar of julianbenegas
Julian Benegas
commented

where does stack stores its state?

Avatar of openai/gpt-5.5
GPT 5.5
commented

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
Avatar of julianbenegas
Julian Benegas
commented

TIL so it goes with pushes and doesn't depend on local file system state? git has like built-in generic storage? wth

Avatar of openai/gpt-5.5
GPT 5.5
commented

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:

ThingShared by push/fetch?Notes
branch commitsyesnormal Git refs/objects
PR/MR target branchesyes-ishfrom GitHub/GitLab API
stack PR/MR blocksyesstored in PR/MR descriptions
.git/stack/state.jsonnolocal clone metadata
.git/stack/undo.jsonnolocal undo journal
backup/landed-* branchesno by defaultlocal 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

Why is this considered "squash safe"? — kitlangton/stack