Skip to content

Commit ea43926

Browse files
committed
Support gh credentials for HTTPS push
1 parent abc85a0 commit ea43926

4 files changed

Lines changed: 103 additions & 17 deletions

File tree

pythongit/cli.py

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -789,6 +789,9 @@ def cmd_config(argv: list[str]) -> int:
789789
ap = argparse.ArgumentParser(prog="pygit config")
790790
ap.add_argument("--global", dest="is_global", action="store_true")
791791
ap.add_argument("--unset", action="store_true")
792+
ap.add_argument("--unset-all", dest="unset_all", action="store_true")
793+
ap.add_argument("--add", action="store_true")
794+
ap.add_argument("--replace-all", action="store_true")
792795
ap.add_argument("name")
793796
ap.add_argument("value", nargs="?")
794797
args = ap.parse_args(argv)
@@ -797,13 +800,13 @@ def cmd_config(argv: list[str]) -> int:
797800
else:
798801
cfg_path = _repo().gitdir / "config"
799802
import configparser
800-
cp = configparser.ConfigParser()
803+
cp = configparser.ConfigParser(interpolation=None)
801804
if cfg_path.exists():
802805
cp.read(cfg_path, encoding="utf-8")
803-
sect, _, key = args.name.partition(".")
806+
sect, key = _config_section_key(args.name)
804807
if not key:
805808
return 1
806-
if args.unset:
809+
if args.unset or args.unset_all:
807810
if cp.has_option(sect, key):
808811
cp.remove_option(sect, key)
809812
with cfg_path.open("w", encoding="utf-8") as f:
@@ -822,6 +825,16 @@ def cmd_config(argv: list[str]) -> int:
822825
return 0
823826

824827

828+
def _config_section_key(name: str) -> tuple[str, str]:
829+
prefix, sep, key = name.rpartition(".")
830+
if not sep or not prefix or not key:
831+
return "", ""
832+
section, dot, subsection = prefix.partition(".")
833+
if dot:
834+
return f'{section} "{subsection}"', key
835+
return section, key
836+
837+
825838
def cmd_remote(argv: list[str]) -> int:
826839
ap = argparse.ArgumentParser(prog="pygit remote")
827840
sub = ap.add_subparsers(dest="action")

pythongit/protocol.py

Lines changed: 67 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,10 @@
1010
from __future__ import annotations
1111

1212
import http.client
13+
import base64
1314
import os
1415
from pathlib import Path
16+
import subprocess
1517
import tempfile
1618
import urllib.parse
1719
import urllib.request
@@ -63,8 +65,68 @@ def _iter_pkt_stream(source) -> Iterator[bytes]:
6365
yield payload
6466

6567

68+
def _request_headers(url: str, extra: dict[str, str] | None = None) -> dict[str, str]:
69+
headers = {"User-Agent": "pythongit/0.1"}
70+
if extra:
71+
headers.update(extra)
72+
auth = _auth_header_for_url(url)
73+
if auth:
74+
headers["Authorization"] = auth
75+
return headers
76+
77+
78+
def _auth_header_for_url(url: str) -> str | None:
79+
user, password = _credentials_for_url(url)
80+
if not user or not password:
81+
return None
82+
raw = f"{user}:{password}".encode("utf-8")
83+
return "Basic " + base64.b64encode(raw).decode("ascii")
84+
85+
86+
def _credentials_for_url(url: str) -> tuple[str, str]:
87+
parsed = urllib.parse.urlsplit(url)
88+
scheme = parsed.scheme
89+
host = parsed.hostname or ""
90+
user = urllib.parse.unquote(parsed.username or "")
91+
password = urllib.parse.unquote(parsed.password or "")
92+
if user and password:
93+
return user, password
94+
95+
try:
96+
from . import bridges
97+
fields = {"protocol": scheme, "host": host}
98+
creds = bridges.credential_fill(fields, use_external=False)
99+
except Exception:
100+
creds = {}
101+
user = user or creds.get("username", "")
102+
password = password or creds.get("password", "")
103+
if user and password:
104+
return user, password
105+
106+
if scheme == "https" and host in {"github.com", "www.github.com"}:
107+
token = os.environ.get("GH_TOKEN") or os.environ.get("GITHUB_TOKEN") or _gh_auth_token(host)
108+
if token:
109+
return user or "x-access-token", token
110+
return "", ""
111+
112+
113+
def _gh_auth_token(host: str) -> str:
114+
try:
115+
proc = subprocess.run(
116+
["gh", "auth", "token", "--hostname", host],
117+
text=True,
118+
capture_output=True,
119+
timeout=5,
120+
)
121+
except (OSError, subprocess.TimeoutExpired):
122+
return ""
123+
if proc.returncode != 0:
124+
return ""
125+
return proc.stdout.strip()
126+
127+
66128
def _get(url: str) -> bytes:
67-
req = urllib.request.Request(url, headers={"User-Agent": "pythongit/0.1"})
129+
req = urllib.request.Request(url, headers=_request_headers(url))
68130
with urllib.request.urlopen(req) as r:
69131
return r.read()
70132

@@ -74,11 +136,7 @@ def _post(url: str, body: bytes, content_type: str, accept: str) -> bytes:
74136
url,
75137
data=body,
76138
method="POST",
77-
headers={
78-
"User-Agent": "pythongit/0.1",
79-
"Content-Type": content_type,
80-
"Accept": accept,
81-
},
139+
headers=_request_headers(url, {"Content-Type": content_type, "Accept": accept}),
82140
)
83141
with urllib.request.urlopen(req) as r:
84142
return r.read()
@@ -89,11 +147,7 @@ def _post_stream(url: str, body: bytes, content_type: str, accept: str):
89147
url,
90148
data=body,
91149
method="POST",
92-
headers={
93-
"User-Agent": "pythongit/0.1",
94-
"Content-Type": content_type,
95-
"Accept": accept,
96-
},
150+
headers=_request_headers(url, {"Content-Type": content_type, "Accept": accept}),
97151
)
98152
return urllib.request.urlopen(req)
99153

@@ -118,9 +172,8 @@ def _post_with_pack_file(
118172
try:
119173
conn.putrequest("POST", target)
120174
conn.putheader("Host", host_header)
121-
conn.putheader("User-Agent", "pythongit/0.1")
122-
conn.putheader("Content-Type", content_type)
123-
conn.putheader("Accept", accept)
175+
for key, value in _request_headers(url, {"Content-Type": content_type, "Accept": accept}).items():
176+
conn.putheader(key, value)
124177
conn.putheader("Content-Length", str(len(prefix) + pack_size))
125178
conn.endheaders()
126179
if prefix:

tests/unit_integration.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
import io
66
import os
77
import sys
8+
import configparser
89
from pathlib import Path
910

1011
import pytest
@@ -579,6 +580,15 @@ def test_check_ignore_honors_directory_only_pattern(tmprepo, capsys):
579580
assert "build" in capsys.readouterr().out
580581

581582

583+
def test_config_replace_all_writes_subsection_keys(tmprepo):
584+
repo, _path = tmprepo
585+
assert cli_run("config", "--replace-all", "credential.https://github.com.helper", "!gh auth git-credential") == 0
586+
587+
cp = configparser.ConfigParser(interpolation=None)
588+
cp.read(repo.gitdir / "config", encoding="utf-8")
589+
assert cp.get('credential "https://github.com"', "helper") == "!gh auth git-credential"
590+
591+
582592
def test_format_patch_then_apply_roundtrip(tmprepo, tmp_path_factory):
583593
from tests.conftest import commit_one
584594
repo, path = tmprepo

tests/unit_modules.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
from pythongit import rerere as rerere_mod
99
from pythongit import bridges as bridges_mod
1010
from pythongit import cli as cli_mod
11+
from pythongit import protocol as protocol_mod
1112

1213

1314
# --- diff -----------------------------------------------------------------
@@ -195,6 +196,15 @@ def test_ignore_trailing_space_requires_escape(tmp_path):
195196
assert not ig.is_ignored("with")
196197

197198

199+
def test_protocol_auth_header_uses_github_token_env(tmp_path, monkeypatch):
200+
monkeypatch.setenv("HOME", str(tmp_path))
201+
monkeypatch.delenv("GIT_USERNAME", raising=False)
202+
monkeypatch.delenv("GIT_PASSWORD", raising=False)
203+
monkeypatch.setenv("GITHUB_TOKEN", "secret-token")
204+
header = protocol_mod._auth_header_for_url("https://github.com/owner/repo.git")
205+
assert header == "Basic eC1hY2Nlc3MtdG9rZW46c2VjcmV0LXRva2Vu"
206+
207+
198208
# --- rerere ---------------------------------------------------------------
199209

200210

0 commit comments

Comments
 (0)