diff --git a/README.md b/README.md index fdd238b..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 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)). 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 fcb5041..6ba5811 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,30 @@ 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): + 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): 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() 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")