patx/hglab
improve readme for howto use github-like feature branch workflows
Commit 1cf36641ed5f · Harrison Erd · 2026-05-05 11:31 -0400
Comments
No comments yet.
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)