Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <remote> -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 <remote> -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**

Expand Down
47 changes: 38 additions & 9 deletions sublime_github.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"],
Expand All @@ -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"):
Expand Down Expand Up @@ -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()

Expand Down
90 changes: 90 additions & 0 deletions tests/test_plugin.py
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down Expand Up @@ -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")
Expand Down
Loading