Mercurial Phases: Built-In Guardrails Against History Rewriting Accidents
Mercurial's phases system (public/draft/secret) attaches immutable metadata to each changeset, preventing accidental rewrites of shared history without server-side hooks. Unlike Git's branch protection, safety travels with the data.
26 Jun 2026, 18:59 UTC

The Problem: Rewriting History You Already Shared
Every distributed version control user eventually faces the same mistake: you rewrite a commit that teammates have already pulled. In Git, this requires force-pushing to a protected branch, and the server decides whether to allow it. Mercurial takes a different approach. Its phases system attaches immutable metadata to each changeset at commit time — public, draft, or secret — and the core commands (rebase, histedit, amend, strip) refuse to touch changesets marked public unless you explicitly override with --force. The safety travels with the data, not the server policy.
Three Phases, One Direction
Every new commit starts as draft — local-only, freely rewritable. When you push or pull to a publishing repository (the default), those changesets become public. Public changesets are immutable by default; hg amend aborts with "cannot rewrite immutable changeset." The third phase, secret, hides changesets from push/pull entirely. You create them with hg commit --secret or by setting phases.new-commit = secret in your config. Secret changesets never participate in outgoing calculations and do not serve as merge bases for public work — a detail that surprises users expecting them to act like regular ancestors.
Phase transitions are one-way by default: secret → draft → public. Demoting public → draft requires hg phase --draft --force and only succeeds if the changeset has no public descendants, preventing silent corruption of already-shared history.
Publishing Repositories Make Phases Portable
The key insight: a repository advertises whether it is publishing via ui.publish (true by default). When you push to a publishing remote, the client promotes your draft changesets to public before sending them. The wire protocol carries phase roots, so every clone learns which changesets are public without a central authority. This works offline — a draft changeset stays draft until it reaches a publishing remote. Contrast this with Git's protected branches, where the server enforces policy and you need network access to know whether a rewrite will be rejected.
A misconfigured non-publishing remote (ui.publish = false) will not promote draft→public, leaving changesets rewriteable even after they've been shared. Check a remote's behavior with hg showconfig ui.publish from the clone pointing at that remote.
Worked Example: Caught Before Force-Push
# Start a feature locally
$ hg init myfeature && cd myfeature
$ echo 'initial' > main.py && hg commit -Am 'start'
$ hg log -T '{rev}:{phase}\n'
0:draft
# Push to a publishing remote (e.g., central server)
$ hg push ssh://[contact removed]/project
$ hg log -T '{rev}:{phase}\n'
0:public
# Try to amend the public changeset
$ echo 'typo fix' >> main.py && hg amend -m 'start (fixed)'
abort: cannot rewrite immutable changeset (use --force to override)
# Correct workflow: create a new commit instead
$ hg commit -Am 'fix typo in start'
$ hg log -T '{rev}:{phase} {desc}\n'
0:public start
1:draft fix typo in start
The amend fails immediately — no force-push, no server round-trip, no surprised teammates. You commit a follow-up instead. If you must rewrite (e.g., a secret credential in a draft changeset), hg phase --draft --force -r 0 demotes it, but only because it has no public children.
Trade-offs and Limitations
- One-way by default: Forcing
public → draftbreaks the evolve extension's obsolescence graph if other clones already have the changeset. Evolve tracks obsolete/successor relationships for draft changesets, enabling safe distributed rewriting (hg evolveafter upstream rebase), but it relies on phase integrity. - Secret changesets are invisible: They don't appear in
hg outgoing, don't push, and don't serve as merge bases. If you commit secret work atop a public base, then make the secret work draft, the ancestry looks normal. But if you try to merge two secret lines, Mercurial won't see the common ancestor. - Performance at scale: Large monorepos with many secret/draft heads saw slower
hg statusandhg logdue to phase filtering. Mercurial 6.0+ added phase caches to mitigate this.
Putting It Into Practice
- Run
hg help phasesandhg help -e evolveto see current command reference (Mercurial ≥ 5.0). - Verify your remotes:
hg showconfig ui.publishper remote path. Ensure shared remotes are publishing. - Use
hg commit --secretfor local experiments you never intend to share — they stay out ofoutgoingand push. - When a rewrite is blocked, ask whether a follow-up commit is cleaner. Reserve
--forcefor genuine mistakes in draft-only history.
The phases system doesn't prevent all history mistakes, but it makes the dangerous ones explicit and local. You rewrite history on your terms, with the data's own metadata telling you when to stop.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.