polygit 0.3.1__tar.gz → 0.3.3__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {polygit-0.3.1 → polygit-0.3.3}/.gitlab-ci.yml +13 -0
- polygit-0.3.3/AGENTS.md +15 -0
- polygit-0.3.3/CLAUDE.md +3 -0
- {polygit-0.3.1 → polygit-0.3.3}/PKG-INFO +2 -2
- {polygit-0.3.1 → polygit-0.3.3}/README.md +1 -1
- {polygit-0.3.1 → polygit-0.3.3}/pyproject.toml +1 -1
- {polygit-0.3.1 → polygit-0.3.3}/src/polygit/repo.py +41 -12
- {polygit-0.3.1 → polygit-0.3.3}/src/polygit/repos.py +12 -16
- {polygit-0.3.1 → polygit-0.3.3}/tests/test_polygit.py +21 -1
- {polygit-0.3.1 → polygit-0.3.3}/uv.lock +1 -1
- {polygit-0.3.1 → polygit-0.3.3}/.gitignore +0 -0
- {polygit-0.3.1 → polygit-0.3.3}/LICENSE.txt +0 -0
- {polygit-0.3.1 → polygit-0.3.3}/src/polygit/__init__.py +0 -0
- {polygit-0.3.1 → polygit-0.3.3}/src/polygit/cli.py +0 -0
- {polygit-0.3.1 → polygit-0.3.3}/src/polygit/config.py +0 -0
- {polygit-0.3.1 → polygit-0.3.3}/tests/__init__.py +0 -0
|
@@ -1,3 +1,8 @@
|
|
|
1
|
+
include:
|
|
2
|
+
- project: "gary.schaetz/private/pipelines"
|
|
3
|
+
ref: main
|
|
4
|
+
file: 'templates/rules/rules.yml'
|
|
5
|
+
|
|
1
6
|
stages:
|
|
2
7
|
- test
|
|
3
8
|
- build
|
|
@@ -12,6 +17,14 @@ test:
|
|
|
12
17
|
tags:
|
|
13
18
|
- docker
|
|
14
19
|
- kubernetes
|
|
20
|
+
# Explicit rules instead of relying on GitLab's implicit default (only: [branches, tags]):
|
|
21
|
+
# that default happens to exclude merge_request_event already, but making it explicit here
|
|
22
|
+
# matches the pattern every other repo running on these self-hosted runners uses, rather
|
|
23
|
+
# than depending on undocumented default behavior for a job that runs arbitrary repo code.
|
|
24
|
+
rules:
|
|
25
|
+
- !reference [.job_rules, standard-rule]
|
|
26
|
+
- !reference [.job_rules, manual-rule]
|
|
27
|
+
- !reference [.job_rules, schedule-rule]
|
|
15
28
|
script:
|
|
16
29
|
- uv sync
|
|
17
30
|
- uv run pytest
|
polygit-0.3.3/AGENTS.md
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# Repo context for AI agents
|
|
2
|
+
|
|
3
|
+
Published Python package (`pip install polygit`, installed via `uv tool install` per the
|
|
4
|
+
user's `setup-new-machine.sh` bootstrap docs — see project memory) providing the `pgit` CLI:
|
|
5
|
+
manages multiple git repos (across GitHub/GitLab, multiple orgs/namespaces) from a single
|
|
6
|
+
`.pgit` config file. `.pgit` is discovered by walking up from the current directory, so
|
|
7
|
+
`pgit` commands work from anywhere inside the repos root — this is in fact what manages the
|
|
8
|
+
very `~/development/gitlab` tree these AGENTS.md files live in.
|
|
9
|
+
|
|
10
|
+
Standard `src`-layout Python package (`pyproject.toml`, `uv.lock` — uses `uv`, not
|
|
11
|
+
pip/poetry, for dev dependency management). `.gitlab-ci.yml` presumably builds/publishes on
|
|
12
|
+
tag, mirroring `gitlab-ci-generator`'s pattern.
|
|
13
|
+
|
|
14
|
+
Replaced an older `sync.sh` script (see recent commit history) — if you find references to
|
|
15
|
+
`sync.sh` elsewhere in the user's dotfiles/bootstrap scripts, they're stale.
|
polygit-0.3.3/CLAUDE.md
ADDED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: polygit
|
|
3
|
-
Version: 0.3.
|
|
3
|
+
Version: 0.3.3
|
|
4
4
|
Summary: Manage multiple git repos from a single .pgit config file.
|
|
5
5
|
Project-URL: Repository, https://gitlab.com/gary.schaetz/public/polygit
|
|
6
6
|
Author-email: Gary Schaetz <gary@schaetzkc.com>
|
|
@@ -36,7 +36,7 @@ Description-Content-Type: text/markdown
|
|
|
36
36
|
Manage multiple git repos from a single `.pgit` config file.
|
|
37
37
|
|
|
38
38
|
```bash
|
|
39
|
-
|
|
39
|
+
uv tool install polygit
|
|
40
40
|
```
|
|
41
41
|
|
|
42
42
|
## Usage
|
|
@@ -1,6 +1,8 @@
|
|
|
1
|
+
import shutil
|
|
1
2
|
import subprocess
|
|
2
3
|
import sys
|
|
3
4
|
import threading
|
|
5
|
+
import time
|
|
4
6
|
from concurrent.futures import ThreadPoolExecutor, as_completed
|
|
5
7
|
from datetime import datetime
|
|
6
8
|
from pathlib import Path
|
|
@@ -9,6 +11,39 @@ from .config import HOSTS
|
|
|
9
11
|
_print_lock = threading.Lock()
|
|
10
12
|
MAX_WORKERS = 8
|
|
11
13
|
|
|
14
|
+
# Errors seen when the SSH server drops a connection under concurrent load
|
|
15
|
+
# rather than a real failure — safe to retry.
|
|
16
|
+
_TRANSIENT_ERR_MARKERS = (
|
|
17
|
+
"kex_exchange_identification",
|
|
18
|
+
"Connection reset by peer",
|
|
19
|
+
"Connection timed out",
|
|
20
|
+
"Could not read from remote repository",
|
|
21
|
+
"early EOF",
|
|
22
|
+
"The remote end hung up unexpectedly",
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _is_transient(stderr):
|
|
27
|
+
return any(marker in stderr for marker in _TRANSIENT_ERR_MARKERS)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _run_git(args, retries=2, delay=1.5, cleanup=None):
|
|
31
|
+
"""Run a git subprocess, retrying on transient SSH/network errors.
|
|
32
|
+
|
|
33
|
+
`cleanup`, if given, runs before each retry (e.g. to remove a partial
|
|
34
|
+
clone directory left behind by the failed attempt).
|
|
35
|
+
"""
|
|
36
|
+
for attempt in range(retries + 1):
|
|
37
|
+
result = subprocess.run(args, capture_output=True, text=True)
|
|
38
|
+
if result.returncode == 0 or not _is_transient(result.stderr):
|
|
39
|
+
return result
|
|
40
|
+
if attempt < retries:
|
|
41
|
+
if cleanup:
|
|
42
|
+
cleanup()
|
|
43
|
+
time.sleep(delay * (attempt + 1))
|
|
44
|
+
return result
|
|
45
|
+
|
|
46
|
+
|
|
12
47
|
_USE_COLOR = sys.stdout.isatty()
|
|
13
48
|
|
|
14
49
|
_G = "\033[32m" if _USE_COLOR else "" # green
|
|
@@ -72,7 +107,7 @@ def _commit_and_push(d, label, msg):
|
|
|
72
107
|
lines = [f" Committing {label}..."]
|
|
73
108
|
subprocess.run(["git", "-C", str(d), "add", "-A"], capture_output=True)
|
|
74
109
|
subprocess.run(["git", "-C", str(d), "commit", "-m", msg], capture_output=True)
|
|
75
|
-
result =
|
|
110
|
+
result = _run_git(["git", "-C", str(d), "push"])
|
|
76
111
|
if result.returncode != 0:
|
|
77
112
|
lines.append(f"{ERR} {label}: push failed — {result.stderr.strip().splitlines()[0]}")
|
|
78
113
|
else:
|
|
@@ -84,19 +119,16 @@ def _sync_one(platform, local_path, remote_path, d):
|
|
|
84
119
|
url = f"{HOSTS[platform]}:{remote_path}.git"
|
|
85
120
|
label = f"{platform}/{local_path}"
|
|
86
121
|
if (d / ".git").exists():
|
|
87
|
-
result =
|
|
88
|
-
["git", "-C", str(d), "fetch", "--all", "-q"],
|
|
89
|
-
capture_output=True, text=True
|
|
90
|
-
)
|
|
122
|
+
result = _run_git(["git", "-C", str(d), "fetch", "--all", "-q"])
|
|
91
123
|
if result.returncode != 0:
|
|
92
124
|
lines = [f"{ERR} {label}: {result.stderr.strip().splitlines()[0]}"]
|
|
93
125
|
else:
|
|
94
126
|
lines = [f"{OK} {_DIM}{label}{_RST}"]
|
|
95
127
|
else:
|
|
96
128
|
d.parent.mkdir(parents=True, exist_ok=True)
|
|
97
|
-
result =
|
|
129
|
+
result = _run_git(
|
|
98
130
|
["git", "clone", url, str(d)],
|
|
99
|
-
|
|
131
|
+
cleanup=lambda: shutil.rmtree(d, ignore_errors=True),
|
|
100
132
|
)
|
|
101
133
|
if result.returncode != 0:
|
|
102
134
|
lines = [f"{ERR} {label}: {result.stderr.strip().splitlines()[0]}"]
|
|
@@ -112,17 +144,14 @@ def _pull_one(platform, local_path, d):
|
|
|
112
144
|
if is_dirty(d):
|
|
113
145
|
subprocess.run(["git", "-C", str(d), "stash"], capture_output=True)
|
|
114
146
|
stashed = True
|
|
115
|
-
result =
|
|
116
|
-
["git", "-C", str(d), "pull", "--rebase"],
|
|
117
|
-
capture_output=True, text=True
|
|
118
|
-
)
|
|
147
|
+
result = _run_git(["git", "-C", str(d), "pull", "--rebase"])
|
|
119
148
|
if result.returncode != 0:
|
|
120
149
|
err = result.stderr.strip()
|
|
121
150
|
if "no tracking information" in err or "no upstream" in err.lower():
|
|
122
151
|
lines.append(f"{WARN} {label}: no upstream branch set")
|
|
123
152
|
else:
|
|
124
153
|
lines.append(f"{ERR} {label}: {err.splitlines()[0]}")
|
|
125
|
-
elif result.stdout.strip() and
|
|
154
|
+
elif result.stdout.strip() and "up to date" not in result.stdout.lower():
|
|
126
155
|
lines.append(f"{UPD} {label}:")
|
|
127
156
|
for ln in result.stdout.strip().splitlines():
|
|
128
157
|
lines.append(f" {ln}")
|
|
@@ -40,28 +40,24 @@ def del_nested(d, keys):
|
|
|
40
40
|
|
|
41
41
|
|
|
42
42
|
def _add_to_config(config, fs_platform, local_path, remote_url):
|
|
43
|
-
"""Insert a repo entry into the config data structure.
|
|
43
|
+
"""Insert a repo entry into the config data structure.
|
|
44
|
+
|
|
45
|
+
Always nests under the *local* namespace (the repo's actual directory
|
|
46
|
+
location), with a `{remote: ...}` override when the remote path differs
|
|
47
|
+
— e.g. a repo cloned under a local namespace unrelated to its remote
|
|
48
|
+
org/namespace.
|
|
49
|
+
"""
|
|
44
50
|
local_parts = local_path.split("/")
|
|
45
51
|
local_name = local_parts[-1]
|
|
52
|
+
local_ns = "/".join(local_parts[:-1])
|
|
46
53
|
_, remote_path = parse_remote_url(remote_url)
|
|
47
54
|
platform_data = config.setdefault(fs_platform, {})
|
|
55
|
+
ns_data = platform_data.setdefault(local_ns, {})
|
|
48
56
|
|
|
49
|
-
if remote_path:
|
|
50
|
-
|
|
51
|
-
r_name = r_parts[-1]
|
|
52
|
-
r_ns = "/".join(r_parts[:-1])
|
|
53
|
-
ns_data = platform_data.setdefault(r_ns, {})
|
|
54
|
-
if local_path == f"{r_ns}/{r_name}":
|
|
55
|
-
ns_data[r_name] = None
|
|
56
|
-
else:
|
|
57
|
-
sub = local_path[len(r_ns)+1:] if local_path.startswith(r_ns + "/") else local_name
|
|
58
|
-
node = ns_data
|
|
59
|
-
for part in sub.split("/")[:-1]:
|
|
60
|
-
node = node.setdefault(part, {})
|
|
61
|
-
node[sub.split("/")[-1]] = {"remote": remote_path}
|
|
57
|
+
if remote_path and remote_path != local_path:
|
|
58
|
+
ns_data[local_name] = {"remote": remote_path}
|
|
62
59
|
else:
|
|
63
|
-
|
|
64
|
-
platform_data.setdefault(local_ns, {})[local_name] = None
|
|
60
|
+
ns_data[local_name] = None
|
|
65
61
|
|
|
66
62
|
|
|
67
63
|
def cmd_repos(config, base, config_path):
|
|
@@ -2,7 +2,7 @@ import tempfile
|
|
|
2
2
|
import pytest
|
|
3
3
|
from pathlib import Path
|
|
4
4
|
from polygit.config import find_config, load_config, write_config
|
|
5
|
-
from polygit.repos import parse_remote_url, del_nested
|
|
5
|
+
from polygit.repos import parse_remote_url, del_nested, _add_to_config
|
|
6
6
|
from polygit.repo import walk
|
|
7
7
|
|
|
8
8
|
|
|
@@ -59,6 +59,26 @@ def test_del_nested_preserves_siblings():
|
|
|
59
59
|
assert d == {"a": {"c": None}}
|
|
60
60
|
|
|
61
61
|
|
|
62
|
+
# ── _add_to_config ──────────────────────────────────────────────────────────
|
|
63
|
+
|
|
64
|
+
def test_add_to_config_plain():
|
|
65
|
+
config = {}
|
|
66
|
+
_add_to_config(config, "github", "gschaetz/mousehat", "git@github.com:gschaetz/mousehat.git")
|
|
67
|
+
assert config == {"github": {"gschaetz": {"mousehat": None}}}
|
|
68
|
+
|
|
69
|
+
def test_add_to_config_cross_namespace_override():
|
|
70
|
+
# Local dir lives under a namespace unrelated to the remote org — must
|
|
71
|
+
# nest under the local namespace, not silently fall back to the remote's.
|
|
72
|
+
config = {}
|
|
73
|
+
_add_to_config(config, "github", "community/modelrelay", "git@github.com:ellipticmarketing/modelrelay.git")
|
|
74
|
+
assert config == {"github": {"community": {"modelrelay": {"remote": "ellipticmarketing/modelrelay"}}}}
|
|
75
|
+
|
|
76
|
+
def test_add_to_config_no_remote():
|
|
77
|
+
config = {}
|
|
78
|
+
_add_to_config(config, "github", "gschaetz/scratch", None)
|
|
79
|
+
assert config == {"github": {"gschaetz": {"scratch": None}}}
|
|
80
|
+
|
|
81
|
+
|
|
62
82
|
# ── find_config / write_config / load_config ──────────────────────────────────
|
|
63
83
|
|
|
64
84
|
def test_find_config_in_cwd():
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|