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 pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ gimmegit = "gimmegit._cli:main"
dev = [
"ruff",
"pytest",
"ty",
"ty>=0.0.1a27",
]

[build-system]
Expand Down
42 changes: 28 additions & 14 deletions src/gimmegit/_cli.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
from dataclasses import dataclass
from dataclasses import asdict, dataclass
from datetime import datetime
from pathlib import Path
import argparse
import json
import logging
import re
import os
Expand Down Expand Up @@ -42,13 +43,15 @@ class ParsedURL:
branch: str | None
owner: str
project: str
remote_url: str


@dataclass
class ParsedBranchSpec:
branch: str
owner: str | None
project: str | None
remote_url: str | None


class CloneError(RuntimeError):
Expand All @@ -71,6 +74,7 @@ def main() -> None:
default="auto",
help="Use SSH for git remotes",
)
parser.add_argument("--parse-url", nargs="?", help="Get a JSON representation of a GitHub URL")
parser.add_argument(
"--no-pre-commit",
action="store_true",
Expand Down Expand Up @@ -100,6 +104,17 @@ def main() -> None:
set_global_color(args.color)
set_global_ssh(args.ssh)
configure_logger()
if "--parse-url" in command_args and not args.parse_url:
logger.error("No GitHub URL specified. Run 'gimmegit -h' for help.")
sys.exit(2)
elif args.parse_url:
parsed_url = parse_github_url(args.parse_url)
if parsed_url:
logger.info(json.dumps(asdict(parsed_url)))
return
else:
logger.error(f"'{args.parse_url}' is not a supported GitHub URL.")
sys.exit(1)
if not args.allow_outer_repo:
working = _inspect.get_outer_repo()
if working:
Expand Down Expand Up @@ -217,7 +232,7 @@ def get_context(args: argparse.Namespace) -> Context:
owner = parsed.owner
project = parsed.project
branch = parsed.branch
clone_url = make_github_clone_url(owner, project)
clone_url = parsed.remote_url
# Check that the repo exists and look for an upstream repo (if a token is set).
upstream = get_github_upstream(owner, project)
upstream_owner = None
Expand All @@ -226,10 +241,11 @@ def get_context(args: argparse.Namespace) -> Context:
if args.base_branch:
parsed_base = parse_github_branch_spec(args.base_branch)
if parsed_base and parsed_base.owner:
assert parsed_base.project # For the type checker.
if (parsed_base.owner, parsed_base.project) != (owner, project):
project = parsed_base.project
upstream_owner = parsed_base.owner
upstream_url = make_github_clone_url(upstream_owner, project)
upstream_url = parsed_base.remote_url
if args.upstream_owner and args.upstream_owner != parsed_base.owner:
logger.warning(
f"Ignoring upstream owner '{args.upstream_owner}' because the base branch includes an owner."
Expand Down Expand Up @@ -266,10 +282,8 @@ def get_context(args: argparse.Namespace) -> Context:


def make_github_url(repo: str) -> str:
if repo.startswith("https://github.com/"):
if repo.startswith(("https://github.com/", "github.com/")):
return repo
if repo.startswith("github.com/"):
return f"https://{repo}"
if repo.count("/") == 1 and not repo.endswith("/"):
return f"https://github.com/{repo}"
if repo.endswith("/") or repo.endswith("\\"):
Expand All @@ -287,37 +301,37 @@ def make_github_url(repo: str) -> str:


def parse_github_url(url: str) -> ParsedURL | None:
pattern = r"https://github\.com/([^/]+)/([^/]+)(/tree/(.+))?"
pattern = r"(https://)?github\.com/([^/]+)/([^/]+)(/tree/(.+))?"
# TODO: Disallow PR URLs.
match = re.search(pattern, url)
if match:
branch = match.group(4)
branch = match.group(5)
if branch:
branch = urllib.parse.unquote(branch)
return ParsedURL(
branch=branch,
owner=match.group(1),
project=match.group(2),
owner=match.group(2),
project=match.group(3),
remote_url=make_github_clone_url(match.group(2), match.group(3)),
)


def parse_github_branch_spec(branch_spec: str) -> ParsedBranchSpec | None:
branch_url = branch_spec
if branch_url.startswith("github.com/"):
branch_url = f"https://{branch_url}"
parsed = parse_github_url(branch_url)
parsed = parse_github_url(branch_spec)
if not parsed:
return ParsedBranchSpec(
branch=branch_spec,
owner=None,
project=None,
remote_url=None,
)
if not parsed.branch:
raise ValueError(f"'{branch_spec}' does not specify a branch.")
return ParsedBranchSpec(
branch=parsed.branch,
owner=parsed.owner,
project=parsed.project,
remote_url=parsed.remote_url,
)


Expand Down
24 changes: 13 additions & 11 deletions tests/functional/helpers_functional.py
Original file line number Diff line number Diff line change
@@ -1,21 +1,23 @@
from pathlib import Path
import os
import re
import subprocess
import types

from gimmegit import _version

no_color = ["--color", "never"]
no_ssh = ["--ssh", "never"]

fail_in_dev = {
"condition": re.search(r"\.dev\d+$", _version.__version__),
"reason": "Follow up before release",
"strict": True,
}
no_token = {
"condition": "GITHUB_TOKEN" not in os.environ,
"reason": "GITHUB_TOKEN is not set",
}
fail_in_dev = types.SimpleNamespace(
condition=re.search(r"\.dev\d+$", _version.__version__),
reason="Follow up before release",
strict=True,
)
no_token = types.SimpleNamespace(
condition="GITHUB_TOKEN" not in os.environ,
reason="GITHUB_TOKEN is not set",
)


def token_env() -> dict[str, str]:
Expand All @@ -24,7 +26,7 @@ def token_env() -> dict[str, str]:
return env


def get_branch(dir: str) -> str:
def get_branch(dir: Path) -> str:
result = subprocess.run(
["git", "branch", "--show-current"],
cwd=dir,
Expand All @@ -35,7 +37,7 @@ def get_branch(dir: str) -> str:
return result.stdout.strip()


def get_config(dir: str, name: str) -> str:
def get_config(dir: Path, name: str) -> str:
result = subprocess.run(
["git", "config", "--get", name],
cwd=dir,
Expand Down
22 changes: 22 additions & 0 deletions tests/functional/test_arg_errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,3 +21,25 @@ def test_no_repo(uv_run, test_dir):
Error: No repo specified. Run 'gimmegit -h' for help.
"""
assert result.stderr == expected_stderr


def test_parse_no_url(uv_run, test_dir):
command = [
*uv_run,
"gimmegit",
*helpers.no_color,
*helpers.no_ssh,
"--parse-url",
]
result = subprocess.run(
command,
cwd=test_dir,
capture_output=True,
text=True,
)
assert result.returncode == 2
assert not result.stdout
expected_stderr = """\
Error: No GitHub URL specified. Run 'gimmegit -h' for help.
"""
assert result.stderr == expected_stderr
100 changes: 100 additions & 0 deletions tests/functional/test_parse.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
import json
import subprocess

import helpers_functional as helpers


def test_parse(uv_run, test_dir):
command = [
*uv_run,
"gimmegit",
*helpers.no_color,
*helpers.no_ssh,
"--parse-url",
"github.com/canonical/operator/tree/2.23-maintenance",
]
result = subprocess.run(
command,
cwd=test_dir,
capture_output=True,
text=True,
check=True,
)
assert json.loads(result.stdout) == {
"branch": "2.23-maintenance",
"owner": "canonical",
"project": "operator",
"remote_url": "https://github.com/canonical/operator.git",
}


def test_parse_no_branch(uv_run, test_dir):
command = [
*uv_run,
"gimmegit",
*helpers.no_color,
*helpers.no_ssh,
"--parse-url",
"github.com/canonical/operator",
]
result = subprocess.run(
command,
cwd=test_dir,
capture_output=True,
text=True,
check=True,
)
assert json.loads(result.stdout) == {
"branch": None,
"owner": "canonical",
"project": "operator",
"remote_url": "https://github.com/canonical/operator.git",
}


def test_parse_with_repo(uv_run, test_dir):
command = [
*uv_run,
"gimmegit",
*helpers.no_color,
*helpers.no_ssh,
"--parse-url",
"github.com/canonical/operator/tree/2.23-maintenance",
"dwilding/jubilant",
]
result = subprocess.run(
command,
cwd=test_dir,
capture_output=True,
text=True,
check=True,
)
assert json.loads(result.stdout) == {
"branch": "2.23-maintenance",
"owner": "canonical",
"project": "operator",
"remote_url": "https://github.com/canonical/operator.git",
}


def test_parse_invalid(uv_run, test_dir):
command = [
*uv_run,
"gimmegit",
*helpers.no_color,
*helpers.no_ssh,
"--parse-url",
"github.com/canonical",
]
result = subprocess.run(
command,
cwd=test_dir,
capture_output=True,
text=True,
)
assert result.returncode == 1
assert not result.stdout
expected_stderr = """\
Error: 'github.com/canonical' is not a supported GitHub URL.
"""
assert result.stderr == expected_stderr
8 changes: 4 additions & 4 deletions tests/functional/test_token.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
import helpers_functional as helpers


@pytest.mark.skipif(**helpers.no_token)
@pytest.mark.skipif(helpers.no_token.condition, reason=helpers.no_token.reason)
def test_forked_repo_token(uv_run, test_dir):
command = [
*uv_run,
Expand Down Expand Up @@ -42,7 +42,7 @@ def test_forked_repo_token(uv_run, test_dir):
assert helpers.get_config(expected_dir, "gimmegit.baseBranch") == "main"


@pytest.mark.skipif(**helpers.no_token)
@pytest.mark.skipif(helpers.no_token.condition, reason=helpers.no_token.reason)
def test_invalid_repo_token(uv_run, test_dir):
command = [
*uv_run,
Expand Down Expand Up @@ -70,7 +70,7 @@ def test_invalid_repo_token(uv_run, test_dir):
assert not (Path(test_dir) / "invalid").exists()


@pytest.mark.skipif(**helpers.no_token)
@pytest.mark.skipif(helpers.no_token.condition, reason=helpers.no_token.reason)
def test_u_sets_upstream_owner(uv_run, test_dir):
command = [
*uv_run,
Expand Down Expand Up @@ -103,7 +103,7 @@ def test_u_sets_upstream_owner(uv_run, test_dir):
assert result.stdout == expected_stdout


@pytest.mark.skipif(**helpers.no_token)
@pytest.mark.skipif(helpers.no_token.condition, reason=helpers.no_token.reason)
def test_b_sets_upstream_owner(uv_run, test_dir):
command = [
*uv_run,
Expand Down
3 changes: 1 addition & 2 deletions tests/unit/test_context_no_token.py
Original file line number Diff line number Diff line change
Expand Up @@ -105,8 +105,7 @@ def test_repo_invalid_github(repo: str):
)
with pytest.raises(ValueError) as exc_info:
_cli.get_context(args)
url = repo if repo.startswith("https://") else f"https://{repo}"
assert str(exc_info.value) == f"'{url}' is not a supported GitHub URL."
assert str(exc_info.value) == f"'{repo}' is not a supported GitHub URL."


@pytest.mark.parametrize(
Expand Down
Loading