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
3 changes: 3 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,9 @@ jobs:
with:
python-version: "3.12"

- name: Run tests
run: python -m unittest discover -v

- name: Install build dependencies
run: python -m pip install -r requirements.txt pyinstaller

Expand Down
23 changes: 23 additions & 0 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
name: Tests

on:
pull_request:
push:
branches:
- main

permissions:
contents: read

jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- uses: actions/setup-python@v5
with:
python-version: "3.12"

- name: Run tests
run: python -m unittest discover -v
27 changes: 27 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,33 @@ inbox/
- 清空远端内容:在“仓库设置”中点击“清空仓库内容”,确认后删除当前分支中的文件。仓库和提交历史不会被删除。
- 不保存 Token:取消勾选“使用 Git 凭据管理器记住 Token”。

### macOS 代理和公司网络

GitDrop 会按以下顺序查找可供 Git 子进程使用的代理:

1. `GITDROP_HTTPS_PROXY`
2. `HTTPS_PROXY` / `https_proxy`
3. `ALL_PROXY` / `all_proxy`
4. `HTTP_PROXY` / `http_proxy`
5. macOS 系统 HTTP/HTTPS 代理

例如,可以从终端为本次启动显式指定代理:

```bash
GITDROP_HTTPS_PROXY=http://127.0.0.1:1082 \
"/Applications/GitDrop.app/Contents/MacOS/GitDrop"
```

如果使用 Shadowrocket、Clash、Surge 或其他 Fake-IP/TUN 代理,并遇到
`SSL_ERROR_SYSCALL`、`Connection reset by peer` 或 `Recv failure`,请确认:

- 本地代理端口正在监听;
- `github.com` 命中代理而不是 `DIRECT`;
- 当前代理节点可访问 GitHub;
- 未将 `198.18.0.0/15` 中的 Fake-IP 当作真实 GitHub 地址直接访问。

请勿通过关闭 Git 的 `http.sslVerify` 解决连接问题。GitDrop 会保持 TLS 证书验证开启。

## 从源码运行

仓库中提供 `start_gitdrop.bat`(Windows)和 `start_gitdrop.command`(macOS)。启动器会在首次运行时自动安装拖放组件,电脑需要 Python 3 和 Git。
Expand Down
146 changes: 145 additions & 1 deletion gitdrop/git_sync.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@
import subprocess
import sys
import time
import urllib.parse
import urllib.request
from dataclasses import dataclass
from datetime import datetime
from pathlib import Path
Expand All @@ -24,6 +26,109 @@
CACHE_DIR = APP_DIR / ".gitdrop-cache"


def _normalise_proxy_url(proxy: str | None) -> str | None:
if not proxy:
return None

proxy = proxy.strip()
if not proxy:
return None

if "://" not in proxy:
proxy = f"http://{proxy}"

return proxy


def _detect_proxy() -> str | None:
"""Return the proxy GitDrop should pass to Git."""
environment_names = (
"GITDROP_HTTPS_PROXY",
"HTTPS_PROXY",
"https_proxy",
"ALL_PROXY",
"all_proxy",
"HTTP_PROXY",
"http_proxy",
)

for name in environment_names:
proxy = _normalise_proxy_url(os.environ.get(name))
if proxy:
return proxy

if platform.system() != "Darwin":
return None

try:
if urllib.request.proxy_bypass("github.com"):
return None
proxies = urllib.request.getproxies()
except (OSError, ValueError):
return None

return _normalise_proxy_url(
proxies.get("https") or proxies.get("http") or proxies.get("all")
)


def _proxy_display_name(proxy: str) -> str:
"""Return a diagnostic-safe proxy URL without credentials or path data."""
try:
parsed = urllib.parse.urlsplit(proxy)
if not parsed.hostname:
return "已配置代理"
host = f"[{parsed.hostname}]" if ":" in parsed.hostname else parsed.hostname
port = f":{parsed.port}" if parsed.port is not None else ""
return f"{parsed.scheme}://{host}{port}"
except ValueError:
return "已配置代理"


def _proxy_has_credentials(proxy: str) -> bool:
"""Return whether a proxy URL contains user information."""
try:
parsed = urllib.parse.urlsplit(proxy)
return parsed.username is not None or parsed.password is not None
except ValueError:
return "@" in proxy


def _redact_proxy_credentials(detail: str, proxy: str | None) -> str:
"""Remove proxy credentials and their encoded forms from Git output."""
if not proxy or not _proxy_has_credentials(proxy):
return detail

try:
parsed = urllib.parse.urlsplit(proxy)
except ValueError:
parsed = None

sensitive_values = {proxy, urllib.parse.unquote(proxy)}
if parsed is not None:
if "@" in parsed.netloc:
user_info = parsed.netloc.rsplit("@", 1)[0]
sensitive_values.update((user_info, urllib.parse.unquote(user_info)))
for credential in (parsed.username, parsed.password):
if credential:
sensitive_values.update(
(credential, urllib.parse.unquote(credential))
)

for value in tuple(sensitive_values):
sensitive_values.update(
(
urllib.parse.quote(value, safe=""),
urllib.parse.quote_plus(value, safe=""),
)
)

for value in sorted(filter(None, sensitive_values), key=len, reverse=True):
detail = detail.replace(value, "[REDACTED_PROXY_CREDENTIALS]")

return detail


class GitSyncError(RuntimeError):
pass

Expand All @@ -42,12 +147,18 @@ def __init__(
repository: str,
branch: str = "main",
cache_dir: Path | None = None,
proxy_url: str | None = None,
):
self.token = token.strip()
self.owner = owner.strip()
self.repository = repository.strip()
self.branch = branch.strip() or "main"
self.cache_dir = cache_dir or CACHE_DIR
self.proxy_url = (
_normalise_proxy_url(proxy_url)
if proxy_url is not None
else _detect_proxy()
)

@property
def repository_url(self) -> str:
Expand All @@ -66,6 +177,15 @@ def _environment(self) -> dict[str, str]:
"GITDROP_TOKEN": self.token,
}
)
if self.proxy_url:
environment.update(
{
"HTTPS_PROXY": self.proxy_url,
"https_proxy": self.proxy_url,
"HTTP_PROXY": self.proxy_url,
"http_proxy": self.proxy_url,
}
)
return environment

def _askpass_path(self) -> Path:
Expand Down Expand Up @@ -99,7 +219,10 @@ def remove_readonly(function, path, _error) -> None:
time.sleep(0.15)

def _run(self, arguments: list[str], cwd: Path | None = None, timeout: int = 180) -> str:
command = ["git", *arguments]
command = ["git"]
if self.proxy_url and not _proxy_has_credentials(self.proxy_url):
command.extend(["-c", f"http.proxy={self.proxy_url}"])
command.extend(arguments)
try:
result = subprocess.run(
command,
Expand All @@ -117,8 +240,29 @@ def _run(self, arguments: list[str], cwd: Path | None = None, timeout: int = 180
raise GitSyncError("连接 GitHub 超时,请检查网络后重试") from exc
if result.returncode != 0:
detail = (result.stderr or result.stdout).strip()
detail = _redact_proxy_credentials(detail, self.proxy_url)
if self.token:
detail = detail.replace(self.token, "[REDACTED]")
if "Authentication failed" in detail or "could not read Password" in detail:
detail = "Token 无效或没有仓库写入权限"
network_markers = (
"SSL_ERROR_SYSCALL",
"Connection reset by peer",
"Recv failure",
"Could not resolve host",
"Failed to connect",
"Connection timed out",
)
if any(marker.lower() in detail.lower() for marker in network_markers):
proxy_hint = (
f"当前检测到代理:{_proxy_display_name(self.proxy_url)}"
if self.proxy_url
else "未检测到可供 Git 使用的代理"
)
detail = (
f"{detail}\n\n{proxy_hint}。\n"
"请检查系统代理、VPN/代理节点,以及 github.com 是否命中代理规则。"
)
raise GitSyncError(detail or f"Git 命令执行失败:{' '.join(arguments[:2])}")
return result.stdout.strip()

Expand Down
1 change: 1 addition & 0 deletions tests/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
"""GitDrop test suite."""
Loading
Loading