feat: Use mirror git cloning
Test and coverage / build (push) Failing after 54s

This commit is contained in:
2026-06-28 01:58:52 +03:00
parent 846ac52854
commit e06ad4aa4e
5 changed files with 65 additions and 32 deletions
+6
View File
@@ -36,6 +36,12 @@ Example config is available in [config.example.toml](config.example.toml).
Use `main.api_token` for Gitea API access. Use `fetch.ssh_key` for SSH Use `main.api_token` for Gitea API access. Use `fetch.ssh_key` for SSH
cloning, or `fetch.user` and `fetch.token` for HTTP(S) cloning. cloning, or `fetch.user` and `fetch.token` for HTTP(S) cloning.
Repositories are stored as bare mirror clones. On the first run the tool uses
`git clone --mirror <url> <path>`, and on following runs it updates each mirror
with `git remote update --prune`. This mirrors all refs and handles forced
updates from the server side, but the resulting directories are not working
tree checkouts.
### Native ### Native
Not recommended, but more efficient in space Not recommended, but more efficient in space
+12 -8
View File
@@ -2,10 +2,9 @@ import argparse
import logging import logging
import os.path import os.path
import sys import sys
from os import makedirs
from .config import Config, read_toml_config from .config import Config, read_toml_config
from .git import git_clone, git_pull, http_git_env, ssh_git_env from .git import git_clone, git_update_mirror, http_git_env, ssh_git_env
from .gitea_api import GiteaApi, GiteaApiError from .gitea_api import GiteaApi, GiteaApiError
from .log_fields import ( from .log_fields import (
OPERATION, OPERATION,
@@ -43,6 +42,12 @@ def get_fetch_params(config: Config, repo: GiteaRepository):
) )
def is_mirror_repository(path: str) -> bool:
return os.path.isfile(os.path.join(path, "config")) and os.path.isdir(
os.path.join(path, "objects")
)
def process_repo(config: Config, repo: GiteaRepository) -> bool: def process_repo(config: Config, repo: GiteaRepository) -> bool:
out_path = get_repository_path( out_path = get_repository_path(
out_dir=config.out_dir, out_dir=config.out_dir,
@@ -56,15 +61,14 @@ def process_repo(config: Config, repo: GiteaRepository) -> bool:
PATH: out_path, PATH: out_path,
REPOSITORY_URL: repository_url, REPOSITORY_URL: repository_url,
} }
makedirs(out_path, exist_ok=True) if is_mirror_repository(out_path):
if os.path.exists(os.path.join(out_path, ".git")):
logger.info( logger.info(
"repository pull started", "repository mirror update started",
extra=log_context | {OPERATION: "pull"}, extra=log_context | {OPERATION: "update_mirror"},
) )
return git_pull(out_path, env=git_env) return git_update_mirror(out_path, env=git_env)
logger.info( logger.info(
"repository clone started", "repository mirror clone started",
extra=log_context | {OPERATION: "clone"}, extra=log_context | {OPERATION: "clone"},
) )
return git_clone( return git_clone(
+16 -8
View File
@@ -3,7 +3,6 @@ import logging
import os import os
import shlex import shlex
import subprocess import subprocess
from os import makedirs
from .log_fields import EXIT_CODE, OPERATION, PATH, REPOSITORY_URL from .log_fields import EXIT_CODE, OPERATION, PATH, REPOSITORY_URL
@@ -34,12 +33,14 @@ def git_clone(
repository: str, repository: str,
env: dict[str, str] | None = None, env: dict[str, str] | None = None,
) -> bool: ) -> bool:
makedirs(repository, exist_ok=True) repository_parent = os.path.dirname(repository)
if repository_parent:
os.makedirs(repository_parent, exist_ok=True)
git_env = os.environ | (env or {}) git_env = os.environ | (env or {})
try: try:
subprocess.check_call( subprocess.check_call(
["git", "clone", repository_url, "."], ["git", "clone", "--mirror", repository_url, repository],
cwd=repository, cwd=None,
env=git_env, env=git_env,
) )
except subprocess.CalledProcessError as err: except subprocess.CalledProcessError as err:
@@ -56,15 +57,22 @@ def git_clone(
return True return True
def git_pull(repository: str, env: dict[str, str] | None = None) -> bool: def git_update_mirror(
repository: str,
env: dict[str, str] | None = None,
) -> bool:
git_env = os.environ | (env or {}) git_env = os.environ | (env or {})
try: try:
subprocess.check_call(["git", "pull"], cwd=repository, env=git_env) subprocess.check_call(
["git", "remote", "update", "--prune"],
cwd=repository,
env=git_env,
)
except subprocess.CalledProcessError as err: except subprocess.CalledProcessError as err:
logger.error( logger.error(
"git pull failed", "git mirror update failed",
extra={ extra={
OPERATION: "pull", OPERATION: "update_mirror",
PATH: repository, PATH: repository,
EXIT_CODE: err.returncode, EXIT_CODE: err.returncode,
}, },
+13 -7
View File
@@ -2,7 +2,7 @@ import base64
import os import os
import subprocess import subprocess
from src.git import git_clone, git_pull, http_git_env, ssh_git_env from src.git import git_clone, git_update_mirror, http_git_env, ssh_git_env
def test_ssh_git_env_uses_requested_identity_file(): def test_ssh_git_env_uses_requested_identity_file():
@@ -32,7 +32,7 @@ def test_http_git_env_uses_basic_auth_header_without_url_credentials():
} }
def test_git_clone_passes_url_and_env_to_git(monkeypatch, tmp_path): def test_git_clone_uses_mirror_clone(monkeypatch, tmp_path):
calls = [] calls = []
env = {"GIT_SSH_COMMAND": "ssh -i /tmp/test_key -o IdentitiesOnly=yes"} env = {"GIT_SSH_COMMAND": "ssh -i /tmp/test_key -o IdentitiesOnly=yes"}
@@ -48,13 +48,19 @@ def test_git_clone_passes_url_and_env_to_git(monkeypatch, tmp_path):
) )
args, cwd, actual_env = calls[0] args, cwd, actual_env = calls[0]
assert args == ["git", "clone", "ssh://git@example.com/owner/repo.git", "."] assert args == [
assert cwd == str(tmp_path) "git",
"clone",
"--mirror",
"ssh://git@example.com/owner/repo.git",
str(tmp_path),
]
assert cwd is None
assert os.environ.items() <= actual_env.items() assert os.environ.items() <= actual_env.items()
assert env.items() <= actual_env.items() assert env.items() <= actual_env.items()
def test_git_pull_passes_env_to_git(monkeypatch, tmp_path): def test_git_update_mirror_prunes_remote_refs(monkeypatch, tmp_path):
calls = [] calls = []
env = {"GIT_CONFIG_COUNT": "1"} env = {"GIT_CONFIG_COUNT": "1"}
@@ -63,10 +69,10 @@ def test_git_pull_passes_env_to_git(monkeypatch, tmp_path):
monkeypatch.setattr(subprocess, "check_call", fake_check_call) monkeypatch.setattr(subprocess, "check_call", fake_check_call)
assert git_pull(repository=str(tmp_path), env=env) assert git_update_mirror(repository=str(tmp_path), env=env)
args, cwd, actual_env = calls[0] args, cwd, actual_env = calls[0]
assert args == ["git", "pull"] assert args == ["git", "remote", "update", "--prune"]
assert cwd == str(tmp_path) assert cwd == str(tmp_path)
assert os.environ.items() <= actual_env.items() assert os.environ.items() <= actual_env.items()
assert env.items() <= actual_env.items() assert env.items() <= actual_env.items()
+18 -9
View File
@@ -79,16 +79,20 @@ def test_process_repo_clones_with_https_url_and_auth_env(monkeypatch, tmp_path):
assert env["GIT_CONFIG_VALUE_0"].startswith("Authorization: Basic ") assert env["GIT_CONFIG_VALUE_0"].startswith("Authorization: Basic ")
def test_process_repo_pulls_existing_repo_with_fetch_env(monkeypatch, tmp_path): def test_process_repo_updates_existing_mirror_with_fetch_env(
monkeypatch,
tmp_path,
):
calls = [] calls = []
repo_path = tmp_path / "alex" / "repo" / ".git" repo_path = tmp_path / "alex" / "repo"
repo_path.mkdir(parents=True) (repo_path / "objects").mkdir(parents=True)
(repo_path / "config").write_text("[core]\nrepositoryformatversion = 0\n")
def fake_git_pull(repository, env): def fake_git_update_mirror(repository, env):
calls.append((repository, env)) calls.append((repository, env))
return True return True
monkeypatch.setattr(cli, "git_pull", fake_git_pull) monkeypatch.setattr(cli, "git_update_mirror", fake_git_update_mirror)
assert cli.process_repo( assert cli.process_repo(
config=make_config(tmp_path, FetchConfig(ssh_key="/tmp/test_key")), config=make_config(tmp_path, FetchConfig(ssh_key="/tmp/test_key")),
@@ -116,10 +120,15 @@ def test_process_repo_returns_false_when_clone_fails(monkeypatch, tmp_path):
) )
def test_process_repo_returns_false_when_pull_fails(monkeypatch, tmp_path): def test_process_repo_returns_false_when_update_fails(monkeypatch, tmp_path):
repo_path = tmp_path / "alex" / "repo" / ".git" repo_path = tmp_path / "alex" / "repo"
repo_path.mkdir(parents=True) (repo_path / "objects").mkdir(parents=True)
monkeypatch.setattr(cli, "git_pull", lambda repository, env: False) (repo_path / "config").write_text("[core]\nrepositoryformatversion = 0\n")
monkeypatch.setattr(
cli,
"git_update_mirror",
lambda repository, env: False,
)
assert not cli.process_repo( assert not cli.process_repo(
config=make_config(tmp_path, FetchConfig(ssh_key="/tmp/test_key")), config=make_config(tmp_path, FetchConfig(ssh_key="/tmp/test_key")),