polygit 0.1.0__py3-none-any.whl
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/__init__.py +0 -0
- polygit/cli.py +57 -0
- polygit/config.py +46 -0
- polygit/repo.py +151 -0
- polygit/repos.py +204 -0
- polygit-0.1.0.dist-info/METADATA +108 -0
- polygit-0.1.0.dist-info/RECORD +10 -0
- polygit-0.1.0.dist-info/WHEEL +4 -0
- polygit-0.1.0.dist-info/entry_points.txt +2 -0
- polygit-0.1.0.dist-info/licenses/LICENSE.txt +21 -0
polygit/__init__.py
ADDED
|
File without changes
|
polygit/cli.py
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import sys
|
|
2
|
+
import argparse
|
|
3
|
+
from .config import find_config, load_config
|
|
4
|
+
from .repo import cmd_sync, cmd_status, cmd_pull, cmd_push
|
|
5
|
+
from .repos import cmd_repos
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def main():
|
|
9
|
+
parser = argparse.ArgumentParser(
|
|
10
|
+
prog="pgit",
|
|
11
|
+
description="Manage multiple git repos defined in a .pgit config file."
|
|
12
|
+
)
|
|
13
|
+
sub = parser.add_subparsers(dest="command", metavar="command")
|
|
14
|
+
|
|
15
|
+
sub.add_parser("sync", help="Clone missing repos and fetch all")
|
|
16
|
+
sub.add_parser("status", help="Show repos with uncommitted changes")
|
|
17
|
+
sub.add_parser("pull", help="Pull all repos, auto-stashing local changes")
|
|
18
|
+
|
|
19
|
+
push_p = sub.add_parser("push", help="Commit and push dirty repos")
|
|
20
|
+
mode_g = push_p.add_mutually_exclusive_group()
|
|
21
|
+
mode_g.add_argument("--interactive", dest="push_mode", action="store_const",
|
|
22
|
+
const="interactive", help="Prompt per repo (default)")
|
|
23
|
+
mode_g.add_argument("--all", dest="push_mode", action="store_const",
|
|
24
|
+
const="all", help="One commit message for all dirty repos")
|
|
25
|
+
mode_g.add_argument("--wip", dest="push_mode", action="store_const",
|
|
26
|
+
const="wip", help='Auto-commit with "WIP <timestamp>"')
|
|
27
|
+
push_p.set_defaults(push_mode="interactive")
|
|
28
|
+
|
|
29
|
+
sub.add_parser("repos", help="Discover drift between .pgit and the filesystem")
|
|
30
|
+
|
|
31
|
+
args = parser.parse_args()
|
|
32
|
+
|
|
33
|
+
if not args.command:
|
|
34
|
+
parser.print_help()
|
|
35
|
+
sys.exit(0)
|
|
36
|
+
|
|
37
|
+
config_path = find_config()
|
|
38
|
+
if config_path is None:
|
|
39
|
+
print("Error: no .pgit config file found in this directory or any parent.", file=sys.stderr)
|
|
40
|
+
print("Create a .pgit file in your repos root directory.", file=sys.stderr)
|
|
41
|
+
sys.exit(1)
|
|
42
|
+
|
|
43
|
+
base = config_path.parent
|
|
44
|
+
config = load_config(config_path)
|
|
45
|
+
|
|
46
|
+
commands = {
|
|
47
|
+
"sync": lambda: cmd_sync(config, base),
|
|
48
|
+
"status": lambda: cmd_status(config, base),
|
|
49
|
+
"pull": lambda: cmd_pull(config, base),
|
|
50
|
+
"push": lambda: cmd_push(config, base, mode=args.push_mode),
|
|
51
|
+
"repos": lambda: cmd_repos(config, base, config_path),
|
|
52
|
+
}
|
|
53
|
+
commands[args.command]()
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
if __name__ == "__main__":
|
|
57
|
+
main()
|
polygit/config.py
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import yaml
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
|
|
4
|
+
HOSTS = {
|
|
5
|
+
"github": "git@github.com",
|
|
6
|
+
"gitlab": "git@gitlab.com",
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
CONFIG_FILE = ".pgit"
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def find_config(start=None):
|
|
13
|
+
"""Walk up from start (default: cwd) to find a .pgit config file."""
|
|
14
|
+
current = Path(start or Path.cwd())
|
|
15
|
+
for path in [current] + list(current.parents):
|
|
16
|
+
candidate = path / CONFIG_FILE
|
|
17
|
+
if candidate.exists():
|
|
18
|
+
return candidate
|
|
19
|
+
return None
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def load_config(path):
|
|
23
|
+
"""Load and return the .pgit YAML config."""
|
|
24
|
+
with open(path) as f:
|
|
25
|
+
return yaml.safe_load(f) or {}
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def write_config(data, path):
|
|
29
|
+
"""Write .pgit config using only stdlib — preserves null and inline-remote styles."""
|
|
30
|
+
def _lines(node, indent):
|
|
31
|
+
prefix = " " * indent
|
|
32
|
+
out = []
|
|
33
|
+
for key, value in node.items():
|
|
34
|
+
if value is None:
|
|
35
|
+
out.append(f"{prefix}{key}:")
|
|
36
|
+
elif isinstance(value, dict) and set(value.keys()) == {"remote"}:
|
|
37
|
+
out.append(f'{prefix}{key}: {{remote: "{value["remote"]}"}}' )
|
|
38
|
+
elif isinstance(value, dict):
|
|
39
|
+
out.append(f"{prefix}{key}:")
|
|
40
|
+
out.extend(_lines(value, indent + 1))
|
|
41
|
+
else:
|
|
42
|
+
out.append(f"{prefix}{key}: {value}")
|
|
43
|
+
return out
|
|
44
|
+
|
|
45
|
+
with open(path, "w") as f:
|
|
46
|
+
f.write("\n".join(_lines(data, 0)) + "\n")
|
polygit/repo.py
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import subprocess
|
|
2
|
+
from datetime import datetime
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from .config import HOSTS
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def walk(node, prefix):
|
|
8
|
+
"""Yield (local_path, remote_path) for every repo in the tree."""
|
|
9
|
+
if node is None:
|
|
10
|
+
yield prefix, prefix
|
|
11
|
+
elif isinstance(node, dict) and set(node.keys()) == {"remote"}:
|
|
12
|
+
yield prefix, node["remote"]
|
|
13
|
+
elif isinstance(node, dict):
|
|
14
|
+
for key, value in node.items():
|
|
15
|
+
yield from walk(value, f"{prefix}/{key}")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def each_repo(config, base):
|
|
19
|
+
"""Yield (platform, local_path, remote_path, dir) for all configured repos."""
|
|
20
|
+
for platform, namespaces in config.items():
|
|
21
|
+
if platform not in HOSTS:
|
|
22
|
+
continue
|
|
23
|
+
for ns_key, ns_value in namespaces.items():
|
|
24
|
+
for local_path, remote_path in walk(ns_value, ns_key):
|
|
25
|
+
yield platform, local_path, remote_path, base / platform / local_path
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def each_existing_repo(config, base):
|
|
29
|
+
"""Yield only repos that are already cloned locally."""
|
|
30
|
+
for platform, local_path, remote_path, d in each_repo(config, base):
|
|
31
|
+
if (d / ".git").exists():
|
|
32
|
+
yield platform, local_path, remote_path, d
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def is_dirty(d):
|
|
36
|
+
result = subprocess.run(
|
|
37
|
+
["git", "-C", str(d), "status", "--porcelain"],
|
|
38
|
+
capture_output=True, text=True
|
|
39
|
+
)
|
|
40
|
+
return bool(result.stdout.strip())
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _commit_and_push(d, label, msg):
|
|
44
|
+
print(f"Committing {label}...")
|
|
45
|
+
subprocess.run(["git", "-C", str(d), "add", "-A"])
|
|
46
|
+
subprocess.run(["git", "-C", str(d), "commit", "-m", msg])
|
|
47
|
+
result = subprocess.run(["git", "-C", str(d), "push"], capture_output=True, text=True)
|
|
48
|
+
if result.returncode != 0:
|
|
49
|
+
print(f" Push failed: {result.stderr.strip()}")
|
|
50
|
+
else:
|
|
51
|
+
print(f" Pushed.")
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def cmd_sync(config, base):
|
|
55
|
+
for platform, local_path, remote_path, d in each_repo(config, base):
|
|
56
|
+
url = f"{HOSTS[platform]}:{remote_path}.git"
|
|
57
|
+
if (d / ".git").exists():
|
|
58
|
+
print(f"Fetching {platform}/{local_path}...")
|
|
59
|
+
subprocess.run(["git", "-C", str(d), "fetch", "--all", "-q"])
|
|
60
|
+
else:
|
|
61
|
+
print(f"Cloning {platform}/{local_path}...")
|
|
62
|
+
d.parent.mkdir(parents=True, exist_ok=True)
|
|
63
|
+
subprocess.run(["git", "clone", url, str(d)])
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def cmd_status(config, base):
|
|
67
|
+
found_dirty = False
|
|
68
|
+
for platform, local_path, _, d in each_existing_repo(config, base):
|
|
69
|
+
result = subprocess.run(
|
|
70
|
+
["git", "-C", str(d), "status", "--short"],
|
|
71
|
+
capture_output=True, text=True
|
|
72
|
+
)
|
|
73
|
+
if result.stdout.strip():
|
|
74
|
+
found_dirty = True
|
|
75
|
+
print(f"\n{platform}/{local_path}:")
|
|
76
|
+
for line in result.stdout.strip().splitlines():
|
|
77
|
+
print(f" {line}")
|
|
78
|
+
if not found_dirty:
|
|
79
|
+
print("All repos are clean.")
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def cmd_pull(config, base):
|
|
83
|
+
for platform, local_path, _, d in each_existing_repo(config, base):
|
|
84
|
+
label = f"{platform}/{local_path}"
|
|
85
|
+
stashed = False
|
|
86
|
+
if is_dirty(d):
|
|
87
|
+
print(f"Stashing {label}...")
|
|
88
|
+
subprocess.run(["git", "-C", str(d), "stash"], capture_output=True)
|
|
89
|
+
stashed = True
|
|
90
|
+
print(f"Pulling {label}...")
|
|
91
|
+
result = subprocess.run(
|
|
92
|
+
["git", "-C", str(d), "pull", "--rebase"],
|
|
93
|
+
capture_output=True, text=True
|
|
94
|
+
)
|
|
95
|
+
if result.returncode != 0:
|
|
96
|
+
print(f" Error: {result.stderr.strip()}")
|
|
97
|
+
elif result.stdout.strip() and result.stdout.strip() != "Already up to date.":
|
|
98
|
+
print(f" {result.stdout.strip()}")
|
|
99
|
+
if stashed:
|
|
100
|
+
pop = subprocess.run(
|
|
101
|
+
["git", "-C", str(d), "stash", "pop"],
|
|
102
|
+
capture_output=True, text=True
|
|
103
|
+
)
|
|
104
|
+
if pop.returncode != 0:
|
|
105
|
+
print(f" Warning: stash pop had conflicts in {label}")
|
|
106
|
+
else:
|
|
107
|
+
print(f" Stash restored.")
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def cmd_push(config, base, mode="interactive"):
|
|
111
|
+
dirty = [
|
|
112
|
+
(platform, local_path, d)
|
|
113
|
+
for platform, local_path, _, d in each_existing_repo(config, base)
|
|
114
|
+
if is_dirty(d)
|
|
115
|
+
]
|
|
116
|
+
|
|
117
|
+
if not dirty:
|
|
118
|
+
print("No dirty repos to commit.")
|
|
119
|
+
return
|
|
120
|
+
|
|
121
|
+
if mode == "wip":
|
|
122
|
+
msg = f"WIP {datetime.now().strftime('%Y-%m-%d %H:%M')}"
|
|
123
|
+
for platform, local_path, d in dirty:
|
|
124
|
+
_commit_and_push(d, f"{platform}/{local_path}", msg)
|
|
125
|
+
|
|
126
|
+
elif mode == "all":
|
|
127
|
+
print("Dirty repos:")
|
|
128
|
+
for platform, local_path, _ in dirty:
|
|
129
|
+
print(f" {platform}/{local_path}")
|
|
130
|
+
msg = input("\nCommit message for all repos (blank to abort): ").strip()
|
|
131
|
+
if not msg:
|
|
132
|
+
print("Aborted.")
|
|
133
|
+
return
|
|
134
|
+
for platform, local_path, d in dirty:
|
|
135
|
+
_commit_and_push(d, f"{platform}/{local_path}", msg)
|
|
136
|
+
|
|
137
|
+
else: # interactive
|
|
138
|
+
for platform, local_path, d in dirty:
|
|
139
|
+
label = f"{platform}/{local_path}"
|
|
140
|
+
status = subprocess.run(
|
|
141
|
+
["git", "-C", str(d), "status", "--short"],
|
|
142
|
+
capture_output=True, text=True
|
|
143
|
+
).stdout.strip()
|
|
144
|
+
print(f"\n{label}:")
|
|
145
|
+
for line in status.splitlines():
|
|
146
|
+
print(f" {line}")
|
|
147
|
+
msg = input(" Commit message (blank to skip): ").strip()
|
|
148
|
+
if not msg:
|
|
149
|
+
print(" Skipped.")
|
|
150
|
+
continue
|
|
151
|
+
_commit_and_push(d, label, msg)
|
polygit/repos.py
ADDED
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
import subprocess
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
from .config import HOSTS, write_config
|
|
4
|
+
from .repo import walk
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def parse_remote_url(url):
|
|
8
|
+
"""Parse a git remote URL into (platform, remote_path).
|
|
9
|
+
|
|
10
|
+
git@github.com:org/repo.git -> (github, org/repo)
|
|
11
|
+
git@gitlab.com:ns/sub/repo.git -> (gitlab, ns/sub/repo)
|
|
12
|
+
https://github.com/org/repo.git -> (github, org/repo)
|
|
13
|
+
"""
|
|
14
|
+
if not url:
|
|
15
|
+
return None, None
|
|
16
|
+
url = url.strip()
|
|
17
|
+
if url.endswith(".git"):
|
|
18
|
+
url = url[:-4]
|
|
19
|
+
if url.startswith("git@"):
|
|
20
|
+
host, path = url[4:].split(":", 1)
|
|
21
|
+
elif "://" in url:
|
|
22
|
+
rest = url.split("://", 1)[1]
|
|
23
|
+
host, path = rest.split("/", 1)
|
|
24
|
+
else:
|
|
25
|
+
return None, None
|
|
26
|
+
platform = "github" if "github" in host else ("gitlab" if "gitlab" in host else None)
|
|
27
|
+
return platform, path
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def del_nested(d, keys):
|
|
31
|
+
"""Delete d[k0][k1]...[kn], pruning empty parent dicts."""
|
|
32
|
+
if not keys or keys[0] not in d:
|
|
33
|
+
return
|
|
34
|
+
if len(keys) == 1:
|
|
35
|
+
del d[keys[0]]
|
|
36
|
+
else:
|
|
37
|
+
del_nested(d[keys[0]], keys[1:])
|
|
38
|
+
if isinstance(d[keys[0]], dict) and not d[keys[0]]:
|
|
39
|
+
del d[keys[0]]
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _add_to_config(config, fs_platform, local_path, remote_url):
|
|
43
|
+
"""Insert a repo entry into the config data structure."""
|
|
44
|
+
local_parts = local_path.split("/")
|
|
45
|
+
local_name = local_parts[-1]
|
|
46
|
+
_, remote_path = parse_remote_url(remote_url)
|
|
47
|
+
platform_data = config.setdefault(fs_platform, {})
|
|
48
|
+
|
|
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}
|
|
62
|
+
else:
|
|
63
|
+
local_ns = "/".join(local_parts[:-1])
|
|
64
|
+
platform_data.setdefault(local_ns, {})[local_name] = None
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def cmd_repos(config, base, config_path):
|
|
68
|
+
"""Diff .pgit config against the filesystem and offer to update."""
|
|
69
|
+
|
|
70
|
+
# ── 1. Scan filesystem ──────────────────────────────────────────────────
|
|
71
|
+
local_repos = {} # "platform/local_path" -> (dir, remote_url)
|
|
72
|
+
for platform in ["github", "gitlab"]:
|
|
73
|
+
platform_dir = base / platform
|
|
74
|
+
if not platform_dir.exists():
|
|
75
|
+
continue
|
|
76
|
+
for git_marker in sorted(platform_dir.rglob(".git")):
|
|
77
|
+
if not git_marker.is_dir():
|
|
78
|
+
continue
|
|
79
|
+
repo_dir = git_marker.parent
|
|
80
|
+
rel = repo_dir.relative_to(platform_dir)
|
|
81
|
+
parts = rel.parts
|
|
82
|
+
# Skip repos nested inside another git repo
|
|
83
|
+
if any((platform_dir.joinpath(*parts[:i]) / ".git").is_dir()
|
|
84
|
+
for i in range(1, len(parts))):
|
|
85
|
+
continue
|
|
86
|
+
result = subprocess.run(
|
|
87
|
+
["git", "-C", str(repo_dir), "remote", "get-url", "origin"],
|
|
88
|
+
capture_output=True, text=True
|
|
89
|
+
)
|
|
90
|
+
remote_url = result.stdout.strip() if result.returncode == 0 else None
|
|
91
|
+
local_repos[f"{platform}/{rel}"] = (repo_dir, remote_url)
|
|
92
|
+
|
|
93
|
+
# ── 2. Build tracked indices ─────────────────────────────────────────────
|
|
94
|
+
tracked_by_local = {} # "platform/local_path" -> (remote_path, expected_url)
|
|
95
|
+
tracked_by_remote = {} # "platform/remote_path" -> "platform/local_path"
|
|
96
|
+
|
|
97
|
+
for platform, namespaces in config.items():
|
|
98
|
+
host = HOSTS.get(platform, "")
|
|
99
|
+
for ns_key, ns_value in namespaces.items():
|
|
100
|
+
for local_path, remote_path in walk(ns_value, ns_key):
|
|
101
|
+
lkey = f"{platform}/{local_path}"
|
|
102
|
+
rkey = f"{platform}/{remote_path}"
|
|
103
|
+
tracked_by_local[lkey] = (remote_path, f"{host}:{remote_path}.git")
|
|
104
|
+
tracked_by_remote[rkey] = lkey
|
|
105
|
+
|
|
106
|
+
# ── 3. Categorize ────────────────────────────────────────────────────────
|
|
107
|
+
untracked = {} # key -> (dir, url)
|
|
108
|
+
wrong_location = {} # key -> (dir, url, config_key)
|
|
109
|
+
remote_mismatch = {} # key -> (expected_url, actual_url)
|
|
110
|
+
missing = {k for k in tracked_by_local if k not in local_repos}
|
|
111
|
+
|
|
112
|
+
for key, (repo_dir, remote_url) in local_repos.items():
|
|
113
|
+
fs_platform = key.split("/")[0]
|
|
114
|
+
_, remote_path = parse_remote_url(remote_url)
|
|
115
|
+
rkey = f"{fs_platform}/{remote_path}" if remote_path else None
|
|
116
|
+
|
|
117
|
+
if key in tracked_by_local:
|
|
118
|
+
_, expected_url = tracked_by_local[key]
|
|
119
|
+
if remote_url and remote_url != expected_url:
|
|
120
|
+
remote_mismatch[key] = (expected_url, remote_url)
|
|
121
|
+
elif rkey and rkey in tracked_by_remote:
|
|
122
|
+
wrong_location[key] = (repo_dir, remote_url, tracked_by_remote[rkey])
|
|
123
|
+
else:
|
|
124
|
+
untracked[key] = (repo_dir, remote_url)
|
|
125
|
+
|
|
126
|
+
# ── 4. Report ─────────────────────────────────────────────────────────────
|
|
127
|
+
if not any([untracked, wrong_location, remote_mismatch, missing]):
|
|
128
|
+
print(".pgit is in sync with the filesystem.")
|
|
129
|
+
return
|
|
130
|
+
|
|
131
|
+
if remote_mismatch:
|
|
132
|
+
print("\nRemote mismatch (local path matches config but git remote differs):")
|
|
133
|
+
for key, (expected, actual) in sorted(remote_mismatch.items()):
|
|
134
|
+
print(f" ~ {key}")
|
|
135
|
+
print(f" config: {expected}")
|
|
136
|
+
print(f" actual: {actual}")
|
|
137
|
+
|
|
138
|
+
if wrong_location:
|
|
139
|
+
print("\nCloned to a different path than .pgit expects:")
|
|
140
|
+
for key, (_, url, config_key) in sorted(wrong_location.items()):
|
|
141
|
+
actual_dir = base / Path(*key.split("/"))
|
|
142
|
+
expected_dir = base / Path(*config_key.split("/"))
|
|
143
|
+
is_rename = actual_dir.parent == expected_dir.parent
|
|
144
|
+
hint = f" (rename: {actual_dir.name} → {expected_dir.name})" if is_rename else ""
|
|
145
|
+
print(f" ? {key}")
|
|
146
|
+
print(f" remote: {url}")
|
|
147
|
+
print(f" expected: {config_key}{hint}")
|
|
148
|
+
|
|
149
|
+
if untracked:
|
|
150
|
+
print("\nOn disk but not in .pgit:")
|
|
151
|
+
for key, (_, url) in sorted(untracked.items()):
|
|
152
|
+
print(f" + {key} ({url or 'no remote'})")
|
|
153
|
+
|
|
154
|
+
if missing:
|
|
155
|
+
print("\nIn .pgit but not cloned locally (run: pgit sync):")
|
|
156
|
+
for key in sorted(missing):
|
|
157
|
+
print(f" - {key}")
|
|
158
|
+
|
|
159
|
+
# ── 5. Add untracked ──────────────────────────────────────────────────────
|
|
160
|
+
if untracked:
|
|
161
|
+
print()
|
|
162
|
+
if input("Add untracked repos to .pgit? [y/N] ").strip().lower() == "y":
|
|
163
|
+
for key, (_, remote_url) in sorted(untracked.items()):
|
|
164
|
+
fs_platform, local_path = key.split("/", 1)
|
|
165
|
+
_add_to_config(config, fs_platform, local_path, remote_url)
|
|
166
|
+
print(f" Added {key}")
|
|
167
|
+
write_config(config, config_path)
|
|
168
|
+
print(f"\nUpdated {config_path}")
|
|
169
|
+
|
|
170
|
+
# ── 6. Fix wrong-location ─────────────────────────────────────────────────
|
|
171
|
+
if wrong_location:
|
|
172
|
+
for key, (_, remote_url, config_key) in sorted(wrong_location.items()):
|
|
173
|
+
actual_dir = base / Path(*key.split("/"))
|
|
174
|
+
expected_dir = base / Path(*config_key.split("/"))
|
|
175
|
+
is_rename = actual_dir.parent == expected_dir.parent
|
|
176
|
+
print()
|
|
177
|
+
if is_rename:
|
|
178
|
+
if input(f"Rename {actual_dir.name} → {expected_dir.name}? [y/N] ").strip().lower() == "y":
|
|
179
|
+
actual_dir.rename(expected_dir)
|
|
180
|
+
print(f" Renamed.")
|
|
181
|
+
else:
|
|
182
|
+
if input(f"Add {key} to .pgit with remote override? [y/N] ").strip().lower() == "y":
|
|
183
|
+
fs_platform, local_path = key.split("/", 1)
|
|
184
|
+
_add_to_config(config, fs_platform, local_path, remote_url)
|
|
185
|
+
print(f" Added {key}")
|
|
186
|
+
write_config(config, config_path)
|
|
187
|
+
print(f"\nUpdated {config_path}")
|
|
188
|
+
|
|
189
|
+
# ── 7. Remove missing ─────────────────────────────────────────────────────
|
|
190
|
+
if missing:
|
|
191
|
+
print()
|
|
192
|
+
if input("Remove missing repos from .pgit? [y/N] ").strip().lower() == "y":
|
|
193
|
+
for key in sorted(missing):
|
|
194
|
+
fs_platform, local_path = key.split("/", 1)
|
|
195
|
+
for ns_key in list(config.get(fs_platform, {}).keys()):
|
|
196
|
+
if local_path.startswith(ns_key + "/"):
|
|
197
|
+
sub = local_path[len(ns_key)+1:].split("/")
|
|
198
|
+
del_nested(config[fs_platform][ns_key], sub)
|
|
199
|
+
if not config[fs_platform][ns_key]:
|
|
200
|
+
del config[fs_platform][ns_key]
|
|
201
|
+
break
|
|
202
|
+
print(f" Removed {key}")
|
|
203
|
+
write_config(config, config_path)
|
|
204
|
+
print(f"\nUpdated {config_path}")
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: polygit
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Manage multiple git repos from a single .pgit config file.
|
|
5
|
+
Project-URL: Repository, https://gitlab.com/gary.schaetz/public/polygit
|
|
6
|
+
Author-email: Gary Schaetz <gary@schaetzkc.com>
|
|
7
|
+
License: MIT License
|
|
8
|
+
|
|
9
|
+
Copyright (c) 2025 Gary Schaetz
|
|
10
|
+
|
|
11
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
12
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
13
|
+
in the Software without restriction, including without limitation the rights
|
|
14
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
15
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
16
|
+
furnished to do so, subject to the following conditions:
|
|
17
|
+
|
|
18
|
+
The above copyright notice and this permission notice shall be included in all
|
|
19
|
+
copies or substantial portions of the Software.
|
|
20
|
+
|
|
21
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
22
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
23
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
24
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
25
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
26
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
27
|
+
SOFTWARE.
|
|
28
|
+
License-File: LICENSE.txt
|
|
29
|
+
Keywords: devtools,git,multi-repo,pgit,polygit,repos
|
|
30
|
+
Requires-Python: >=3.8
|
|
31
|
+
Requires-Dist: pyyaml>=6.0
|
|
32
|
+
Description-Content-Type: text/markdown
|
|
33
|
+
|
|
34
|
+
# polygit
|
|
35
|
+
|
|
36
|
+
Manage multiple git repos from a single `.pgit` config file.
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
pip install polygit
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
## Usage
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
pgit sync # clone missing repos, fetch all
|
|
46
|
+
pgit status # show repos with uncommitted changes
|
|
47
|
+
pgit pull # pull all repos, auto-stashing local changes
|
|
48
|
+
pgit push # interactively commit and push dirty repos
|
|
49
|
+
pgit repos # discover drift between .pgit and the filesystem
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
## Configuration
|
|
53
|
+
|
|
54
|
+
Create a `.pgit` file in the root directory that contains your repos:
|
|
55
|
+
|
|
56
|
+
```yaml
|
|
57
|
+
github:
|
|
58
|
+
your-username:
|
|
59
|
+
repo-one:
|
|
60
|
+
repo-two:
|
|
61
|
+
some-org:
|
|
62
|
+
their-repo: {remote: "some-org/their-repo"}
|
|
63
|
+
|
|
64
|
+
gitlab:
|
|
65
|
+
your-namespace/group:
|
|
66
|
+
private-repo:
|
|
67
|
+
another-repo:
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
`pgit` discovers this file by walking up from the current directory — so running `pgit status` anywhere inside your repos root just works.
|
|
71
|
+
|
|
72
|
+
### Remote overrides
|
|
73
|
+
|
|
74
|
+
If a repo is cloned to a local path that differs from its remote path, use the `remote` key:
|
|
75
|
+
|
|
76
|
+
```yaml
|
|
77
|
+
github:
|
|
78
|
+
my-local-name:
|
|
79
|
+
project: {remote: "upstream-org/project"}
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
## push modes
|
|
83
|
+
|
|
84
|
+
```bash
|
|
85
|
+
pgit push # prompt for a message per repo (default)
|
|
86
|
+
pgit push --all # one message for all dirty repos
|
|
87
|
+
pgit push --wip # auto-commit with "WIP <timestamp>"
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
## repos — drift detection
|
|
91
|
+
|
|
92
|
+
`pgit repos` compares what's actually on disk against your `.pgit` config and reports:
|
|
93
|
+
|
|
94
|
+
- **Remote mismatch** — repo is at the right path but the git remote differs (e.g. SSH vs HTTPS)
|
|
95
|
+
- **Wrong location** — remote is tracked in `.pgit` but cloned to a different directory
|
|
96
|
+
- **Untracked** — repo on disk not referenced in `.pgit` at all
|
|
97
|
+
- **Missing** — in `.pgit` but not yet cloned (run `pgit sync`)
|
|
98
|
+
|
|
99
|
+
It then offers to update `.pgit` or rename directories interactively.
|
|
100
|
+
|
|
101
|
+
## Migrating from repos.yaml
|
|
102
|
+
|
|
103
|
+
If you were using the `sync.sh` predecessor, rename your `repos.yaml` to `.pgit` — the format is identical.
|
|
104
|
+
|
|
105
|
+
## Requirements
|
|
106
|
+
|
|
107
|
+
- Python 3.8+
|
|
108
|
+
- git
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
polygit/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
2
|
+
polygit/cli.py,sha256=t55G_27aWlqScA3mQeRVFg1HwitpzBYOATn3qVl7Ulk,2163
|
|
3
|
+
polygit/config.py,sha256=3lZCp073173cskTxXpqNjP8bk50QuYstz0zKLCEieQY,1378
|
|
4
|
+
polygit/repo.py,sha256=92l_OO63KBIdho-Sq1KaWoRhbLT616trA08Ct03GRlE,5384
|
|
5
|
+
polygit/repos.py,sha256=1X4xpA7d32cdHt--v2-1yRjw4uiMxGRrWz-uMw27zfE,9602
|
|
6
|
+
polygit-0.1.0.dist-info/METADATA,sha256=5vNz9Ny6u74O6Zk8kWJp1Wf5bPZy0hKrY3r8MS9Wjb8,3593
|
|
7
|
+
polygit-0.1.0.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
|
|
8
|
+
polygit-0.1.0.dist-info/entry_points.txt,sha256=hBPbWbQygzHXMfwect-YNoaVPFh1_9EMfIo4ec3EY_A,42
|
|
9
|
+
polygit-0.1.0.dist-info/licenses/LICENSE.txt,sha256=YxFgIp_vc7jYYhGaqNWuAcBNNenp4Kgnj622VLgew_U,1069
|
|
10
|
+
polygit-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 Gary Schaetz
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|