improve readme for howto use github-like feature branch workflows

Commit 1cf36641ed5f · Harrison Erd · 2026-05-05 11:31 -0400

Changeset
1cf36641ed5f4c9dc78da00e7abe73d6939697ca

View source at this commit

Comments

No comments yet.

Log in to comment

Diff

diff --git a/README.md b/README.md
--- a/README.md
+++ b/README.md
@@ -29,23 +29,72 @@
 hg push http://<user>@127.0.0.1:8080/hg/<user>/<repo>
 ```
 
-For a Git-like branch workflow, keep the main line on Mercurial's special `@`
-bookmark and put feature work on separate bookmarks:
+## GitHub-style workflow with bookmarks
+
+Use Mercurial bookmarks as the Git branch equivalent:
+
+- Git `main` or GitHub default branch: Mercurial bookmark `@`
+- Git feature branch: Mercurial bookmark like `feature/xyz`
+- GitHub PR from same repo branch: HgLab PR from a same-repo bookmark or branch
+- GitHub fork PR: HgLab PR from a fork bookmark or branch
+- HgLab "Branches" tab: Mercurial named branches, not Git-style branches
+
+Set up the main bookmark once on the commit you consider main:
 
 ```sh
 hg bookmark @
 hg push -B @
+```
 
+Create and push feature work from that bookmark:
+
+```sh
+hg pull -B @
 hg update @
+
 hg bookmark feature/xyz
 # edit files
 hg commit -m "Implement xyz"
+
 hg push -B feature/xyz
 ```
 
 HgLab treats `@` as the repository's default code ref when it exists. The
 feature bookmark remains browsable from the Bookmarks tab without replacing the
-unqualified repository view.
+unqualified repository view. Maintainers can also open pull requests from
+bookmarks and branches in the same repository, so collaborators with push access
+do not need to fork before asking for review.
+
+Open a pull request in HgLab with:
+
+```text
+Source ref: alice/demo bookmark feature/xyz
+Target ref: alice/demo bookmark @
+```
+
+For an outside contributor using a fork:
+
+```text
+Source ref: bob/demo-fork bookmark feature/xyz
+Target ref: alice/demo bookmark @
+```
+
+After merge, update your local main bookmark:
+
+```sh
+hg pull -B @
+hg update @
+```
+
+Optionally delete the feature bookmark locally and remotely:
+
+```sh
+hg bookmark -d feature/xyz
+hg push -B feature/xyz
+```
+
+For GitHub-like branches, prefer `hg bookmark`. Use `hg branch` only when you
+want a permanent Mercurial named branch label in history.
 
 ## Configuration
 
diff --git a/app.py b/app.py
--- a/app.py
+++ b/app.py
@@ -1852,9 +1852,9 @@
     return options
 
 
-def source_repo_ref_options(source_repo):
+def source_repo_ref_options(source_repo, include_tip=True):
     path = repo_path(source_repo["owner_username"], source_repo["name"])
-    options = repo_ref_options(path, include_closed_branches=True, include_tip=True, include_tags=False)
+    options = repo_ref_options(path, include_closed_branches=True, include_tip=include_tip, include_tags=False)
     for option in options:
         option["value"] = source_ref_option_value(
             source_repo["id"],
@@ -2162,10 +2162,14 @@
     target_ref_name,
 ):
     if not source_repo:
-        raise ValueError("Choose a source fork.")
-    if source_repo["id"] == target_repo["id"]:
-        raise ValueError("Choose a fork as the source repository.")
-    if source_repo["owner_id"] != author["id"] or source_repo["forked_from_repo_id"] != target_repo["id"]:
+        raise ValueError("Choose a source repository.")
+    source_is_target = source_repo["id"] == target_repo["id"]
+    if source_is_target:
+        if not user_can_maintain_repo(author, target_repo):
+            raise ValueError("Only maintainers can open pull requests from this repository.")
+        if source_ref_type == REF_TYPE_TIP:
+            raise ValueError("Choose a branch or bookmark as the source ref.")
+    elif source_repo["owner_id"] != author["id"] or source_repo["forked_from_repo_id"] != target_repo["id"]:
         raise ValueError("Choose one of your forks of this repository.")
     target_path = repo_path(target_repo["owner_username"], target_repo["name"])
     source_path = repo_path(source_repo["owner_username"], source_repo["name"])
@@ -2180,6 +2184,8 @@
     source_node = source_ref.get("node")
     if not source_node:
         raise ValueError("Source repository has no commits.")
+    if source_is_target and source_node == (target_ref.get("node") or NULL_REV):
+        raise ValueError("Source ref has no changes compared with target ref.")
     base_node = pull_request_base_node(target_repo, source_repo, target_ref)
     now = utcnow()
     with db_connect() as conn:
@@ -2252,6 +2258,7 @@
 
     target_path = repo_path(target_repo["owner_username"], target_repo["name"])
     source_path = repo_path(source_repo["owner_username"], source_repo["name"])
+    source_is_target = source_repo["id"] == target_repo["id"]
     try:
         source_ref = resolve_pr_ref(source_path, pr, "source")
         target_ref = resolve_pr_ref(target_path, pr, "target")
@@ -2267,7 +2274,8 @@
     target_node_before = target_ref.get("node") or NULL_REV
     source_branch = revision_branch(source_path, source_node)
     run_hg(["update", "-C", target_node_before], cwd=target_path, check=False)
-    pull_source_into_target(target_path, source_path)
+    if not source_is_target:
+        pull_source_into_target(target_path, source_path)
     if not repo_has_revision(target_path, source_node):
         raise HgCommandError("Source revision was not pulled into the target repository.")
 
@@ -3028,7 +3036,10 @@
         abort(404, "Repository not found.")
     path = repo_path(owner, repo_name)
     forks = list_user_forks_for_target(user["id"], repo["id"])
+    can_use_repo_refs = user_can_maintain_repo(user, repo)
     source_options = []
+    if can_use_repo_refs:
+        source_options.extend(source_repo_ref_options(repo, include_tip=False))
     for fork in forks:
         source_options.extend(source_repo_ref_options(fork))
     target_options = target_repo_ref_options(path)
diff --git a/templates/new_pull_request.tpl b/templates/new_pull_request.tpl
--- a/templates/new_pull_request.tpl
+++ b/templates/new_pull_request.tpl
@@ -11,7 +11,7 @@
 </section>
 
 <section class="panel">
-  % if forks and source_options and target_options:
+  % if source_options and target_options:
     <h2>Open pull request</h2>
     <form method="post">
       {{!csrf_field()}}
@@ -41,8 +41,10 @@
       </label>
       <button class="button" type="submit">Open pull request</button>
     </form>
-  % elif forks and source_options:
+  % elif source_options:
     <p class="empty">This repository has no open target branches or bookmarks.</p>
+  % elif can_maintain:
+    <p class="empty">This repository has no source branches or bookmarks yet.</p>
   % else:
     <p class="empty">You do not have a fork of this repository yet.</p>
     <form class="inline-form" method="post" action="/{{repo['owner_username']}}/{{repo['name']}}/fork">
diff --git a/tests/test_app.py b/tests/test_app.py
--- a/tests/test_app.py
+++ b/tests/test_app.py
@@ -834,6 +834,91 @@
     assert "branch feature" in bookmark_diff
 
 
+def test_same_repo_pull_requests_can_use_bookmarks_for_maintainers(isolated_app):
+    owner = create_user("alice")
+    contributor = create_user("bob")
+    create_user("mallory")
+    isolated_app.create_repository(owner, "demo", "")
+    target_path = isolated_app.repo_path("alice", "demo")
+    base_node = commit_file(target_path, "README.md", "# Demo\n", message="initial", user="alice")
+    isolated_app.run_hg(["bookmark", "-r", base_node, isolated_app.DEFAULT_BOOKMARK_NAME], cwd=target_path)
+    isolated_app.run_hg(["update", isolated_app.DEFAULT_BOOKMARK_NAME], cwd=target_path)
+    isolated_app.run_hg(["bookmark", "feature/xyz"], cwd=target_path)
+    source_node = commit_file(
+        target_path,
+        "feature.txt",
+        "same repo feature\n",
+        message="same repo feature",
+        user="bob",
+    )
+    target_repo = isolated_app.get_repo("alice", "demo")
+    source_ref_value = isolated_app.source_ref_option_value(
+        target_repo["id"],
+        isolated_app.REF_TYPE_BOOKMARK,
+        "feature/xyz",
+    )
+    target_ref_value = isolated_app.ref_option_value(
+        isolated_app.REF_TYPE_BOOKMARK,
+        isolated_app.DEFAULT_BOOKMARK_NAME,
+    )
+
+    outsider_client = WsgiClient(isolated_app.app)
+    login_client(outsider_client, "mallory")
+    response = outsider_client.post(
+        "/alice/demo/pulls/new",
+        {
+            "source_ref": source_ref_value,
+            "target_ref": target_ref_value,
+            "title": "Tampered same repo PR",
+            "body": "",
+        },
+    )
+    assert response.status_code == 200
+    assert "Only maintainers can open pull requests from this repository." in response.text
+
+    isolated_app.add_repo_contributor(target_repo, owner, contributor["username"])
+    contributor_client = WsgiClient(isolated_app.app)
+    login_client(contributor_client, "bob")
+    response = contributor_client.get("/alice/demo/pulls/new")
+
+    assert response.status_code == 200
+    assert "alice/demo bookmark feature/xyz" in response.text
+    assert "alice/demo bookmark @" in response.text
+    assert "You do not have a fork of this repository yet." not in response.text
+
+    response = contributor_client.post(
+        "/alice/demo/pulls/new",
+        {
+            "source_ref": source_ref_value,
+            "target_ref": target_ref_value,
+            "title": "Same repo feature",
+            "body": "Please review this bookmark.",
+        },
+    )
+    assert response.status_code == 303
+    assert response.location_path == "/alice/demo/pulls/1"
+
+    pr = isolated_app.get_pull_request(target_repo["id"], 1)
+    diff, current_source_node, source_ref = isolated_app.pull_request_diff(pr)
+
+    assert pr["source_repo_id"] == target_repo["id"]
+    assert pr["target_repo_id"] == target_repo["id"]
+    assert pr["base_node"] == base_node
+    assert pr["source_node"] == source_node
+    assert current_source_node == source_node
+    assert source_ref["type"] == isolated_app.REF_TYPE_BOOKMARK
+    assert "feature.txt" in diff
+    assert "same repo feature" in diff
+
+    response = contributor_client.post("/alice/demo/pulls/1", {"action": "merge"})
+    assert response.status_code == 303
+    merged = isolated_app.get_pull_request(target_repo["id"], 1)
+
+    assert merged["status"] == "merged"
+    assert merged["merge_node"] == source_node
+    assert isolated_app.default_code_ref(target_path)["node"] == source_node
+
+
 def test_branch_tag_bookmark_helpers_resolve_and_filter_refs(isolated_app):
     owner = create_user("alice")
     nodes = create_repo_with_refs(owner)