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.
@@ -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
@@ -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.
@@ -0,0 +1,3 @@
1
+ # Claude Code context
2
+
3
+ See [AGENTS.md](AGENTS.md) for repo overview.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: polygit
3
- Version: 0.3.1
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
- pip install polygit
39
+ uv tool install polygit
40
40
  ```
41
41
 
42
42
  ## Usage
@@ -3,7 +3,7 @@
3
3
  Manage multiple git repos from a single `.pgit` config file.
4
4
 
5
5
  ```bash
6
- pip install polygit
6
+ uv tool install polygit
7
7
  ```
8
8
 
9
9
  ## Usage
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "polygit"
7
- version = "0.3.1"
7
+ version = "0.3.3"
8
8
  description = "Manage multiple git repos from a single .pgit config file."
9
9
  readme = "README.md"
10
10
  license = { file = "LICENSE.txt" }
@@ -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 = subprocess.run(["git", "-C", str(d), "push"], capture_output=True, text=True)
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 = subprocess.run(
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 = subprocess.run(
129
+ result = _run_git(
98
130
  ["git", "clone", url, str(d)],
99
- capture_output=True, text=True
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 = subprocess.run(
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 result.stdout.strip() != "Already up to date.":
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
- r_parts = remote_path.split("/")
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
- local_ns = "/".join(local_parts[:-1])
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():
@@ -90,7 +90,7 @@ wheels = [
90
90
 
91
91
  [[package]]
92
92
  name = "polygit"
93
- version = "0.1.0"
93
+ version = "0.3.3"
94
94
  source = { editable = "." }
95
95
  dependencies = [
96
96
  { name = "pyyaml" },
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes