1. Introduction

hg4j is a pure Java implementation of the Mercurial (hg) version control system, modeled after JGit’s structure and split into a low-level Plumbing layer (HgRepository, Revlog, Dirstate, etc.) and a high-level Porcelain layer — the Hg facade and its XxxCommand classes, one per hg subcommand.

This manual is a task-oriented usage guide. It covers "how do I add a file, commit it, and push it to a remote," not an exhaustive listing of every class and method. For a complete reference of every public class, method, and parameter, see the Javadoc instead:

  • Published (always matches the latest release on Maven Central): javadoc.io/doc/io.github.search5.hg4j/hg4j

  • Local build:

    ./gradlew javadoc    # English, generated under build/docs/javadoc/
    ./gradlew javadocKo  # Korean,  generated under build/docs/javadoc-ko/

Every code sample in this manual is actually compiled at build time (see src/docs/examples/java). If the Hg API ever changes in a way that breaks an example, the build fails outright instead of the manual quietly going stale.

1.1. The Hg facade

Almost everything in this manual goes through a single entry point: the Hg class.

  • Hg.init() / Hg.cloneRepository() — static methods. They create a repository that doesn’t exist yet and return the resulting HgRepository, since there is no existing Hg instance to call them on.

  • Hg.open(path) / Hg.wrap(repository) — obtain an Hg instance bound to an already existing repository. Every other command (add(), commit(), status(), push(), …​) is an instance method on that Hg object.

  • Hg implements AutoCloseable — always use it inside a try-with-resources block.

Each XxxCommand returned by an Hg instance method follows the same builder shape: configure it via setXxx(…​) calls (each returning this for chaining), then call call() to actually run it.

2. Getting Started

2.1. Adding the dependency

build.gradle
dependencies {
    implementation 'io.github.search5.hg4j:hg4j:1.0.1'
}

hg4j targets Java 21. If you want to use hg4j as a Gradle SCM plugin instead of (or in addition to) a plain library dependency, see the "Gradle Plugin Usage" section of the project README.

2.2. Creating a new repository

        HgRepository repository = Hg.init()
                .setDirectory(new File("/path/to/repo"))
                .call();

2.3. Cloning an existing repository

        HgRepository repository = Hg.cloneRepository()
                .setSource("https://example.com/hg/some-project")
                .setDirectory(new File("/path/to/local-clone"))
                .call();

2.4. Opening an existing repository

Once a repository already exists on disk, Hg.open(…​) gives you the facade instance every other command in this manual is called on.

        try (Hg hg = Hg.open("/path/to/repo")) {
            HgRepository repository = hg.getRepository();
            // ... run commands via hg.xxx().call() ...
        }

If you already have an HgRepository instance from somewhere else (for example, the return value of Hg.init()/Hg.cloneRepository()), wrap it directly instead of reopening it from disk.

        try (Hg hg = Hg.wrap(repository)) {
            // ... run commands via hg.xxx().call() ...
        }

3. Basic Workflow: add, status, commit, log, diff

3.1. Tracking files

            hg.add()
                    .addFile("README.md")
                    .addFile("src/main/java/App.java")
                    .call();

3.2. Checking working-copy status

status() mirrors `hg status’s five buckets exactly.

            Status status = hg.status().call();
            status.getAdded();      // newly tracked, not yet committed
            status.getModified();   // tracked and changed since the last commit
            status.getRemoved();    // tracked but deleted (hg remove/rm)
            status.getUntracked();  // present on disk but not tracked at all

3.3. Committing changes

            byte[] newNodeId = hg.commit()
                    .setMessage("Add initial application skeleton")
                    .setAuthor("Jane Doe <jane@example.com>")
                    .call();

commit() returns the new commit’s raw 20-byte node id.

3.4. Viewing history

            List<HgCommit> history = hg.log().call();
            for (HgCommit commit : history) {
                System.out.printf("%s %s %s%n",
                        commit.getNodeId().toHex().substring(0, 12),
                        commit.getAuthor(),
                        commit.getMessage());
            }

3.5. Viewing a diff

With no revisions configured, diff() compares the tip against its parent.

            // With no revisions set, diffs the tip against its parent.
            List<DiffEntry> changes = hg.diff().call();
            for (DiffEntry entry : changes) {
                System.out.println(entry.getPath() + " (" + entry.getChangeType() + ")");
                System.out.println(entry.getDiffContent());
            }

To diff two specific revisions directly, use setOldRevision(…​)/setNewRevision(…​) (both support either an int revision number or a NodeId overload).

4. Branches, Bookmarks, Merging & Rebasing

4.1. Named branches vs. bookmarks

Mercurial has two different ways to name a line of development, and they behave quite differently — this is a common point of confusion for developers coming from Git.

  • A named branch is a label recorded permanently inside the commit itself (branch()). Once committed, it never moves, and it stays visible in hg log forever.

  • A bookmark (bookmark()) is a movable pointer to a specific revision — much closer to what a Git branch actually is. While active, it moves forward automatically on the next commit.

4.2. Working with named branches

            // Marks the working copy so the NEXT commit records this branch name.
            hg.branch().setBranchName("feature/payments").call();
            // With no argument, reports the working copy's current branch instead.
            String currentBranch = hg.branch().call();

4.3. Working with bookmarks

            // Creates (or moves) a bookmark to point at the working copy's current parent.
            hg.bookmark().setBookmarkName("release-1.0").call();

            // Lists every bookmark as a name -> hex-node-id map.
            hg.bookmark().call().forEach((name, nodeHex) ->
                    System.out.println(name + " -> " + nodeHex));

            // Deletes a bookmark.
            hg.bookmark().setBookmarkName("release-1.0").setDelete(true).call();

4.4. Updating the working copy

            // setRevision() accepts "tip", a revision number, a hex node id (or unambiguous
            // prefix), or a named branch name -- NOT a bookmark name, so a bookmark must be
            // resolved to its node id first.
            hg.update().setRevision("feature/payments").call();

            String bookmarkNodeHex = hg.bookmark().call().get("release-1.0");
            hg.update().setRevision(bookmarkNodeHex).call();

4.5. Listing all branches

            // One entry per named branch's head -- closed branches are excluded by default.
            List<BranchHead> heads = hg.branches().call();
            for (BranchHead head : heads) {
                System.out.printf("%s %d:%s%n", head.getBranch(), head.getRev(), head.isActive());
            }

            // Include closed branches too.
            List<BranchHead> allHeads = hg.branches().setIncludeClosed(true).call();

By default, branches() returns only each named branch’s head, excluding closed branches — pass setIncludeClosed(true) to include closed branches too.

4.6. Merging

            List<String> headHexes = hg.heads().call();
            // Merge some other head into the working copy's current parent.
            byte[] otherHead = NodeId.fromHex(headHexes.get(1)).getBytes();
            MergeResult result = hg.merge().setNodeId(otherHead).call();

            if (result.isConflicted()) {
                for (String conflictedPath : result.getConflicts()) {
                    // Each conflicted file already has <<<<<<< / ======= / >>>>>>> markers
                    // written to disk -- resolve them, then commit like a normal merge commit.
                    System.out.println("Unresolved: " + conflictedPath);
                }
            } else {
                hg.commit().setMessage("Merge").call();
            }

A conflicted merge is not an exception — merge() returns normally, and conflicting paths are reported via MergeResult.isConflicted()/getConflicts(). Each conflicted file already has standard <<<<<<< / ======= / >>>>>>> markers written to disk; resolve them, then commit just like any other merge commit.

4.7. Rebasing

            List<String> headHexes = hg.heads().call();
            byte[] source = NodeId.fromHex(headHexes.get(0)).getBytes();
            byte[] target = NodeId.fromHex(headHexes.get(1)).getBytes();

            // Cherry-picks `source` (and its descendants) onto `target`. The original revisions
            // stay readable via `hg log --hidden`; only an obsolescence marker records that
            // they were superseded -- nothing is physically stripped.
            hg.rebase().setSource(source).setTarget(target).call();

Unlike a merge, a rebase conflict is actually reported by throwing HgMergeConflictException — see Error Handling for how to recover from it. rebase() never physically removes the original revisions; it records an obsolescence marker instead, so they remain visible via hg log --hidden.

5. Working with Remotes

5.1. Pushing

            String result = hg.push()
                    .setDestination("https://example.com/hg/some-project")
                    .call();
            System.out.println(result);

5.2. Pulling

            // Pulls new changesets from the remote into the local repository, WITHOUT touching
            // the working copy -- follow with hg.update() to actually check out the new tip.
            List<byte[]> importedNodeIds = hg.pull()
                    .setSource("https://example.com/hg/some-project")
                    .call();

5.3. Pulling with bookmark/phase sync

fetch() does everything pull() does, plus atomically synchronizes bookmarks and phases with the remote.

            // Like pull(), but also synchronizes bookmarks and phases atomically with the
            // remote. Neither pull() nor fetch() touches the working copy -- both still need a
            // separate hg.update() call to check the new tip out.
            List<byte[]> importedNodeIds = hg.fetch()
                    .setSource("https://example.com/hg/some-project")
                    .call();
Neither pull() nor fetch() touches the working copy — both only update the local repository’s store. Always follow either one with update() (see Branches, Bookmarks, Merging & Rebasing) to actually check the newly-fetched tip out.

5.4. Listing configured remote paths

            // The [paths] aliases (e.g. "default", "default-push") from .hg/hgrc, alphabetically
            // ordered by alias name. Empty if the repository has no [paths] section configured.
            Map<String, String> paths = hg.paths().call();
            String defaultUrl = paths.get("default");

paths() returns the [paths] aliases (e.g. default, default-push) registered in .hg/hgrc, alphabetically ordered — an empty map if no [paths] section is configured.

6. Undoing Changes & Tagging

6.1. Removing files

            // Untracks the file AND deletes it from disk (matching plain `hg remove`).
            hg.remove().setFile("obsolete/OldService.java").call();

remove() untracks the file and deletes it from disk at the same time — identical to plain hg remove.

6.2. Reverting files

            // Restores a file's working-copy content back to the current parent revision. A
            // dirty file gets its pre-revert bytes backed up to "<file>.orig" first.
            hg.revert().setFile("src/main/java/App.java").call();

            // Or revert to a specific historical revision instead of the current parent.
            hg.revert().setFile("src/main/java/App.java").setRevision("42").call();

A file that is currently modified has its pre-revert on-disk content backed up to <file>.orig before being overwritten. A file that is already clean gets no such backup, even when reverting to a revision whose content differs from what’s on disk.

6.3. Tagging a revision

            // Creates a tag pointing at the working copy's current parent and commits the
            // updated .hgtags file (like plain `hg tag`).
            hg.tag().setTagName("v1.0.0").call();

            // A local tag is written to .hg/localtags instead, and is never committed.
            hg.tag().setTagName("wip-checkpoint").setLocal(true).call();

A regular tag is recorded in the version-controlled .hgtags file and committed automatically. A local tag (setLocal(true)) is instead written to .hg/localtags and is never committed.

6.4. Listing all tags

            List<Tag> tags = hg.tags().call();
            for (Tag t : tags) {
                System.out.printf("%-20s %5d:%s%n", t.getName(), t.getRev(), t.isLocal());
            }

tags() merges both .hgtags (committed tags) and .hg/localtags (local tags) into a single list — use Tag#isLocal() to tell which one a given entry came from.

7. Error Handling

hg4j’s checked-exception hierarchy is deliberate, and — contrary to what you might expect — not every exception funnels through IOException.

Exception Extends When it’s thrown

HgLockException

HgException (NOT IOException)

The .hg/wlock or .hg/store/lock OS-level file lock could not be acquired — another process (or another Hg instance) is currently writing to the repository.

HgMergeConflictException

HgException (NOT IOException)

GraftCommand, BackoutCommand, or RebaseCommand left one or more files with unresolved conflict markers during their internal cherry-pick/replay merge.

HgRepositoryNotFoundException, HgRevisionNotFoundException, HgValidationException, HgTransportException, HgCorruptDataException, HgCensoredContentException

IOException

Everything else: a missing repository, an unresolvable revision string, bad input, a network/wire-protocol failure, or corrupted/censored on-disk data.

Because HgLockException and HgMergeConflictException are not IOException subtypes, a bare catch (IOException e) lets both of them silently pass through uncaught. This is exactly why most command methods declare both explicitly in their throws clause — catch them separately whenever you need to react to lock contention or a conflict differently from a generic I/O failure.

7.1. Recovering from lock contention

            try {
                hg.commit().setMessage("Automated checkpoint").call();
            } catch (HgLockException e) {
                // Another process (or another Hg instance) currently holds .hg/wlock or
                // .hg/store/lock -- retry after a delay, or surface it to the caller as
                // "repository is locked by another process".
                System.err.println("Repository busy, try again later: " + e.getMessage());
            }

7.2. Recovering from a merge conflict

            try {
                hg.graft().setSource("42").call();
            } catch (HgMergeConflictException e) {
                // The graft/rebase/backout is paused; the conflicting files already have
                // <<<<<<< / ======= / >>>>>>> markers written to disk. Resolve them by hand
                // (or via ResolveCommand), then call the same command's continue-style method.
                for (String conflictedPath : e.getConflictPaths()) {
                    System.err.println("Unresolved: " + conflictedPath);
                }
            }

8. Inspecting Repository State

This chapter covers the commands that inspect the repository’s own location and current state: root(), tip(), parents(), summary(), identify(), heads(), describe(). All of them are read-only and never modify the repository.

8.1. Finding the repository’s root path

root() returns the absolute path of the working directory that contains .hg — the same as hg root.

            String rootPath = hg.root().call();
            System.out.println("Repository root: " + rootPath);

8.2. Checking the tip revision

            byte[] tipNode = hg.tip().call();
            int tipRevisionNumber = hg.tip().getRevisionNumber();
            if (tipNode != null) {
                System.out.println("Tip revision: " + tipRevisionNumber);
            }

tip always refers to the highest revision number in the changelog, regardless of branch or head. In a repository whose history has been rewritten, this can differ from "the most recently created" revision — just like real hg, tip is defined by revision number, not by wall-clock time. On a repository with no commits at all, call() returns null and getRevisionNumber() returns -1.

8.3. Checking the working directory’s parent revision(s)

            // One entry in the common case, two while an uncommitted merge is in progress,
            // and zero only for a brand-new empty repository.
            List<String> parentHexes = hg.parents().call();
            for (String hex : parentHexes) {
                System.out.println("Parent: " + hex);
            }

parents() returns the working directory’s parent node id(s) (hex), as recorded in the dirstate. The common case is one entry; while an uncommitted merge is in progress it is two; a brand-new repository with no commits yet returns zero.

8.4. Checking a working-copy summary

hg4j is a library rather than a CLI, so instead of printing formatted text like hg summary, it returns a structured SummaryInfo record.

            SummaryInfo summary = hg.summary().call();
            for (ParentInfo parent : summary.parents()) {
                System.out.println("parent: " + parent.node() + " " + parent.description());
            }
            System.out.println("branch: " + summary.branch());
            System.out.println("bookmark: " + summary.activeBookmark());
            System.out.println("phase: " + summary.currentPhase());
            if (summary.mergeInProgress()) {
                System.out.println("An uncommitted merge is in progress.");
            }

SummaryInfo bundles the list of parent revisions (ParentInfo — revision number, node hex, commit message), the current branch, the active bookmark (null if none), status counts (modified/added/removed/unknown), whether a merge is in progress, and the phase of the first parent changeset. Internally it is assembled from parents(), BookmarkCommand, status(), and the phase roots, so think of summary() as a single call that gathers all of those separately queryable values at once.

8.5. Getting a one-line revision identity

identify() follows the exact format of hg identify (hg id)'s default (no --template) output.

            // Working copy's own parent(s): a trailing "+" marks a dirty working copy.
            String workingCopyIdentity = hg.identify().call();
            System.out.println(workingCopyIdentity);

Called with no arguments, it identifies the working directory’s own parent(s): the id is the 12-hex short node, +-joined for both parents when a merge is in progress, with one more trailing + appended whenever the working copy is dirty (modified/added/removed files, or a merge in progress). An empty repository with no commits at all identifies as 000000000000. The branch name is only shown in parentheses when it is not default, and tags/bookmarks are aggregated across every current parent, sorted alphabetically, and joined with /.

            // A fixed revision instead: no dirty marker is ever appended here.
            String tipIdentity = hg.identify().setRevision("tip").call();
            System.out.println(tipIdentity);

setRevision(String) pins the query to a fixed revision instead: no dirty marker is ever appended, and exactly that one revision is identified rather than the working copy’s parent(s). The branch shown in that case is the queried revision’s own branch — for example, while checked out on branch feature, identifying an older revision that belongs to default via -r shows default, the branch that revision actually belongs to, not the working copy’s current branch. Also note that the string setRevision accepts is limited to a revision number, a hex prefix, or "tip" — it does not resolve bookmark or branch names directly, so to identify a bookmark you first need hg.bookmark().call() to get the node hex it points to, and pass that.

8.6. Listing branch heads

            // Plain hg heads: every named branch's own open head(s).
            List<String> heads = hg.heads().call();

            // hg heads --topo: pure repo-wide topological leaves, ignoring branches.
            List<String> topoHeads = hg.heads().setTopo(true).call();

            // hg heads <branch>: only that branch's own heads.
            List<String> featureHeads = hg.heads().setBranch("feature").call();

The no-argument default (hg heads) collects each named branch’s own open head(s) — even when such a head is not a repository-wide topological leaf (e.g. a branch whose own tip has since been merged into and absorbed by another branch), it is still included as long as it has no same-branch child. Passing setTopo(true) switches to hg heads --topo’s behavior: it ignores branch mechanics entirely and returns only the pure topological leaves that have no children anywhere in the repository. `setBranch(name) narrows the result to just that branch’s own heads, and this behaves the same whether or not --topo is layered on top of it. Use setIncludeClosed(true) to also include heads closed via hg commit --close-branch.

8.7. Describing a revision relative to its nearest tag

            // "<tag>-<distance>-g<shortnode>" relative to the nearest ancestral tag,
            // or just "<tag>" when the working copy's parent IS a tagged revision.
            String description = hg.describe().call();
            System.out.println(description);

describe() starts from the working directory’s current parent revision and walks backward through ancestry to find the closest tag recorded in .hgtags. If the current revision is itself a tagged revision, only the tag name is returned; otherwise it returns a Git-describe-style tagname-distance-gshortnode string. If the repository has no tags at all, v0.0-distance-gshortnode is used instead, and a repository with no commits returns "empty-repository".

This chapter covers the commands that inspect file history and repository content: annotate(), cat(), files(), locate(), manifest(), tree(), grep(), revset(), phase(). Among these, the revision strings accepted by cat(), files(), locate(), manifest(), and phase() support only a revision number, a hex node prefix, or "tip" — bookmark or branch names cannot be passed directly; resolve them to a node hex first (e.g. via bookmark().call()) if you need to pass one.

9.1. Blaming a file’s history (annotate)

            List<BlameLine> lines = hg.annotate()
                    .setPath("src/main/java/App.java")
                    .call();
            for (BlameLine line : lines) {
                System.out.printf("%4d rev %-4d %-20s %s%n",
                        line.getLineNumber(), line.getRevision(), line.getAuthor(), line.getContent());
            }

annotate() always follows rename/copy boundaries — real hg’s hg annotate has no separate --follow flag for this either; it is simply the default behavior. Note that the value setRevision(int) accepts is not a changelog revision but that file’s own filelog revision number (the default -1 means that file’s latest filelog revision). Conversely, the resulting BlameLine#getRevision() is the changelog revision number (linkRev) that introduced the line — don’t confuse the two different kinds of "revision" on the input and output sides.

9.2. Reading a file’s content at a given revision

            byte[] content = hg.cat()
                    .setFile("README.md")
                    .setRevision("tip")
                    .call();
            System.out.println(new String(content, java.nio.charset.StandardCharsets.UTF_8));

cat() resolves the file’s filelog revision through the target revision’s manifest and returns its raw bytes. If the file was not yet tracked at that revision, it throws HgRevisionNotFoundException.

9.3. Listing tracked files

            // With no revision set, this reflects the working copy's dirstate (added-but-
            // uncommitted files included, removed-but-uncommitted files excluded).
            List<String> trackedFiles = hg.files().call();

            // Restrict to files matching a glob pattern.
            List<String> javaFiles = hg.files().setPattern("src/main/java/**/*.java").call();

Like hg files, files() operates against the working copy's dirstate when no revision is set — files added but not yet committed are included, while files marked removed (hg rm) but not yet committed are excluded. With a revision set, it lists that commit’s manifest as-is. setPattern(…​) narrows the result by glob pattern, following the same directory-prefix semantics as SparsePathFilter, where a pattern naming a directory matches everything below it.

9.4. Finding files by pattern (locate)

            // Unlike files(), locate() searches the dirstate directly and still reports
            // files marked removed (hg rm'd) but not yet committed.
            List<String> matches = hg.locate().setPattern("*.md").call();

locate() is a distinct, older command from files(). With no revision set, it queries the dirstate directly, so files marked removed (hg rm’d) but not yet committed are still included in the result — this is the key difference from `files(). With no pattern, every candidate path is returned; the default pattern kind matches real hg’s relglob (an unrooted glob matched against the basename portion of a path in any directory). Only , *, and ? are supported; […​] character classes and {a,b} brace expansion are not translated and are treated as literal characters.

9.5. Listing a manifest

            // With no revision set, this lists the working directory's first parent.
            List<ManifestEntry> entries = hg.manifest().setRevision("tip").call();
            for (ManifestEntry entry : entries) {
                System.out.println(entry.getPath() + " " + entry.getNodeHex()
                        + (entry.isExecutable() ? " (executable)" : "")
                        + (entry.isSymlink() ? " (symlink)" : ""));
            }

When no revision is set, manifest() operates against the working directory’s first parent, not tip — for example, after hg update -r 0 checks out an earlier revision, calling it with no revision lists revision 0’s files, not tip’s. The returned ManifestEntry carries the file’s path, the full 40-hex node id of its content, and its executable/symlink flags. setDebug(boolean) merely records which rendering the caller intends; it has no effect on the data call() returns — the full information (node hex included) needed for either rendering is always returned.

9.6. Walking the directory tree

            // With no revision set, tree() defaults to the tip.
            List<TreeEntry> entries = hg.tree().call();
            for (TreeEntry entry : entries) {
                System.out.printf("%06o %s %s%n", entry.getMode(), entry.getNodeId(), entry.getPath());
            }

When no revision is set (or the given one fails to resolve), tree() defaults to tip — note that this differs from manifest(), which defaults to the working directory’s parent instead. TreeEntry#getMode() returns a POSIX-style mode: 0644 for a regular file, 0755 for an executable, and 0120000 for a symlink.

9.7. Searching across all of history (grep)

            // Searches every historical revision of every tracked filelog, not just the
            // working copy or tip.
            List<GrepResult> matches = hg.grep()
                    .setQuery("TODO")
                    .setCaseInsensitive(true)
                    .call();
            for (GrepResult match : matches) {
                System.out.printf("%s:%d [%s] %s%n",
                        match.path, match.lineNumber, match.hexNode.substring(0, 12), match.lineContent);
            }

grep() searches every filelog revision of every tracked file, not just the working copy or a single revision — a line that remains unchanged across several revisions can therefore appear multiple times in the result. setQuery(…​) accepts a plain string as well as a regular expression, matching real hg grep’s own default. `GrepResult’s `path, hexNode, lineNumber, and lineContent are exposed as public final fields rather than getters — access them directly.

9.8. Querying revisions with a revset expression

            List<String> allRevisions = hg.revset().setExpression("all()").call();
            List<String> tipParents = hg.revset().setExpression("parents(tip)").call();

revset() accepts a functional revset expression — such as all() or parents(tip) — and returns the hex node ids of the matching revisions. The supported function set is narrower than real hg’s full revset language, so it’s worth checking what HgRevsetEngine actually supports before relying on a more complex expression.

9.9. Querying and changing phase

            // 0 = public, 1 = draft, 2 = secret.
            int phase = hg.phase().setRevision("tip").call();

Phase is represented as an integer: 0 (public), 1 (draft), 2 (secret). Passing setPhase(…​) turns the call into a phase change rather than a plain query.

            // Moving towards public (a lower number) is unconditional; moving towards
            // secret (a higher number) requires setForce(true), like real hg's --force gate.
            int newPhase = hg.phase().setRevision("tip").setPhase(0).call();

Moving a revision to a lower (more public) phase is always allowed and is applied to all of its ancestors as well — a child can never be less public than its parent. Moving it to a higher (more secret) phase, conversely, is applied to all of its descendants, and — just like real hg’s hg phase --force — is rejected with an IOException unless setForce(true) is given.

10. Copying, Renaming & Cleanup

This chapter covers the commands that work on files in the working copy (copy(), rename(), addremove(), forget()) and the commands that clean up the repository itself (purge(), gc()).

10.1. Copying a file

            // Duplicates a tracked file: the source is left untouched and still tracked, and the
            // destination is added as a new file with copy-source metadata recorded in the
            // dirstate, so `hg log --follow`/`hg annotate` on the destination follows through to
            // the source's own history.
            hg.copy().setSource("src/main/resources/app.properties")
                    .setDestination("src/main/resources/app-staging.properties")
                    .call();

            // Refused by default if the destination already exists on disk; setForce(true)
            // overwrites it, mirroring `hg copy --force`.
            hg.copy().setSource("src/main/resources/app.properties")
                    .setDestination("src/main/resources/app-staging.properties")
                    .setForce(true)
                    .call();

copy() leaves the source file untouched and adds a new file at the destination path, recording a "destination → source" relationship in the dirstate’s copyMap — this metadata is what lets hg log --follow and hg annotate on the destination follow through to the source’s own history. It is refused by default when the destination path already has a file, and can only be overwritten with setForce(true).

10.2. Renaming a file

            // Moves a tracked file on disk, marks the old path removed, and records the new path
            // as a copy-of-the-old-path in the dirstate copyMap -- equivalent to plain `hg rename`
            // (`hg mv`), which is itself just `hg copy` + `hg remove` in one step.
            hg.rename().setSource("src/main/java/App.java")
                    .setTarget("src/main/java/Application.java")
                    .call();

rename() is effectively copy() + remove() performed together — it physically moves the file to the new path, marks the original path as removed, and still records "new path → original path" in the copyMap.

10.3. Adding and removing in one pass (addremove)

            // Scans the working copy in one pass: every untracked file is added, and every
            // tracked file that is missing on disk is marked removed. Returns status-style lines
            // ("A path" / "R path") describing exactly what changed.
            List<String> affected = hg.addremove().call();
            for (String line : affected) {
                System.out.println(line);
            }

addremove() scans the working copy once, adding every untracked file and marking every tracked file that has disappeared from disk as removed. The return value is a list of status-style strings such as "A path/to/new.txt" / "R path/to/gone.txt", so you can see exactly what changed.

10.4. Untracking a file (forget)

            // Stops tracking a file WITHOUT touching it on disk -- the opposite of remove(), which
            // both untracks and deletes. A file that was never committed is simply dropped from
            // the dirstate; an already-committed file is instead recorded as removed at the next
            // commit, while the working copy keeps its content untouched.
            hg.forget().setFile("secrets/local-only.env").call();

forget() is the opposite of remove(): it untracks a file without touching it on disk at all. A file that has never been committed is simply dropped from the dirstate; an already-committed file is instead just flagged to be recorded as removed at the next commit, while the working copy’s content is left exactly as it is.

10.5. Cleaning up untracked files (purge)

            // Deletes every untracked, non-ignored file plus the empty directories left behind --
            // matching plain `hg purge`'s own default scope. Ignored files (matched by
            // .hgignore) are NEVER touched, and a declared subrepo's own working copy is treated
            // as an opaque boundary and never walked into.
            hg.purge().call();

            // setPurgeDirectories(false) restricts the sweep to files only, leaving any leftover
            // empty untracked directories in place.
            hg.purge().setPurgeDirectories(false).call();

purge() actually deletes files from disk, so it’s worth knowing its exact scope before using it. Its default matches real hg purge: it deletes files that are untracked and not matched by .hgignore, plus any directories that end up empty as a result. Conversely:

  • Ignored files (matched by .hgignore) are never touched.

  • Any path declared as a subrepo is skipped entirely, so a subrepo’s own working copy is never destroyed.

  • A symlink is never followed into whatever it points at, even a directory — only the link itself is ever a deletion candidate (a broken symlink whose target is missing is also a deletion candidate).

setPurgeDirectories(false) restricts the sweep to files only, leaving any empty directories behind. Note that, like the real --dirs/--files flags, this only ever restricts what gets deleted — it never adds to it.

10.6. Compacting the repository store (gc)

            // Compacts and re-deltas every revlog in the store, rebuilds fncache, and removes
            // orphaned temp/backup files -- a housekeeping pass with no equivalent single stock
            // `hg` command, closer to `hg debugupgraderepo`/manual store maintenance in spirit.
            String summary = hg.gc().call();
            System.out.println(summary);

gc() recompresses and re-deltas every revlog in the store, rebuilds fncache, and cleans up leftover temp/backup files. It doesn’t map onto a single standard hg command exactly — if anything, it’s closer in spirit to hg debugupgraderepo or manual store maintenance. The return value is a summary string reporting how many revlogs were actually rewritten and how many temporary files were deleted.

11. Work in Progress: Shelving & Worktrees

This chapter covers shelve(), which sets aside work you don’t want to commit yet; worktree(), which creates another working copy sharing the same repository; and resolve(), which clears conflicts left behind by a merge, graft, rebase, or backout.

11.1. Setting changes aside (shelve)

            // Saves every modified/added/removed working-copy file under the given name, then
            // reverts the working copy back to its parent commit -- a clean slate to switch tasks
            // on. A plain call() with no setName() shelves under the name "default".
            hg.shelve().setName("wip-feature").call();

shelve() saves the working copy’s currently modified/added/removed files under a given name, then reverts the working copy back to its parent commit. This lets you clear your workspace without committing when you need to switch to something else urgently. Omitting setName() leaves the name at its default, "default".

11.2. Restoring shelved changes (unshelve)

            try {
                // Restores a previously shelved set of changes as pending, uncommitted edits.
                // Internally this replays the shelve as a throwaway commit and rebases it onto
                // whatever the working copy's parent has become in the meantime (a no-op if
                // nothing has landed there since the shelve was taken); every trace of that
                // throwaway commit is erased once the restore succeeds.
                hg.shelve().setName("wip-feature").setUnshelve(true).call();
            } catch (HgMergeConflictException e) {
                // Paused exactly like `hg rebase` on a real conflict: resolve the reported files
                // (see resolveConflicts() below), then continue or abandon the attempt. A fresh
                // ShelveCommand instance is fine for either call -- both are driven purely by
                // persisted on-disk state, not by anything held in this object.
                hg.shelve().unshelveContinue();
                // Or, to give up and restore the pre-unshelve state while keeping the shelve
                // itself intact for a later attempt:
                // hg.shelve().unshelveAbort();
            }

Passing setUnshelve(true) does the reverse. Internally, the shelved changes are replayed as a throwaway commit on top of the parent they were originally shelved from; if the working copy’s parent has since changed (because something else was committed on top of it), that throwaway commit is rebased onto the new parent — a no-op if nothing happened in between — and the result is finally released back onto the working copy as pending, uncommitted changes. The throwaway commit created along the way is erased without a trace the moment this succeeds.

If the rebase step hits a conflict, it pauses exactly the way a real hg rebase would, and an HgMergeConflictException is thrown. Once you’ve resolved the conflicting files (see "Resolving conflicts" below), call unshelveContinue() to proceed, or unshelveAbort() to give up on this attempt while leaving the shelve itself intact for a later attempt. Both methods rely purely on state persisted to disk rather than anything held by this instance, so it’s fine to call them on a freshly obtained ShelveCommand.

11.3. Creating a separate worktree

            // Creates a second working copy backed by the SAME shared store as this repository
            // (Mercurial's `hg share`, exposed here under a more Git-familiar name) -- commits
            // made from either working copy are immediately visible to the other, since there is
            // only one store underneath. The target directory must be empty or not yet exist.
            HgRepository worktreeRepo = hg.worktree()
                    .setNewWorktreeDir(new File("/path/to/repo-worktree-hotfix"))
                    .call();

worktree() is just a more Git-familiar name for what is really Mercurial’s hg share — rather than creating an entirely separate repository, it creates a new working copy that shares the same store. A commit made from either working copy is immediately visible from the other. The return value is an HgRepository pointing at the new working copy, and the target directory must be empty or not yet exist.

11.4. Resolving conflicts

            // After a merge()/graft()/rebase()/backout() leaves conflict markers on disk (see
            // the error-handling and merging chapters), list() reports every file's resolution
            // state without changing anything.
            Map<String, Boolean> states = hg.resolve().list(true).call();
            for (Map.Entry<String, Boolean> entry : states.entrySet()) {
                if (!entry.getValue()) {
                    System.out.println("Still unresolved: " + entry.getKey());
                }
            }

            // Once a conflicting file's content has been hand-edited (or overwritten by some
            // other tool) to remove its <<<<<<< / ======= / >>>>>>> markers, mark it resolved so
            // the merge/rebase/graft can be committed or continued.
            hg.resolve().setFile("src/main/java/Config.java").markResolved(true).call();

resolve() inspects and updates the conflict state left behind by merge() (see Branches, Bookmarks, Merging & Rebasing) or by graft()/rebase()/backout() throwing HgMergeConflictException (see Error Handling). All of these commands share the same .hg/merge/state2 file, so resolve() can inspect or clear a conflict left by any of them. list(true) returns the resolution state of every file involved in the current merge, and setFile(path).markResolved(true) marks a single file — whose <<<<<<< / ======= / >>>>>>> markers have been cleaned up by hand (or by some other tool) — as resolved. An exception is thrown if no merge is in progress, or if the given file isn’t part of the current merge.

12. Rewriting History

The commands in this chapter each "rewrite" in a fundamentally different way. amend() and histedit() follow the same approach as chapter 04’s rebase(): the original revisions are never physically removed, only replaced via an obsolescence marker. graft(), by contrast, never touches the original at all — it just copies its changes into a brand-new commit. backout() doesn’t touch history either; it only adds a new "undo" commit on top. treeMerge()/mergeCommit() are different again — a pure computation/write pair that never goes near the working directory. Each section below spells out exactly what that difference means in practice.

12.1. Amending a commit

            // Replaces the tip commit with a new one on the SAME parent(s) (a sibling, not a
            // child) -- anything not explicitly overridden here (author, close-branch state)
            // is copied from the amended-away commit itself.
            byte[] amendedNode = hg.amend().setMessage("Fixed typo in commit message").call();

amend() creates a new commit that replaces the tip commit — the new commit is created as a sibling that shares the same parent(s) as the original, not as its child (matching real hg commit --amend exactly). Anything not overridden via setAuthor()/setMessage()/ setCloseBranch() is copied from the original commit’s own values. Once done, an obsolescence marker linking the original commit to the new one is recorded in .hg/store/obsstore, so the original only remains visible via hg log --hidden.

12.2. Editing history (histedit)

            // Rules are replayed in order, OLDEST revision first -- like an interactive rebase
            // "todo list". Here two adjacent commits are squashed into one (their file changes
            // are combined and their commit messages concatenated), and the newest commit is
            // recommitted as-is right after.
            String oldestOfRange = "aaaa1111aaaa1111aaaa1111aaaa1111aaaa1111";
            String middleCommit = "bbbb2222bbbb2222bbbb2222bbbb2222bbbb2222";
            String newestCommit = "cccc3333cccc3333cccc3333cccc3333cccc3333";

            hg.histedit()
                    .addRule(HisteditCommand.Action.PICK, oldestOfRange)
                    .addRule(HisteditCommand.Action.FOLD, middleCommit)
                    .addRule(HisteditCommand.Action.PICK, newestCommit)
                    .call();

histedit() attaches a rule (Rule) to each individual revision and replays them in order. The rules accumulated via addRule(Action, hexNode) must be listed oldest revision first (the same concept as an interactive rebase’s "todo list"), and the rewritten range always starts from the parent of whatever revision the first rule points to.

Each Action means:

  • PICK — recommit the revision as-is (the default behavior).

  • FOLD — merge this revision’s changes and commit message into the previous PICK/FOLD/ROLL group (messages are concatenated).

  • ROLL — same as FOLD, but discards the commit message.

  • DROP — discard the revision entirely. It is not physically stripped; instead a prune obsolescence marker is recorded so it disappears from a plain hg log.

Any files a `DROP`ped revision had added also disappear from the working directory once histedit finishes, and the dirstate is reconciled against the new tip — exactly like real `hg histedit’s implicit checkout of the new tip once it finishes.

12.3. Grafting a commit (cherry-pick)

            // Copies revision "42"'s changes onto the working copy's current parent, as a
            // brand-new commit. Unlike amend()/histedit(), graft() writes no obsolescence
            // marker -- the source revision stays fully visible in a plain `hg log` afterward.
            String graftedHex = hg.graft().setSource("42").call();
            System.out.println("Grafted as: " + graftedHex);

graft() corresponds to Git’s cherry-pick: it copies a given source revision’s changes onto the working copy’s current parent as a new commit. Unlike amend()/histedit(), it writes no obsolescence marker at all — matching real hg graft, a graft is a copy, not a rewrite, so the source revision stays fully visible in a plain hg log after grafting.

If the destination and the source have both changed the same file differently, a real 3-way merge is performed, and HgMergeConflictException is thrown if genuine conflicts remain unresolved — see Error Handling for how to recover. Unlike rebase, graft has its own pause/resume state.

            // After resolving every conflicted path reported by the HgMergeConflictException
            // (see the error-handling chapter) and re-staging the resolved content on disk,
            // finish the paused commit:
            String graftedHex = hg.graft().continueGraft();
            System.out.println("Grafted as: " + graftedHex);

            // Or, to give up on the paused graft entirely and restore the pre-graft working copy:
            // hg.graft().abort();

After resolving the conflicted files (by hand, or via ResolveCommand) and re-staging the resolution on disk, call continueGraft() to finish the paused commit, or abort() to give up on the graft attempt entirely and restore the working copy to its pre-graft state. This pause state is hg4j’s own private format — it does not interoperate with real hg graft --continue/--abort at the mid-flight state level, though the resulting commit and conflict marker format are themselves compatible with real hg.

12.4. Backing out a commit

            // Creates a brand-new commit that reverses revision 17's changes -- history itself
            // is untouched (no obsolescence marker, no strip). Backing out the working copy's
            // own parent (the common case) can never conflict; backing out an older ancestor
            // performs a genuine 3-way merge and can throw HgMergeConflictException just like
            // graft/rebase (see the error-handling chapter) -- but unlike those commands there
            // is no backout --continue: resolve the conflicted files by hand, then just commit
            // normally.
            byte[] backoutNode = hg.backout().setRevision("17").setMessage("Revert regression from rev 17").call();

backout() only adds a new commit that undoes a given revision’s changes — history itself is never touched (no obsolescence marker, no strip). Merge changesets are not supported as a target (specifying one throws), and the result always has a single parent, preserving a linear history.

There are two cases:

  • The target revision is the working copy’s current parent (the common case) — since the working copy already exactly matches that revision, the diff between it and its own parent can simply be applied directly, and this can never conflict.

  • The target revision is an older ancestor — a genuine 3-way merge is performed, with the ancestor being the target revision itself, "local" the current working copy, and "other" the target revision’s own parent. This preserves any unrelated changes made after the target revision while undoing only that revision’s own effect.

In the second case, a genuine conflict throws HgMergeConflictException and no commit is created (see Error Handling). Unlike graft()/rebase(), however, there is no "continue" method for backout — once the conflicted files are resolved by hand, you simply call commit() normally instead of calling backout() again.

12.5. Computing and recording a merge without a working copy (treeMerge, mergeCommit)

            List<String> headHexes = hg.heads().call();
            byte[] ours = NodeId.fromHex(headHexes.get(0)).getBytes();
            byte[] theirs = NodeId.fromHex(headHexes.get(1)).getBytes();

            // Computes the merge purely from the changelog/manifest/filelog store -- the
            // working directory and dirstate are never touched.
            TreeMergeCommand.TreeMergeResult result = hg.treeMerge().setOurs(ours).setTheirs(theirs).call();

            if (result.isConflicted()) {
                for (String conflictedPath : result.getConflicts()) {
                    System.out.println("Would conflict: " + conflictedPath);
                }
            } else {
                // Writes the result directly as a new two-parent changeset -- again without
                // ever checking anything out. Bookmarks are not moved automatically; use
                // bookmark() afterward if the merge should advance one.
                byte[] mergeCommitNode = hg.mergeCommit()
                        .setParents(ours, theirs)
                        .setTreeMergeResult(result)
                        .setMessage("Merge (server-side, no checkout)")
                        .call();
            }

merge() (chapter 04) is the porcelain command that actually updates the working directory, and reports a conflict via MergeResult.isConflicted() rather than an exception. treeMerge() is the opposite: it never touches the working directory or dirstate at all, computing the merge result purely as data (TreeMergeResult) from the changelog/manifest/ filelog store — files that must change or be added (getChangedFiles()), files that must be removed (getRemovedFiles()), and whether anything conflicted (isConflicted()/ getConflicts()) — without writing anything to disk.

mergeCommit() then writes that computed result (TreeMergeResult) directly to the changelog/manifest/filelog as a real two-parent changeset — again without touching the working directory. A conflicted TreeMergeResult is rejected outright, so only a result whose isConflicted() is false may be passed in. Bookmarks are never moved automatically; call bookmark() yourself against the returned node if one should advance.

This combination is far cheaper than cloning the whole repository into a scratch directory, checking it out, running merge() + commit(), and pushing the single resulting changeset back — cost here scales only with the files the merge actually touches. It’s well suited to server-side merges, or previewing a PR’s merge result without ever checking anything out.

13. Discarding Changes: Strip, Rollback & Censor

All three commands in this chapter are either irreversible, or reversible only within a very narrow window. Use them with real caution. strip() in particular is worth contrasting sharply with chapter 12’s rebase()/histedit(): those record only an obsolescence marker, leaving the original revisions still visible via hg log --hidden, whereas strip() physically removes revisions from the revlogs.

13.1. Stripping revisions outright

            // Physically truncates revision 23 and every descendant out of the changelog/
            // manifest/filelog revlogs. Unlike rebase()/histedit() (which only ever record an
            // obsolescence marker, leaving the originals visible via `hg log --hidden`), a
            // stripped revision is gone from the store entirely and cannot be recovered through
            // this library. A successful strip also invalidates any pending rollback() -- see
            // below.
            hg.strip().setRevision("23").call();

strip() physically truncates the given revision — and every descendant of it — out of the changelog/manifest/filelog revlogs. Unlike the obsolescence-marker-based rewriting from chapter 12, a stripped revision can no longer be recovered via hg log --hidden either — it is simply gone from the store. A successful strip also erases the undo information that rollback() (covered below) relies on — if you commit twice and then strip the tip, the undo information for that second commit disappears along with it.

A bookmark that pointed at a stripped revision is not deleted; it follows the stripped revision back to the nearest surviving ancestor (or to the null revision, if everything was stripped) — matching real `hg strip’s own behavior exactly.

13.2. Rolling back the last transaction

            // A plain method, NOT a builder -- there is no rollback().call(). Undoes only the
            // MOST RECENT commit or pull, using the undo.* files that transaction left behind;
            // a second commit (or a strip()) overwrites/erases that undo information, after
            // which rollback() throws IllegalStateException ("no rollback information
            // available") instead of reaching further back.
            hg.rollback();
Unlike the other commands here, rollback() is not a Hg.rollback().call()-style builder — it’s a plain void Hg.rollback() throws IOException method you call directly.

rollback() can only undo the single most recent transaction — a commit or a pull. It works by restoring the store/dirstate/bookmarks from the .hg/store/undo* files that CommitCommand/FetchCommand leave behind right after succeeding, so committing once more overwrites that undo information and makes it unreachable. Calling it when no undo information exists at all (no prior commit, or after an operation like strip() above that erases it) throws IllegalStateException — note that this is an unchecked exception not declared in the throws clause, so it doesn’t appear in Error Handling's checked-exception table.

13.3. Censoring a file revision

            // censor() targets one exact FILE revision, identified by its own filelog node hex
            // -- not the changeset's commit hash. Resolve it via manifest() at the offending
            // changeset first.
            List<ManifestCommand.ManifestEntry> entries = hg.manifest().setRevision("55").call();
            String fileNodeHex = entries.stream()
                    .filter(e -> e.getPath().equals("config/secrets.properties"))
                    .findFirst()
                    .orElseThrow()
                    .getNodeHex();

            // Scrubs just that one revision's content (replacing it with a tombstone) while
            // keeping its node identity, parents, and linkrev intact -- history shape is
            // unaffected, and other revisions of the same file are untouched. Refuses (unless
            // setCheckHeads(false)) if this exact content is still live at a head or a
            // working-directory parent.
            hg.censor()
                    .setFile("config/secrets.properties")
                    .setRevision(fileNodeHex)
                    .setTombstone("removed leaked credentials")
                    .call();

censor() corresponds to real hg censor (used to permanently scrub content such as an accidentally committed secret). Importantly, it censors exactly one file revision you specify — not that file’s entire history. The value passed to setRevision() is not the changeset’s commit hash; it’s the 40-character hex node ID of that specific filelog revision (obtainable, as in the example above, by looking up the file’s node hex at a given changeset via the manifest() command). Earlier and later revisions of the same path are left completely untouched, so if the same secret is spread across several commits, censor() needs to be called once per affected revision.

The censored content is replaced with a tombstone, but the revision’s node ID, parents, and linkrev are all preserved — history/DAG shape is unaffected. By default (setCheckHeads(true), the default), censoring is refused if that exact content is still live at any head or at a working-directory parent — clean/delete and commit, or update away first, or pass setCheckHeads(false) to bypass the guard entirely (matching real hg’s --no-check-heads).

14. Bisecting to Find a Regression

bisect() (hg.bisect()) is hg4j’s counterpart to hg bisect: it binary-searches between a revision known to be "good" and one known to be "bad" to pinpoint the exact revision that first introduced a bug.

The real hg bisect CLI is a stateful command that persists its progress in .hg/bisect.state, but hg4j’s BisectCommand keeps no state on disk at all. Every call to next() computes the next candidate fresh from whatever good/bad nodes you pass it at that moment, so remembering and updating the good/bad range across iterations is entirely the caller’s responsibility.

14.1. Getting the first candidate

            // A known-good revision (here, simply the oldest commit -- in practice this would
            // usually be a known-good tag or release) and a known-bad one (the current tip,
            // where the regression is visible).
            List<HgCommit> history = hg.log().call(); // newest first
            byte[] goodNode = history.get(history.size() - 1).getNodeId().getBytes();
            byte[] badNode = history.get(0).getNodeId().getBytes();

            // next() picks the revision that best splits the good..bad range in half, AND
            // checks it out into the working directory -- there is no separate update() call
            // needed before building/testing it.
            byte[] candidate = hg.bisect()
                    .setGood(goodNode)
                    .setBad(badNode)
                    .next();
            System.out.println("Now build/test the working copy, then mark it good or bad.");

setGood(byte[]) and setBad(byte[]) both take a raw 20-byte node ID — not a revision number or a tag name. Calling next() without setting both throws IllegalStateException, and passing a node that doesn’t exist throws IOException.

next() not only picks the revision that best splits the good..bad range in half, it also checks that revision out into the working directory immediately — it updates the real files, moves the dirstate parent, and even switches the working branch to match the candidate. So there’s no need to call update() separately after getting a candidate; you can build/test it right away.

14.2. Narrowing down to the culprit

            List<HgCommit> history = hg.log().call();
            HgCommit goodCommit = history.get(history.size() - 1);
            HgCommit badCommit = history.get(0);

            byte[] good = goodCommit.getNodeId().getBytes();
            byte[] bad = badCommit.getNodeId().getBytes();
            int goodRev = goodCommit.getRevision();
            int badRev = badCommit.getRevision();

            // BisectCommand keeps no state of its own on disk (unlike real hg's
            // ".hg/bisect.state") -- every next() call is a fresh good/bad split computed from
            // whatever nodes you pass it, so the CALLER tracks the current good/bad range across
            // iterations, exactly as this loop does. Bisection is done once good and bad become
            // adjacent revisions: at that point "bad" itself is the culprit.
            while (badRev - goodRev > 1) {
                byte[] candidate = hg.bisect().setGood(good).setBad(bad).next();
                int candidateRev = findRevisionNumber(history, candidate);

                boolean isGood = testCurrentCheckout(); // e.g. run the project's test suite
                if (isGood) {
                    good = candidate;
                    goodRev = candidateRev;
                } else {
                    bad = candidate;
                    badRev = candidateRev;
                }
            }
            System.out.println("First bad revision: " + badRev);

At each step, test the candidate you just received, update either good or bad to that candidate node depending on the result, and call next() again. Repeat until good and bad become adjacent revisions (badRev - goodRev == 1) — at that point, whatever bad currently points to is the revision that first introduced the regression.

The candidate-selection algorithm doesn’t simply pick the midpoint index of the revision number range — like real hg’s own mercurial/hbisect.py, it picks whichever candidate best splits the DAG in half by ancestor count. That distinction actually matters on a branching history that includes merge commits.

To start a fresh bisection (real hg’s hg bisect --reset equivalent), just build a new hg.bisect() instance and call setGood()/setBad() from scratch — there is no persisted state to clear in the first place.

15. Patches, Bundles & Comparing Remotes

Besides push()/pull(), there are two more ways to move revisions elsewhere: human-readable text patch files (export()/importPatch(), similar to git format-patch/git am), and binary changegroup bundles (bundle()/unbundle(), a completely different format). Despite the similar-sounding names, these two are different file formats for different purposes and are not interchangeable. This chapter also covers incoming()/outgoing(), which preview what a pull/push would bring in or send out without actually pulling or pushing, and archive(), which extracts a snapshot with no version-control metadata at all.

15.1. Exporting/applying patch files

            // Produces a classic (non---git) unified-diff patch with "# HG changeset patch"
            // headers, matching hg export's default output format -- a plain text FILE you could
            // email or store next to the repo, not a binary changegroup (see bundle() below for
            // that).
            String patchText = hg.export().setRevision("tip").call();
            Files.writeString(Path.of("/tmp/my-change.patch"), patchText);

export() produces a text patch: a # HG changeset patch header block followed by a unified-diff body, matching hg export’s default (non---git`) output format exactly. It’s a good shape to email or store alongside the repository.

            // Applies a unified-diff patch (as produced by export()/hg export/hg diff) to the
            // working directory and commits it on top of the CURRENT dirstate parent -- like real
            // "hg import" without --exact, the patch's own "# Node ID"/"# Parent" headers are
            // read for author/date only, never used to pick where the patch is applied.
            String patchText = Files.readString(Path.of("/tmp/my-change.patch"));
            hg.importPatch().setPatchText(patchText).call();

importPatch() applies this patch to the working directory and commits it on top of the current dirstate parent. Just like real hg import without --exact, the patch’s own # Node ID/# Parent headers are ignored for placement purposes — they’re only read for author/date information, never used to decide where the patch gets applied. That means applying a patch while checked out on a different parent than the one it was created from still just commits it right there.

15.2. Creating/applying bundle files

            // Equivalent of "hg bundle --all out.hg": writes every changeset as a binary
            // changegroup FILE -- a different format from export()'s patch text, and the base
            // revision is required (pass the "null" sentinel, matching hg's own --base null/-a,
            // to mean "no known ancestor, bundle everything").
            File outputFile = new File("/tmp/full-repo.hg");
            int changesetCount = hg.bundle()
                    .setOutputFile(outputFile)
                    .setBaseRevision("null")
                    .call();
            System.out.println("Bundled " + changesetCount + " changesets");

bundle() writes the same changegroup bytes push() would send over the wire to a local binary file instead — a completely different artifact from export()’s text patch. `setOutputFile() and setBaseRevision() are both required: just like real hg bundle refuses to guess how much the other side already has without a destination or an explicit --base/-a, hg4j never infers a base implicitly either. To bundle the entire repository, follow hg’s own --base null/--all convention and pass the literal string "null" as the base revision.

            // Equivalent of "hg bundle --base 0 out.hg": excludes revision 0 and all of its
            // ancestors from the bundle, useful for shipping only the delta to a peer already
            // known to have everything up to that point. NOTE: setBaseRevision()/setRevision()
            // resolve only a revision number, a hex node prefix, or "tip" -- NOT a tag or
            // bookmark name, so a tag has to be resolved to its target revision first.
            File outputFile = new File("/tmp/incremental.hg");
            int changesetCount = hg.bundle()
                    .setOutputFile(outputFile)
                    .setBaseRevision("0")
                    .setType(BundleCommand.BundleType.GZIP_V1)
                    .call();
            System.out.println("Bundled " + changesetCount + " changesets since revision 0");

Passing an actual revision to setBaseRevision() excludes that revision and all of its ancestors from the bundle, leaving only its descendants — useful for shipping just the delta to a peer already known to have everything up to that point. BundleType selects the container/compression format (none-v1/gzip-v1/bzip2-v1, or the -v3 family required for a treemanifest repository).

setRevision()/setBaseRevision() resolve only a revision number, a hex node prefix, or "tip" — a tag or bookmark name is not resolved on its own, so you have to look up the revision the tag points to first and pass that instead.
            // Applies a local bundle FILE (from bundle(), or received out of band) to this
            // repository -- decodes the HG10UN/HG10GZ/HG10BZ and bundle2/HG20 container formats
            // automatically.
            List<byte[]> importedNodeIds = hg.unbundle()
                    .setBundleFile(new File("/tmp/full-repo.hg"))
                    .call();
            System.out.println("Applied " + importedNodeIds.size() + " changesets from the bundle");

unbundle() applies a bundle file (produced by bundle(), or obtained some other way) to the current repository, auto-detecting the HG10UN/HG10GZ/HG10BZ and bundle2 (HG20) container formats.

15.3. Downloading a pre-built bundle via clonebundle

            // NOTE: unlike every other command in this manual, this is Hg.clonebundle(url)
            // called directly -- not a *Command obtained via a no-arg factory method and a
            // separate call(). It downloads the bundle at "url" with a plain HTTP(S) GET and
            // applies it in one step.
            List<byte[]> importedNodeIds = hg.clonebundle("https://example.com/hg/bundles/full.hg");

            // Real hg's own clonebundles mechanism still expects a normal pull afterward, to
            // catch up on anything committed to the origin since the bundle was generated.
            hg.pull().setSource("https://example.com/hg/some-project").call();

Unlike every other command in this chapter, Hg.clonebundle(url) is not a command object obtained via a no-arg factory method plus a separate call() — the factory method itself performs the download and apply. It fetches the bundle at url with a plain HTTP(S) GET and applies it in one step. Matching real hg’s own clonebundles mechanism, you should still follow up with a normal pull() afterward to catch up on anything committed to the origin since the bundle was generated.

15.4. Previewing a remote’s differences: incoming/outgoing

            // Equivalent of "hg incoming": contacts the remote and reports changesets it has
            // that this repository does not, WITHOUT pulling them. Returns pre-formatted display
            // lines (a single "no incoming changes found" line when there is nothing new), not
            // structured changeset objects.
            List<String> incomingLines = hg.incoming()
                    .setSource("https://example.com/hg/some-project")
                    .call();
            incomingLines.forEach(System.out::println);

incoming() contacts the remote and reports revisions it has that this repository doesn’t, without actually pulling them.

            // Equivalent of "hg outgoing": reports local changesets the remote does not have yet,
            // WITHOUT pushing them.
            List<String> outgoingLines = hg.outgoing()
                    .setDestination("https://example.com/hg/some-project")
                    .call();
            outgoingLines.forEach(System.out::println);

outgoing() does the reverse: it reports local revisions the remote doesn’t have yet, without actually pushing them. Both return a List<String> of pre-formatted display lines — similar in shape to hg incoming/`hg outgoing’s human-readable output — rather than structured commit objects, and a list containing just a single informational line when there’s nothing new.

15.5. Extracting a revision snapshot

            // Writes an unversioned snapshot of "tip" (no .hg metadata, just a
            // .hg_archival.txt provenance file) -- the archive type is auto-detected from the
            // destination's extension here (.tar.gz -> gzip-compressed tar).
            hg.archive()
                    .setRevision("tip")
                    .setDestination(new File("/tmp/release-snapshot.tar.gz"))
                    .call();

archive() produces a plain snapshot of a given revision with no .hg metadata at all — the archive type is auto-detected from the destination’s extension (directory/zip/uzip/tar/ tgz/tbz2), matching what hg help archive documents, and every output carries a .hg_archival.txt file recording the repository root, revision, and branch.

16. Repository Maintenance & Advanced Configuration

The commands in this chapter are less about everyday workflow and more about keeping a repository healthy, reading configuration, and handling more specialized scenarios like subrepositories or narrow clones.

16.1. Checking integrity

            List<String> errors = hg.verify().call();
            if (errors.isEmpty()) {
                System.out.println("repository is healthy");
            } else {
                errors.forEach(System.out::println);
            }

Instead of throwing, verify() returns a list of the problems it found — an empty list means the repository is healthy. It recomputes and compares node id hashes across the changelog, the manifest (including any treemanifest submanifests under meta/), and every filelog. A freshly created repository (or one that hasn’t been pulled into yet) legitimately has an empty changelog, so that case is never reported as an error.

16.2. Recovering an interrupted transaction

            // setVerify(true) additionally runs VerifyCommand, but only when a journal was
            // actually found and successfully rolled back.
            RecoverCommand.RecoverResult result = hg.recover().setVerify(true).call();
            if (!result.wasInterrupted()) {
                // Nothing to do -- matches real hg's "no interrupted transaction available".
                System.out.println("nothing to recover");
            } else if (result.isSuccess()) {
                System.out.println("rolled back an interrupted transaction");
                List<String> verifyErrors = result.getVerifyErrors();
                if (verifyErrors != null) {
                    verifyErrors.forEach(System.out::println);
                }
            } else {
                // The journal is left on disk for a future retry.
                System.out.println("rollback did not complete, journal retained");
            }

recover() looks for a leftover .hg/store/journal from a crashed or killed command and rolls it back. The outcome comes back as a RecoverCommand.RecoverResult, and you need to check both wasInterrupted() (was there anything to recover in the first place) and isSuccess() (did the rollback actually complete) separately — there being nothing to roll back is itself a normal outcome, not a failure. Setting setVerify(true) additionally runs verify(), but only once the rollback has actually succeeded, and its result is exposed via getVerifyErrors().

16.3. Reading configuration

            // config() merges system hgrc, user hgrc, and .hg/hgrc, in that priority order.
            HgRcConfig cfg = hg.config();
            String username = cfg.getUsername();
            String defaultRemote = cfg.getPath("default");

            // Enumerate every configured remote, in the order they were parsed.
            Map<String, String> paths = cfg.getSection("paths");
            paths.forEach((name, url) -> System.out.println(name + " -> " + url));

The HgRcConfig returned by config() merges the system-wide (/etc/mercurial/hgrc), user-wide (~/.hgrc or ~/mercurial.ini), and repository-local (.hg/hgrc) configuration files, in that priority order. There are helper methods for common lookups like getUsername()/getPath(name), and getSection(section) retrieves an entire section (such as [paths]) in its original parse order.

16.4. Working with subrepositories

            // Registers a new entry in .hgsub/.hgsubstate, pinned to a specific revision.
            hg.subrepo()
                    .setAction("add")
                    .setSubrepoPath("libs/vendor")
                    .setSubrepoUrl("https://example.com/hg/vendor-lib")
                    .setRevision("a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2")
                    .call();

            // Clones/updates every subrepo listed in .hgsub to its .hgsubstate-pinned revision --
            // safe to call again later to bring existing subrepo checkouts back in line after
            // .hgsubstate changes (e.g. a pull that advanced the pinned revision).
            hg.subrepo().setAction("update").call();

subrepo()’s `setAction(…​) accepts three values: "add" (registers a new entry in .hgsub/.hgsubstate), "init", and "update" (both check out every configured subrepo to its pinned revision). "add" only updates those two files — it does not actually clone anything — so a subsequent "update" call is what actually clones the subrepo and checks it out at the pinned revision. Calling "update" again later on an existing checkout is safe even after .hgsubstate has moved on to a different pinned revision: it pulls first if needed, then forces the working copy to the newly pinned revision.

16.5. Narrow clones

        // Hg.narrowClone() is static, like Hg.init()/Hg.cloneRepository() -- it creates a new
        // repository, so there is no existing Hg instance to call it on.
        try (Hg hg = Hg.narrowClone()
                .setSource("https://example.com/hg/monorepo")
                .setDirectory(new File("/path/to/narrow-clone"))
                .addIncludePath("services/billing")
                .addExcludePath("services/billing/vendor")
                .call()) {
            HgRepository repository = hg.getRepository();
            // ... the working copy only contains services/billing, minus its vendor/ subtree ...
            repository.getDirectory();
        }

Hg.narrowClone() is a static method, just like Hg.init()/Hg.cloneRepository() — it creates a repository that doesn’t exist yet, so there’s no existing Hg instance to call it on. It restricts the metadata tracked by the clone itself to the given include/exclude path patterns, and, matching real hg’s own narrow extension, records a narrowhg-experimental requirement in .hg/requires.

16.6. Resolving the sparse profile

            // Resolves .hg/sparse (including any %include-referenced profile files, read from
            // the given changelog revision's manifest) into the effective include/exclude rules.
            SparseConfig sparse = hg.sparseConfig(0);
            System.out.println("includes: " + sparse.includes);
            System.out.println("excludes: " + sparse.excludes);

            // Turns the resolved rules into a reusable PathFilter, e.g. to restrict a
            // walkWorkingDir()/walkTree() traversal to the active sparse profile.
            boolean included = sparse.toPathFilter().accept("services/billing/pom.xml");

Unlike the other commands in this chapter, sparseConfig(int changelogRev) is not a Command builder — it’s a direct method on Hg that returns a SparseConfig immediately. .hg/sparse itself is an untracked, plain working-copy file read straight off disk, but a profile referenced via %include is a tracked file, resolved from the manifest of the given changelog revision — so switching revisions can change which sparse rules apply. The returned SparseConfig’s `toPathFilter() turns these rules into a PathFilter, which composes directly with walkWorkingDir()/walkTree() from Walking Trees Directly (Advanced) to restrict a traversal to the active sparse profile.

17. Walking Trees Directly (Advanced)

If you’ve used JGit’s TreeWalk, this chapter will feel familiar — it’s the same public API pattern JGit users reach for when building custom analysis or tooling, and hg4j exposes it too. Hg’s `walkManifest(String)/walkWorkingDir()/walkTree() methods drive the very same tree traversal engine that most porcelain commands (StatusCommand, DiffCommand, CommitCommand, UpdateCommand, MergeCommand, and others) already use internally. Everyday work is well served by the higher-level commands covered earlier in this manual, but comparing manifests across revisions directly, or building your own analysis tooling, is where this API comes in.

17.1. Walking the manifest of a single revision

            ManifestWalk walk = hg.walkManifest("tip");
            while (walk.next()) {
                ManifestWalk.Entry entry = walk.getEntry();
                System.out.println(entry.getPath() + " -> " + entry.getNodeIdHex()
                        + (entry.isExecutable() ? " (executable)" : "")
                        + (entry.isSymlink() ? " (symlink)" : ""));
            }

walkManifest(revision) accepts a revision number, a hex node id (or an unambiguous prefix), or "tip", and returns the tracked files recorded in that manifest one at a time as ManifestWalk.Entry objects. Note that each entry’s getNodeIdHex() is the filelog node id pointing at that file’s content — not a commit id. next() loads and caches every entry on its first call, so later calls are just walking the cached list.

17.2. Walking the working directory

            // Merges tracked (dirstate) and untracked paths into one sorted walk, so every
            // relevant working-copy path is visited regardless of whether it is already tracked.
            WorkingDirWalk walk = hg.walkWorkingDir();
            while (walk.next()) {
                WorkingDirWalk.Entry entry = walk.getEntry();
                // State is a dirstate character: 'n' (normal), 'a' (added), 'r' (removed),
                // 'm' (merged), or '?' for an untracked path.
                System.out.println(entry.getState() + " " + entry.getPath()
                        + " (" + entry.getSize() + " bytes)");
            }

walkWorkingDir() merges tracked (dirstate) paths and not-yet-tracked paths into a single sorted walk — so you see every relevant working-copy path in one pass, regardless of whether it has been added yet. Each Entry’s `getState() is a dirstate state character ('n' normal, 'a' added, 'r' removed, 'm' merged), or '?' for a path that isn’t tracked yet.

17.3. Comparing multiple trees at once with TreeWalk

            HgRepository repository = hg.getRepository();

            // Each added tree is indexed in add order -- that index is what isTracked()/
            // getNodeId()/getState() take below. ManifestTreeIterator resolves "tip" or a
            // revision number/hex node id, exactly like walkManifest(String).
            TreeWalk tw = hg.walkTree();
            tw.addTree(new ManifestTreeIterator(repository, "0")); // tree 0: revision 0
            tw.addTree(new ManifestTreeIterator(repository, "tip")); // tree 1: tip

            while (tw.next()) {
                String path = tw.getPath();
                boolean inOld = tw.isTracked(0);
                boolean inNew = tw.isTracked(1);

                if (inOld && !inNew) {
                    System.out.println("removed: " + path);
                } else if (!inOld && inNew) {
                    System.out.println("added: " + path);
                } else if (tw.getState(1) != tw.getState(0)
                        || !java.util.Arrays.equals(tw.getNodeId(0), tw.getNodeId(1))) {
                    System.out.println("modified: " + path);
                }
            }

walkManifest/walkWorkingDir are actually convenience facades built on top of TreeWalk. True to its JGit namesake, TreeWalk simultaneously walks several TreeIterator`s (say, two `ManifestTreeIterator`s, or a manifest and the working directory) in merged, sorted path order. The order trees are added via `addTree() becomes their index, and isTracked(int)/getNodeId(int)/getState(int) take that index to ask a specific tree how it records the current path — a tree that doesn’t track the current path simply reports "not tracked" instead of throwing. The example above follows exactly the pattern UpdateCommand.call() itself uses internally to compare the target revision’s manifest against the working copy’s current parent revision and work out what to write and delete.

17.4. Narrowing the walk with a filter and non-recursive mode

            TreeWalk tw = hg.walkTree();
            tw.addTree(new ManifestTreeIterator(repository, "tip"));

            // Restricts the walk to one subtree.
            tw.setFilter(path -> path.equals("services/billing") || path.startsWith("services/billing/"));

            while (tw.next()) {
                System.out.println(tw.getPath());
            }

setFilter(PathFilter) is applied to every path the walk encounters, which is useful for narrowing the walk down to one subtree.

            // With no filter set, setRecursive(false) restricts the walk to paths with no "/"
            // at all -- i.e. files directly at the repository root, not inside any subdirectory.
            TreeWalk tw = hg.walkTree();
            tw.addTree(new ManifestTreeIterator(repository, "tip"));
            tw.setRecursive(false);

            while (tw.next()) {
                System.out.println(tw.getPath()); // e.g. "README.md", never "src/Main.java"
            }

setRecursive(false) stops the walk at the entries directly below the closest ancestor directory the current filter accepts, without descending any further. With no filter set at all, that ancestor is the repository root, so — as in the example above — only top-level paths with no / in them survive. Be aware, though: if the filter passed to setFilter itself accepts paths broadly under a subtree (say, anything matching path.startsWith(…​)), those deeper paths also count as an accepted "closest ancestor directory," and setRecursive(false) won’t hold back recursion the way you might expect. To see only the entries directly inside a given directory, write the filter so it does not accept paths any deeper than that directory itself.