From 9940fbd9e3c54e847d0b21ef18c9b3bb3bfba808 Mon Sep 17 00:00:00 2001 From: Jeff Wear Date: Wed, 17 Jul 2019 17:52:13 -0700 Subject: [PATCH 1/2] Get permalinks for the local HEAD not the HEAD of the remote branch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On GitHub, permalinks reference the HEAD of the remote branch since that’s what you’re looking at when you request the permalink (https://help.github.com/en/articles/getting-permanent-links-to-files). In Sublime, it makes more sense for permalinks to reference your local HEAD since _that’s_ what you’re looking at when you run a permalink command. Referencing the local HEAD lets the user run permalink commands with a commit checked out rather than a branch; or having checked out a branch that they've just created, if the user has neither added commits to that branch nor pushed the branch to the remote. If the user _has_ added commits to such a branch without pushing them to the remote, then the link will 404 on GitHub. Then again, previously, if the user had pushed the branch to the remote—just not the most recent commits on the branch—then we would have created an out-of-date permalink, and this would arguably have been a worse failure because it would be silent. At some future point, it might be nice to detect, locally, if a permalink was going to 404, and show an error message before the user went to GitHub. Referencing the local HEAD also fixes a bug in the previous implementation where we’d only successfully fetch the remote HEAD if the name of the remote branch was exactly the same as the local branch, since we executed `git rev-parse ` at line 508 without prefixing `remote_branch` with the name of the remote. Tests: - [x] “Open Remote URL in Browser” works if the current branch has been pushed to the remote - [x] “Open Remote URL in Browser” shows an error alert if the current branch has not been pushed to the remote - [x] “Open Remote URL in Browser” shows an error alert if a commit is checked out rather than a branch - [x] “Open Remote URL in Browser (permalink)” works if the current branch has been pushed to the remote - [x] “Open Remote URL in Browser (permalink)” works if a commit is checked out vs. a branch - [x] “Open Remote URL in Browser (permalink)” works if a branch is checked out and the user has not pushed that branch but has also not added commits to that branch - [x] “Open Remote URL in Browser (permalink)” results in a 404 on GitHub if the user has added commits to a branch and not pushed them to GitHub - [x] “Open Remote URL in Browser (master)” works regardless of what’s checked out above --- README.md | 2 +- sublime_github.py | 35 ++++++++++++++++++++++++++--------- 2 files changed, 27 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index fdd238b..5c480f2 100644 --- a/README.md +++ b/README.md @@ -64,7 +64,7 @@ The following commands are available in the Command Palette: **The following commands require a `git` executable on your PATH, but not the Git Sublime package.** -**Note:** These commands use the current branch by default. Variants such as **GitHub: Blame (default branch)** read the remote's local `HEAD` ref, falling back to its local `main` or `master` tracking ref. This does not contact GitHub; if the local refs are missing, fetch the remote or set `default_branch` in your user settings as a fallback. If the remote changed its default branch, refresh the local pointer with `git remote set-head -a` when online. All commands except **GitHub: Edit** also have a "permalink" variant, which uses the current remote branch's commit ([more info](https://help.github.com/en/articles/getting-permanent-links-to-files)). +**Note:** These commands use the current branch by default. Variants such as **GitHub: Blame (default branch)** read the remote's local `HEAD` ref, falling back to its local `main` or `master` tracking ref. This does not contact GitHub; if the local refs are missing, fetch the remote or set `default_branch` in your user settings as a fallback. If the remote changed its default branch, refresh the local pointer with `git remote set-head -a` when online. All commands except **GitHub: Edit** also have a "permalink" variant, which references the locally checked-out commit ([more info](https://help.github.com/en/articles/getting-permanent-links-to-files)). * **GitHub: Open Remote URL in Browser** diff --git a/sublime_github.py b/sublime_github.py index fcb5041..05456ca 100644 --- a/sublime_github.py +++ b/sublime_github.py @@ -309,16 +309,25 @@ def done_rev_parse(self, result): if not self.active_account: self.active_account = list(self.accounts.keys())[0] + self.protocol = self.accounts[self.active_account].get("protocol", "https") + configured_remote = self.accounts[self.active_account].get("remote") if "fatal:" in result: - if self.branch != "default": + if self.branch == "default": + remote = "origin" + elif self.branch is False: + if configured_remote: + self.remote = configured_remote + self.fetch_remote_url() + else: + self.run_command(["git", "remote"], self.done_remote_list) + return + else: sublime.error_message(result) return - remote = "origin" else: remote, self.remote_branch = result.strip().split("/", 1) - self.protocol = self.accounts[self.active_account].get("protocol", "https") - self.remote = self.accounts[self.active_account].get("remote", remote) + self.remote = configured_remote or remote if self.branch == "default": self.run_command( ["git", "symbolic-ref", "--quiet", "--short", f"refs/remotes/{self.remote}/HEAD"], @@ -339,6 +348,14 @@ def done_default_branch(self, result): self.done_default_candidates, ) + def done_remote_list(self, result): + remotes = result.splitlines() + if len(remotes) != 1: + sublime.error_message("Set a remote in GitHub settings to create a permalink without an upstream branch.") + return + self.remote = remotes[0] + self.fetch_remote_url() + def done_default_candidates(self, result): refs = set(result.splitlines()) for branch in ("main", "master"): @@ -405,18 +422,18 @@ def done_toplevel(self, result): if self.branch: self.generate_url() else: - command = "git rev-parse " + self.remote_branch - self.run_command(command.split(), self.done_remote_head) + self.run_command(["git", "rev-parse", "HEAD"], self.done_head) - def done_remote_head(self, result): - self.remote_head = result.strip() + def done_head(self, result): + self.head = result.strip() self.generate_url() def generate_url(self): if self.branch: remote_id = self.remote_branch else: - remote_id = self.remote_head + # Use the commit checked out locally, including when HEAD is detached. + remote_id = self.head self.url = "%s/%s/%s%s%s" % (self.repo_url, self.url_type, remote_id, self.relative_path, self.line_nums) self.on_done() From 9802ec14eee0ec4c2377dcec1db91e30af90ae53 Mon Sep 17 00:00:00 2001 From: Maximo Mussini Date: Tue, 29 Sep 2026 15:10:13 -0300 Subject: [PATCH 2/2] fix: avoid permalinks to commits absent from selected remote Guard local HEAD links using the selected remote\x27s tracking branches; cover detached HEAD, unpushed commits, and ambiguous remotes. --- README.md | 2 +- sublime_github.py | 12 ++++++ tests/test_plugin.py | 90 ++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 103 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 5c480f2..4a79d77 100644 --- a/README.md +++ b/README.md @@ -64,7 +64,7 @@ The following commands are available in the Command Palette: **The following commands require a `git` executable on your PATH, but not the Git Sublime package.** -**Note:** These commands use the current branch by default. Variants such as **GitHub: Blame (default branch)** read the remote's local `HEAD` ref, falling back to its local `main` or `master` tracking ref. This does not contact GitHub; if the local refs are missing, fetch the remote or set `default_branch` in your user settings as a fallback. If the remote changed its default branch, refresh the local pointer with `git remote set-head -a` when online. All commands except **GitHub: Edit** also have a "permalink" variant, which references the locally checked-out commit ([more info](https://help.github.com/en/articles/getting-permanent-links-to-files)). +**Note:** These commands use the current branch by default. Variants such as **GitHub: Blame (default branch)** read the remote's local `HEAD` ref, falling back to its local `main` or `master` tracking ref. This does not contact GitHub; if the local refs are missing, fetch the remote or set `default_branch` in your user settings as a fallback. If the remote changed its default branch, refresh the local pointer with `git remote set-head -a` when online. All commands except **GitHub: Edit** also have a "permalink" variant, which references the locally checked-out commit ([more info](https://help.github.com/en/articles/getting-permanent-links-to-files)). A permalink is only generated when the commit appears in a local tracking branch of the selected remote; push or fetch first if it does not. This is a local check, not a live check against GitHub. * **GitHub: Open Remote URL in Browser** diff --git a/sublime_github.py b/sublime_github.py index 05456ca..6ba5811 100644 --- a/sublime_github.py +++ b/sublime_github.py @@ -425,7 +425,19 @@ def done_toplevel(self, result): self.run_command(["git", "rev-parse", "HEAD"], self.done_head) def done_head(self, result): + if not result.strip() or result.lstrip().startswith(("fatal:", "error:")): + sublime.error_message("Cannot resolve HEAD: %s" % result.strip()) + return self.head = result.strip() + self.run_command( + ["git", "branch", "--remotes", "--contains", self.head, "--format=%(refname:short)"], + self.done_remote_branches, + ) + + def done_remote_branches(self, result): + if not any(branch.startswith(self.remote + "/") for branch in result.splitlines()): + sublime.error_message("Push this commit to %s (or fetch its branches) before creating a permalink." % self.remote) + return self.generate_url() def generate_url(self): diff --git a/tests/test_plugin.py b/tests/test_plugin.py index eb22b76..bda47c3 100644 --- a/tests/test_plugin.py +++ b/tests/test_plugin.py @@ -51,6 +51,7 @@ def setUp(self): github.GitHubApi.etags.clear() github.GitHubApi.cache.clear() self.errors.clear() + type(self).opened_url = None def test_commands_load_on_current_python(self): self.assertTrue(issubclass(self.plugin.BlameDefaultCommand, self.plugin.OpenRemoteUrlCommand)) @@ -309,6 +310,95 @@ def test_missing_remote_reports_error_instead_of_opening_invalid_link(self): self.assertIsNone(self.opened_url) self.assertIn("origin", self.errors[-1]) + def test_permalink_uses_local_head_on_a_tracked_branch(self): + command = self.plugin.OpenRemoteUrlPermalinkCommand() + command.view = types.SimpleNamespace(file_name=lambda: "/repo/file.rb", sel=lambda: []) + responses = { + "git rev-parse --abbrev-ref --symbolic-full-name @{upstream}": "origin/feature/topic", + "git remote get-url origin": "git@github.com:Giftly/China.git", + "git rev-parse --show-toplevel": "/repo", + "git rev-parse HEAD": "abc123", + "git branch --remotes --contains abc123 --format=%(refname:short)": "origin/main\n", + } + command.run_command = lambda args, callback: callback(responses[" ".join(args)]) + command.run(None) + self.assertEqual(self.opened_url, "https://github.com/Giftly/China/blob/abc123/file.rb") + + def test_permalink_uses_local_head_when_detached_with_one_remote(self): + command = self.plugin.OpenRemoteUrlPermalinkCommand() + command.view = types.SimpleNamespace(file_name=lambda: "/repo/file.rb", sel=lambda: []) + responses = { + "git rev-parse --abbrev-ref --symbolic-full-name @{upstream}": "fatal: HEAD does not point to a branch", + "git remote": "origin\n", + "git remote get-url origin": "git@github.com:Giftly/China.git", + "git rev-parse --show-toplevel": "/repo", + "git rev-parse HEAD": "abc123", + "git branch --remotes --contains abc123 --format=%(refname:short)": "origin/main\n", + } + command.run_command = lambda args, callback: callback(responses[" ".join(args)]) + command.run(None) + self.assertEqual(self.opened_url, "https://github.com/Giftly/China/blob/abc123/file.rb") + + def test_permalink_rejects_unpushed_commit(self): + command = self.plugin.OpenRemoteUrlPermalinkCommand() + command.view = types.SimpleNamespace(file_name=lambda: "/repo/file.rb", sel=lambda: []) + responses = { + "git rev-parse --abbrev-ref --symbolic-full-name @{upstream}": "origin/feature/topic", + "git remote get-url origin": "git@github.com:Giftly/China.git", + "git rev-parse --show-toplevel": "/repo", + "git rev-parse HEAD": "abc123", + "git branch --remotes --contains abc123 --format=%(refname:short)": "fork/main\n", + } + command.run_command = lambda args, callback: callback(responses[" ".join(args)]) + command.run(None) + self.assertIsNone(self.opened_url) + self.assertIn("Push", self.errors[-1]) + + def test_permalink_without_upstream_uses_configured_remote(self): + command = self.plugin.OpenRemoteUrlPermalinkCommand() + command.view = types.SimpleNamespace(file_name=lambda: "/repo/file.rb", sel=lambda: []) + responses = { + "git rev-parse --abbrev-ref --symbolic-full-name @{upstream}": "fatal: no upstream configured", + "git remote get-url fork": "git@github.com:Giftly/China.git", + "git rev-parse --show-toplevel": "/repo", + "git rev-parse HEAD": "abc123", + "git branch --remotes --contains abc123 --format=%(refname:short)": "fork/main\n", + } + command.run_command = lambda args, callback: callback(responses[" ".join(args)]) + settings = types.SimpleNamespace(get=lambda key, default=None: { + "accounts": {"GitHub": {"base_uri": "https://api.github.com", "remote": "fork"}}, + }.get(key, default)) + with patch.object(sys.modules["sublime"], "load_settings", return_value=settings): + command.run(None) + self.assertEqual(self.opened_url, "https://github.com/Giftly/China/blob/abc123/file.rb") + + def test_permalink_without_upstream_requires_exactly_one_remote_or_a_setting(self): + for remotes in ("", "origin\nfork\n"): + with self.subTest(remotes=remotes): + command = self.plugin.OpenRemoteUrlPermalinkCommand() + command.run_command = lambda args, callback: callback({ + "git rev-parse --abbrev-ref --symbolic-full-name @{upstream}": "fatal: no upstream configured", + "git remote": remotes, + }[" ".join(args)]) + command.run(None) + self.assertIsNone(self.opened_url) + self.assertIn("remote", self.errors[-1]) + + def test_permalink_does_not_open_a_link_when_head_is_missing(self): + command = self.plugin.OpenRemoteUrlPermalinkCommand() + command.view = types.SimpleNamespace(file_name=lambda: "/repo/file.rb", sel=lambda: []) + responses = { + "git rev-parse --abbrev-ref --symbolic-full-name @{upstream}": "fatal: no upstream configured", + "git remote": "origin\n", + "git remote get-url origin": "git@github.com:Giftly/China.git", + "git rev-parse --show-toplevel": "/repo", + "git rev-parse HEAD": "fatal: ambiguous argument 'HEAD'", + } + command.run_command = lambda args, callback: callback(responses[" ".join(args)]) + command.run(None) + self.assertIsNone(self.opened_url) + self.assertIn("Cannot resolve HEAD", self.errors[-1]) + def test_current_branch_without_upstream_still_reports_error(self): command = self.plugin.OpenRemoteUrlCommand() command.view = types.SimpleNamespace(file_name=lambda: "/repo/file.rb")