pubgate 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.
- pubgate/__init__.py +4 -0
- pubgate/__main__.py +97 -0
- pubgate/_log.py +42 -0
- pubgate/absorb.py +302 -0
- pubgate/config.py +157 -0
- pubgate/core.py +611 -0
- pubgate/errors.py +10 -0
- pubgate/filtering.py +74 -0
- pubgate/git.py +461 -0
- pubgate/models.py +38 -0
- pubgate/pr.py +374 -0
- pubgate/publish.py +68 -0
- pubgate/stage_snapshot.py +124 -0
- pubgate/state.py +44 -0
- pubgate/status.py +198 -0
- pubgate-0.1.0.dist-info/METADATA +317 -0
- pubgate-0.1.0.dist-info/RECORD +21 -0
- pubgate-0.1.0.dist-info/WHEEL +5 -0
- pubgate-0.1.0.dist-info/entry_points.txt +2 -0
- pubgate-0.1.0.dist-info/licenses/LICENSE +21 -0
- pubgate-0.1.0.dist-info/top_level.txt +1 -0
pubgate/__init__.py
ADDED
pubgate/__main__.py
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import argparse
|
|
2
|
+
import enum
|
|
3
|
+
import logging
|
|
4
|
+
import sys
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from ._log import setup_logging
|
|
8
|
+
from .config import load_config
|
|
9
|
+
from .errors import PubGateError
|
|
10
|
+
|
|
11
|
+
logger = logging.getLogger(__name__)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class Command(enum.Enum):
|
|
15
|
+
ABSORB = "absorb"
|
|
16
|
+
STAGE = "stage"
|
|
17
|
+
PUBLISH = "publish"
|
|
18
|
+
STATUS = "status"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _add_common_flags(subparser: argparse.ArgumentParser) -> None:
|
|
22
|
+
subparser.add_argument(
|
|
23
|
+
"--dry-run",
|
|
24
|
+
action="store_true",
|
|
25
|
+
help="Preview planned actions (fetches remotes but skips branch creation, commits, and pushes)",
|
|
26
|
+
)
|
|
27
|
+
subparser.add_argument(
|
|
28
|
+
"--force", action="store_true", help="Overwrite existing PR branch (use when previous PR was not merged)"
|
|
29
|
+
)
|
|
30
|
+
subparser.add_argument(
|
|
31
|
+
"--no-pr", action="store_true", help="Skip automatic PR creation (show manual steps instead)"
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
36
|
+
parser = argparse.ArgumentParser(prog="pubgate", description="Sync internal repo <-> public repo")
|
|
37
|
+
parser.add_argument("--repo-dir", default=".", help="Path to the internal repo (default: .)")
|
|
38
|
+
|
|
39
|
+
sub = parser.add_subparsers(dest="command")
|
|
40
|
+
for cmd, help_text in (
|
|
41
|
+
(Command.ABSORB.value, "Bring public repo changes into internal main via PR"),
|
|
42
|
+
(Command.STAGE.value, "Generate stage candidate and open internal PR into pubgate/public-approved"),
|
|
43
|
+
(Command.PUBLISH.value, "Push reviewed pubgate/public-approved content to the public repo and open PR"),
|
|
44
|
+
):
|
|
45
|
+
sp = sub.add_parser(cmd, help=help_text)
|
|
46
|
+
_add_common_flags(sp)
|
|
47
|
+
|
|
48
|
+
sub.add_parser(Command.STATUS.value, help="Show sync status of absorb, stage, and publish")
|
|
49
|
+
|
|
50
|
+
return parser
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def main(argv: list[str] | None = None) -> None:
|
|
54
|
+
parser = build_parser()
|
|
55
|
+
args = parser.parse_args(argv)
|
|
56
|
+
|
|
57
|
+
setup_logging()
|
|
58
|
+
|
|
59
|
+
if not args.command:
|
|
60
|
+
parser.print_help()
|
|
61
|
+
sys.exit(1)
|
|
62
|
+
|
|
63
|
+
try:
|
|
64
|
+
cfg = load_config(args.repo_dir)
|
|
65
|
+
except PubGateError as exc:
|
|
66
|
+
logger.error("Configuration error: %s", exc)
|
|
67
|
+
sys.exit(1)
|
|
68
|
+
|
|
69
|
+
cmd = Command(args.command)
|
|
70
|
+
|
|
71
|
+
from .core import PubGate
|
|
72
|
+
from .git import GitRepo
|
|
73
|
+
|
|
74
|
+
try:
|
|
75
|
+
git = GitRepo(Path(args.repo_dir))
|
|
76
|
+
git.verify_repo()
|
|
77
|
+
git.ensure_remote(cfg.public_remote, cfg.public_url)
|
|
78
|
+
pg = PubGate(cfg, git)
|
|
79
|
+
|
|
80
|
+
if cmd == Command.STATUS:
|
|
81
|
+
pg.status()
|
|
82
|
+
else:
|
|
83
|
+
flags = dict(dry_run=args.dry_run, force=args.force, no_pr=args.no_pr)
|
|
84
|
+
match cmd:
|
|
85
|
+
case Command.ABSORB:
|
|
86
|
+
pg.absorb(**flags)
|
|
87
|
+
case Command.STAGE:
|
|
88
|
+
pg.stage(**flags)
|
|
89
|
+
case Command.PUBLISH:
|
|
90
|
+
pg.publish(**flags)
|
|
91
|
+
except PubGateError as exc:
|
|
92
|
+
logger.error("Command failed: %s", exc)
|
|
93
|
+
sys.exit(1)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
if __name__ == "__main__":
|
|
97
|
+
main()
|
pubgate/_log.py
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import os
|
|
3
|
+
import sys
|
|
4
|
+
|
|
5
|
+
import colorlog
|
|
6
|
+
|
|
7
|
+
_ENV_VAR = "PUBGATE_LOG_LEVEL"
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def setup_logging() -> None:
|
|
11
|
+
env_level = os.environ.get(_ENV_VAR, "INFO").upper()
|
|
12
|
+
level = getattr(logging, env_level, None)
|
|
13
|
+
if level is None:
|
|
14
|
+
level = logging.INFO
|
|
15
|
+
|
|
16
|
+
handler = logging.StreamHandler(sys.stderr)
|
|
17
|
+
if hasattr(sys.stderr, "isatty") and sys.stderr.isatty():
|
|
18
|
+
handler.setFormatter(
|
|
19
|
+
colorlog.ColoredFormatter(
|
|
20
|
+
"%(log_color)s[%(levelname)-7s]:%(reset)s %(message)s",
|
|
21
|
+
log_colors={
|
|
22
|
+
"DEBUG": "cyan",
|
|
23
|
+
"INFO": "green",
|
|
24
|
+
"WARNING": "yellow",
|
|
25
|
+
"ERROR": "red",
|
|
26
|
+
"CRITICAL": "bold_red",
|
|
27
|
+
},
|
|
28
|
+
)
|
|
29
|
+
)
|
|
30
|
+
else:
|
|
31
|
+
handler.setFormatter(logging.Formatter("[%(levelname)-7s]: %(message)s"))
|
|
32
|
+
|
|
33
|
+
root = logging.getLogger()
|
|
34
|
+
root.handlers.clear()
|
|
35
|
+
root.addHandler(handler)
|
|
36
|
+
root.setLevel(logging.WARNING)
|
|
37
|
+
|
|
38
|
+
# App logger gets the user-requested level.
|
|
39
|
+
logging.getLogger("pubgate").setLevel(level)
|
|
40
|
+
|
|
41
|
+
# Route warnings.warn() through logging.
|
|
42
|
+
logging.captureWarnings(True)
|
pubgate/absorb.py
ADDED
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import tempfile
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from .config import Config
|
|
6
|
+
from .errors import GitError, PubGateError
|
|
7
|
+
from .filtering import scrub_internal_blocks
|
|
8
|
+
from .git import GitRepo, is_lfs_pointer
|
|
9
|
+
from .models import format_commit
|
|
10
|
+
from .state import AbsorbStatus, StateRef
|
|
11
|
+
|
|
12
|
+
logger = logging.getLogger(__name__)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
# ---------------------------------------------------------------------------
|
|
16
|
+
# Public API
|
|
17
|
+
# ---------------------------------------------------------------------------
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class AbsorbResult:
|
|
21
|
+
__slots__ = ("status", "public_head", "last_absorbed")
|
|
22
|
+
|
|
23
|
+
def __init__(self, status: AbsorbStatus, public_head: str, last_absorbed: str | None) -> None:
|
|
24
|
+
self.status = status
|
|
25
|
+
self.public_head = public_head
|
|
26
|
+
self.last_absorbed = last_absorbed
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def check_absorb(cfg: Config, git: GitRepo) -> AbsorbResult:
|
|
30
|
+
if not git.remote_branch_exists(cfg.public_remote, cfg.public_main_branch):
|
|
31
|
+
raise PubGateError(
|
|
32
|
+
f"Error: public repo has no '{cfg.public_main_branch}' branch. "
|
|
33
|
+
f"The public repo must have at least one commit before running absorb."
|
|
34
|
+
)
|
|
35
|
+
public_head = git.rev_parse(cfg.public_main_ref)
|
|
36
|
+
|
|
37
|
+
absorb_ref = StateRef.read(git, cfg.internal_main_branch, cfg.absorb_state_file)
|
|
38
|
+
last_absorbed = absorb_ref.sha if absorb_ref else None
|
|
39
|
+
|
|
40
|
+
if last_absorbed is None:
|
|
41
|
+
logger.debug("Inbound status: NEEDS_BOOTSTRAP")
|
|
42
|
+
return AbsorbResult(AbsorbStatus.NEEDS_BOOTSTRAP, public_head, None)
|
|
43
|
+
|
|
44
|
+
if last_absorbed == public_head:
|
|
45
|
+
logger.debug("Inbound status: UP_TO_DATE")
|
|
46
|
+
return AbsorbResult(AbsorbStatus.UP_TO_DATE, public_head, last_absorbed)
|
|
47
|
+
|
|
48
|
+
logger.debug("Inbound status: NEEDS_ABSORB")
|
|
49
|
+
return AbsorbResult(AbsorbStatus.NEEDS_ABSORB, public_head, last_absorbed)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def resolve_and_apply(cfg: Config, git: GitRepo, base_sha: str, public_head: str) -> list[str]:
|
|
53
|
+
public_ref = f"{cfg.public_remote}/{cfg.public_main_branch}"
|
|
54
|
+
excluded = cfg.state_files
|
|
55
|
+
staged_sha: str | None = None
|
|
56
|
+
try:
|
|
57
|
+
stage_ref = StateRef.read(git, public_ref, cfg.stage_state_file)
|
|
58
|
+
if stage_ref is not None:
|
|
59
|
+
staged_sha = stage_ref.sha
|
|
60
|
+
except PubGateError as exc:
|
|
61
|
+
logger.warning("Could not read stage state from %s: %s", public_ref, exc)
|
|
62
|
+
return _apply_absorb_changes(git, base_sha, public_head, public_ref, excluded=excluded, staged_sha=staged_sha)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def absorb_commit_message(
|
|
66
|
+
git: GitRepo,
|
|
67
|
+
last_absorbed: str,
|
|
68
|
+
public_head: str,
|
|
69
|
+
conflicted: list[str] | None = None,
|
|
70
|
+
needs_review: list[str] | None = None,
|
|
71
|
+
) -> str:
|
|
72
|
+
subject = f"pubgate: absorb public changes {last_absorbed[:7]}..{public_head[:7]}"
|
|
73
|
+
commits = git.log_oneline(last_absorbed, public_head)
|
|
74
|
+
lines = [subject]
|
|
75
|
+
if commits:
|
|
76
|
+
lines.append("")
|
|
77
|
+
lines.append(f"Included commits ({last_absorbed[:7]}..{public_head[:7]}):")
|
|
78
|
+
lines.extend(f" {i}. {format_commit(c)}" for i, c in enumerate(commits, 1))
|
|
79
|
+
if conflicted:
|
|
80
|
+
lines.append("")
|
|
81
|
+
lines.append("CONFLICTS (resolve before merging):")
|
|
82
|
+
for path in conflicted:
|
|
83
|
+
lines.append(f" {path}")
|
|
84
|
+
if needs_review:
|
|
85
|
+
lines.append("")
|
|
86
|
+
lines.append("Review manually:")
|
|
87
|
+
for item in needs_review:
|
|
88
|
+
lines.append(f" {item}")
|
|
89
|
+
return "\n".join(lines)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
# ---------------------------------------------------------------------------
|
|
93
|
+
# Implementation details (private)
|
|
94
|
+
# ---------------------------------------------------------------------------
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _read_text_at_ref(git: GitRepo, ref: str, path: str) -> str | None:
|
|
98
|
+
data = git.read_file_at_ref_bytes(ref, path)
|
|
99
|
+
if data is None:
|
|
100
|
+
return None
|
|
101
|
+
return data.decode("utf-8")
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _log_review_diff(git: GitRepo, public_ref: str, path: str, local_path: Path) -> None:
|
|
105
|
+
"""Log debug info comparing local vs public version of a file needing review."""
|
|
106
|
+
try:
|
|
107
|
+
public_bytes = git.read_file_at_ref_bytes(public_ref, path)
|
|
108
|
+
local_bytes = local_path.read_bytes() if local_path.exists() else None
|
|
109
|
+
pub_size = len(public_bytes) if public_bytes is not None else 0
|
|
110
|
+
loc_size = len(local_bytes) if local_bytes is not None else 0
|
|
111
|
+
same = public_bytes == local_bytes
|
|
112
|
+
logger.debug(" public: %d bytes, local: %d bytes, identical: %s", pub_size, loc_size, same)
|
|
113
|
+
if same:
|
|
114
|
+
return
|
|
115
|
+
# For text files, show a unified diff
|
|
116
|
+
if public_bytes is not None and local_bytes is not None:
|
|
117
|
+
try:
|
|
118
|
+
pub_text = public_bytes.decode("utf-8")
|
|
119
|
+
loc_text = local_bytes.decode("utf-8")
|
|
120
|
+
except UnicodeDecodeError:
|
|
121
|
+
return
|
|
122
|
+
import difflib
|
|
123
|
+
|
|
124
|
+
diff = difflib.unified_diff(
|
|
125
|
+
pub_text.splitlines(keepends=True),
|
|
126
|
+
loc_text.splitlines(keepends=True),
|
|
127
|
+
fromfile=f"public ({public_ref})",
|
|
128
|
+
tofile="local (working tree)",
|
|
129
|
+
)
|
|
130
|
+
diff_text = "".join(diff)
|
|
131
|
+
if diff_text:
|
|
132
|
+
for line in diff_text.splitlines():
|
|
133
|
+
logger.debug(" %s", line)
|
|
134
|
+
except Exception:
|
|
135
|
+
pass # best-effort debug logging
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _apply_absorb_changes(
|
|
139
|
+
git: GitRepo,
|
|
140
|
+
base_sha: str,
|
|
141
|
+
public_head: str,
|
|
142
|
+
public_ref: str,
|
|
143
|
+
*,
|
|
144
|
+
excluded: frozenset[str] = frozenset(),
|
|
145
|
+
staged_sha: str | None = None,
|
|
146
|
+
) -> list[str]:
|
|
147
|
+
changes = git.diff_tree(base_sha, public_head)
|
|
148
|
+
changes = [c for c in changes if c.path not in excluded and (c.old_path is None or c.old_path not in excluded)]
|
|
149
|
+
actions: list[str] = []
|
|
150
|
+
|
|
151
|
+
for change in changes:
|
|
152
|
+
logger.debug("Processing change: %s %s", change.status, change.path)
|
|
153
|
+
if change.is_add:
|
|
154
|
+
local_path = git.repo_dir / change.path
|
|
155
|
+
if local_path.exists():
|
|
156
|
+
kind = git.classify_at_ref(public_ref, change.path)
|
|
157
|
+
if kind != "text":
|
|
158
|
+
label = "LFS file" if kind == "lfs" else "binary"
|
|
159
|
+
public_bytes = git.read_file_at_ref_bytes(public_ref, change.path)
|
|
160
|
+
local_bytes = local_path.read_bytes()
|
|
161
|
+
if public_bytes == local_bytes:
|
|
162
|
+
actions.append(f" {label} (identical): {change.path}")
|
|
163
|
+
else:
|
|
164
|
+
actions.append(
|
|
165
|
+
f" {label} added on public (kept local version, review manually): {change.path}"
|
|
166
|
+
)
|
|
167
|
+
_log_review_diff(git, public_ref, change.path, local_path)
|
|
168
|
+
else:
|
|
169
|
+
theirs_content = _read_text_at_ref(git, public_ref, change.path)
|
|
170
|
+
if theirs_content is None:
|
|
171
|
+
actions.append(f" added on public (kept local version, review manually): {change.path}")
|
|
172
|
+
_log_review_diff(git, public_ref, change.path, local_path)
|
|
173
|
+
continue
|
|
174
|
+
# Try to find the published base: the scrubbed version of
|
|
175
|
+
# the file at the internal commit that was staged.
|
|
176
|
+
published_base: str | None = None
|
|
177
|
+
if staged_sha is not None:
|
|
178
|
+
staged_content = _read_text_at_ref(git, staged_sha, change.path)
|
|
179
|
+
if staged_content is not None:
|
|
180
|
+
published_base = scrub_internal_blocks(staged_content, path=change.path)
|
|
181
|
+
if published_base is not None:
|
|
182
|
+
# Three-way merge using the published version as base
|
|
183
|
+
with tempfile.TemporaryDirectory() as tmpdir:
|
|
184
|
+
base_tmp = Path(tmpdir) / "base"
|
|
185
|
+
theirs_tmp = Path(tmpdir) / "theirs"
|
|
186
|
+
base_tmp.write_text(published_base, encoding="utf-8", newline="")
|
|
187
|
+
theirs_tmp.write_text(theirs_content, encoding="utf-8", newline="")
|
|
188
|
+
# Read ours from git objects instead of working copy
|
|
189
|
+
# to avoid CRLF mismatch on Windows (see _merge_file).
|
|
190
|
+
ours_content = _read_text_at_ref(git, "HEAD", change.path)
|
|
191
|
+
if ours_content is not None:
|
|
192
|
+
local_path.write_text(ours_content, encoding="utf-8", newline="")
|
|
193
|
+
clean = git.merge_file(local_path, base_tmp, theirs_tmp)
|
|
194
|
+
git.stage(change.path)
|
|
195
|
+
if clean:
|
|
196
|
+
actions.append(f" merge (clean): {change.path}")
|
|
197
|
+
else:
|
|
198
|
+
actions.append(f" merge (CONFLICTS - resolve manually): {change.path}")
|
|
199
|
+
else:
|
|
200
|
+
# No staged version; file wasn't published through pubgate
|
|
201
|
+
actions.append(f" added on public (kept local, review manually): {change.path}")
|
|
202
|
+
_log_review_diff(git, public_ref, change.path, local_path)
|
|
203
|
+
else:
|
|
204
|
+
is_binary = git.copy_file_from_ref(public_ref, change.path)
|
|
205
|
+
if is_binary:
|
|
206
|
+
with open(git.repo_dir / change.path, "rb") as f:
|
|
207
|
+
head = f.read(1024)
|
|
208
|
+
tag = " (LFS)" if is_lfs_pointer(head) else " (binary)"
|
|
209
|
+
else:
|
|
210
|
+
tag = ""
|
|
211
|
+
actions.append(f" add{tag}: {change.path}")
|
|
212
|
+
|
|
213
|
+
elif change.is_modify:
|
|
214
|
+
_merge_file(git, base_sha, public_ref, change.path, actions, staged_sha=staged_sha)
|
|
215
|
+
|
|
216
|
+
elif change.is_delete:
|
|
217
|
+
if (git.repo_dir / change.path).exists():
|
|
218
|
+
actions.append(f" deleted on public (kept locally, review manually): {change.path}")
|
|
219
|
+
logger.debug(" local file exists at %s", git.repo_dir / change.path)
|
|
220
|
+
|
|
221
|
+
elif change.is_rename:
|
|
222
|
+
old_path = change.old_path or ""
|
|
223
|
+
git.copy_file_from_ref(public_ref, change.path)
|
|
224
|
+
msg = f" rename on public: {old_path} → {change.path} (kept old, review manually)"
|
|
225
|
+
actions.append(msg)
|
|
226
|
+
|
|
227
|
+
return actions
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def _merge_file(
|
|
231
|
+
git: GitRepo,
|
|
232
|
+
base_sha: str,
|
|
233
|
+
public_ref: str,
|
|
234
|
+
path: str,
|
|
235
|
+
actions: list[str],
|
|
236
|
+
*,
|
|
237
|
+
staged_sha: str | None = None,
|
|
238
|
+
) -> None:
|
|
239
|
+
if git.is_binary_at_ref(public_ref, path) or git.is_binary_at_ref(base_sha, path):
|
|
240
|
+
theirs_bytes = git.read_file_at_ref_bytes(public_ref, path)
|
|
241
|
+
if theirs_bytes is None:
|
|
242
|
+
raise GitError(
|
|
243
|
+
["show", f"{public_ref}:{path}"],
|
|
244
|
+
1,
|
|
245
|
+
f"diff_tree reported M for {path} but binary content is unreadable "
|
|
246
|
+
f"at {public_ref}. Repository may have corrupt objects.",
|
|
247
|
+
)
|
|
248
|
+
git.write_file_and_stage_bytes(path, theirs_bytes)
|
|
249
|
+
label = "LFS file" if is_lfs_pointer(theirs_bytes) else "binary"
|
|
250
|
+
actions.append(f" {label} changed on public (replaced locally, review manually): {path}")
|
|
251
|
+
_log_review_diff(git, public_ref, path, git.repo_dir / path)
|
|
252
|
+
return
|
|
253
|
+
|
|
254
|
+
# Use the scrubbed staged content as merge base when available.
|
|
255
|
+
# This mirrors the is_add path: after a publish cycle, the correct base
|
|
256
|
+
# is the scrubbed version of the internal file that was staged, not the
|
|
257
|
+
# old public content (which would cause false conflicts on internal blocks).
|
|
258
|
+
base_content: str | None = None
|
|
259
|
+
if staged_sha is not None:
|
|
260
|
+
staged_content = _read_text_at_ref(git, staged_sha, path)
|
|
261
|
+
if staged_content is not None:
|
|
262
|
+
base_content = scrub_internal_blocks(staged_content, path=path)
|
|
263
|
+
if base_content is None:
|
|
264
|
+
base_content = _read_text_at_ref(git, base_sha, path)
|
|
265
|
+
theirs_content = _read_text_at_ref(git, public_ref, path)
|
|
266
|
+
|
|
267
|
+
if base_content is None or theirs_content is None:
|
|
268
|
+
missing_ref = base_sha if base_content is None else public_ref
|
|
269
|
+
raise GitError(
|
|
270
|
+
["show", f"{missing_ref}:{path}"],
|
|
271
|
+
1,
|
|
272
|
+
f"diff_tree reported M for {path} but content is unreadable "
|
|
273
|
+
f"at {missing_ref}. Repository may have corrupt objects.",
|
|
274
|
+
)
|
|
275
|
+
|
|
276
|
+
ours_path = git.repo_dir / path
|
|
277
|
+
if not ours_path.exists():
|
|
278
|
+
if theirs_content is not None:
|
|
279
|
+
git.write_file_and_stage(path, theirs_content)
|
|
280
|
+
actions.append(f" add (was modified on public, missing locally): {path}")
|
|
281
|
+
return
|
|
282
|
+
|
|
283
|
+
with tempfile.TemporaryDirectory() as tmpdir:
|
|
284
|
+
base_tmp = Path(tmpdir) / "base"
|
|
285
|
+
theirs_tmp = Path(tmpdir) / "theirs"
|
|
286
|
+
base_tmp.write_text(base_content, encoding="utf-8", newline="")
|
|
287
|
+
theirs_tmp.write_text(theirs_content, encoding="utf-8", newline="")
|
|
288
|
+
|
|
289
|
+
# Read ours from git objects instead of the working copy.
|
|
290
|
+
# On Windows, checkout may denormalize LF→CRLF, causing
|
|
291
|
+
# git merge-file to see spurious differences vs the LF
|
|
292
|
+
# base/theirs content read from git objects.
|
|
293
|
+
ours_content = _read_text_at_ref(git, "HEAD", path)
|
|
294
|
+
if ours_content is not None:
|
|
295
|
+
ours_path.write_text(ours_content, encoding="utf-8", newline="")
|
|
296
|
+
|
|
297
|
+
clean = git.merge_file(ours_path, base_tmp, theirs_tmp)
|
|
298
|
+
git.stage(path)
|
|
299
|
+
if clean:
|
|
300
|
+
actions.append(f" merge (clean): {path}")
|
|
301
|
+
else:
|
|
302
|
+
actions.append(f" merge (CONFLICTS - resolve manually): {path}")
|
pubgate/config.py
ADDED
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import re
|
|
3
|
+
import sys
|
|
4
|
+
from dataclasses import dataclass, field, fields
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
if sys.version_info >= (3, 11):
|
|
8
|
+
import tomllib
|
|
9
|
+
else:
|
|
10
|
+
import tomli as tomllib # type: ignore[import-untyped]
|
|
11
|
+
|
|
12
|
+
from .errors import PubGateError
|
|
13
|
+
|
|
14
|
+
logger = logging.getLogger(__name__)
|
|
15
|
+
|
|
16
|
+
# ---------------------------------------------------------------------------
|
|
17
|
+
# Constants
|
|
18
|
+
# ---------------------------------------------------------------------------
|
|
19
|
+
|
|
20
|
+
CONFIG_FILE = "pubgate.toml"
|
|
21
|
+
|
|
22
|
+
DEFAULT_IGNORE_PATTERNS: list[str] = [
|
|
23
|
+
".internal/*",
|
|
24
|
+
"internal/*",
|
|
25
|
+
"*-internal.*",
|
|
26
|
+
"*.internal.*",
|
|
27
|
+
"*_internal.*",
|
|
28
|
+
"*-private.*",
|
|
29
|
+
"*.private.*",
|
|
30
|
+
"*_private.*",
|
|
31
|
+
"*.secret",
|
|
32
|
+
"*.secrets",
|
|
33
|
+
]
|
|
34
|
+
|
|
35
|
+
# Allowed: alphanumeric, underscore, forward slash, dot, colon, hyphen
|
|
36
|
+
_VALID_BRANCH_RE = re.compile(r"^[a-zA-Z0-9_/.:-]+$")
|
|
37
|
+
|
|
38
|
+
# ---------------------------------------------------------------------------
|
|
39
|
+
# Field helpers
|
|
40
|
+
# ---------------------------------------------------------------------------
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _config_field(kind: str, *, scope: str = "", **kwargs):
|
|
44
|
+
metadata: dict[str, str] = {"kind": kind}
|
|
45
|
+
if scope:
|
|
46
|
+
metadata["scope"] = scope
|
|
47
|
+
return field(metadata=metadata, **kwargs)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _validate_branch_name(name: str, field_name: str) -> None:
|
|
51
|
+
if not _VALID_BRANCH_RE.match(name):
|
|
52
|
+
raise PubGateError(f"'{field_name}' contains invalid characters: '{name}'")
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
# ---------------------------------------------------------------------------
|
|
56
|
+
# Field metadata utilities
|
|
57
|
+
# ---------------------------------------------------------------------------
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _fields_by_kind(*kinds: str) -> frozenset[str]:
|
|
61
|
+
return frozenset(f.name for f in fields(Config) if f.metadata.get("kind") in kinds)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _branch_scope_groups() -> dict[str, frozenset[str]]:
|
|
65
|
+
groups: dict[str, set[str]] = {}
|
|
66
|
+
for f in fields(Config):
|
|
67
|
+
if f.metadata.get("kind") == "branch":
|
|
68
|
+
groups.setdefault(f.metadata.get("scope", ""), set()).add(f.name)
|
|
69
|
+
return {k: frozenset(v) for k, v in groups.items()}
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
# ---------------------------------------------------------------------------
|
|
73
|
+
# Config dataclass
|
|
74
|
+
# ---------------------------------------------------------------------------
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
@dataclass
|
|
78
|
+
class Config:
|
|
79
|
+
# Internal repo
|
|
80
|
+
internal_main_branch: str = _config_field("branch", scope="internal", default="main")
|
|
81
|
+
internal_approved_branch: str = _config_field("branch", scope="internal", default="pubgate/public-approved")
|
|
82
|
+
internal_absorb_branch: str = _config_field("branch", scope="internal", default="pubgate/absorb")
|
|
83
|
+
internal_stage_branch: str = _config_field("branch", scope="internal", default="pubgate/stage")
|
|
84
|
+
|
|
85
|
+
# Public repo
|
|
86
|
+
public_url: str | None = _config_field("str", default=None)
|
|
87
|
+
public_remote: str = _config_field("str", default="public-remote")
|
|
88
|
+
public_main_branch: str = _config_field("branch", scope="public", default="main")
|
|
89
|
+
public_publish_branch: str = _config_field("branch", scope="public", default="pubgate/publish")
|
|
90
|
+
|
|
91
|
+
# State tracking
|
|
92
|
+
absorb_state_file: str = _config_field("state", default=".pubgate-absorbed")
|
|
93
|
+
stage_state_file: str = _config_field("state", default=".pubgate-staged")
|
|
94
|
+
|
|
95
|
+
# Filtering
|
|
96
|
+
ignore: list[str] = _config_field("list", default_factory=lambda: list(DEFAULT_IGNORE_PATTERNS))
|
|
97
|
+
|
|
98
|
+
def __post_init__(self) -> None:
|
|
99
|
+
for key in _fields_by_kind("branch"):
|
|
100
|
+
_validate_branch_name(getattr(self, key), key)
|
|
101
|
+
for keys in _branch_scope_groups().values():
|
|
102
|
+
self._check_no_duplicates(keys, "branch name")
|
|
103
|
+
self._check_no_duplicates(_fields_by_kind("state"), "filename")
|
|
104
|
+
|
|
105
|
+
def _check_no_duplicates(self, keys: frozenset[str], label: str) -> None:
|
|
106
|
+
seen: dict[str, str] = {}
|
|
107
|
+
for key in sorted(keys):
|
|
108
|
+
val = getattr(self, key)
|
|
109
|
+
if val in seen:
|
|
110
|
+
raise PubGateError(f"'{key}' and '{seen[val]}' share the same {label} '{val}'")
|
|
111
|
+
seen[val] = key
|
|
112
|
+
|
|
113
|
+
@property
|
|
114
|
+
def public_main_ref(self) -> str:
|
|
115
|
+
return f"{self.public_remote}/{self.public_main_branch}"
|
|
116
|
+
|
|
117
|
+
@property
|
|
118
|
+
def state_files(self) -> frozenset[str]:
|
|
119
|
+
return frozenset({self.absorb_state_file, self.stage_state_file})
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
# ---------------------------------------------------------------------------
|
|
123
|
+
# TOML loader
|
|
124
|
+
# ---------------------------------------------------------------------------
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def load_config(repo_dir: str | Path = ".") -> Config:
|
|
128
|
+
repo_path = Path(repo_dir)
|
|
129
|
+
config_path = repo_path / CONFIG_FILE
|
|
130
|
+
|
|
131
|
+
if not config_path.is_file():
|
|
132
|
+
raise PubGateError(
|
|
133
|
+
f'No {CONFIG_FILE} found at {config_path.resolve()}. Create one with at least: public_url = "..."'
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
with config_path.open("rb") as f:
|
|
137
|
+
data = tomllib.load(f)
|
|
138
|
+
logger.debug("Loaded config from %s", config_path)
|
|
139
|
+
|
|
140
|
+
str_keys = _fields_by_kind("str", "branch", "state")
|
|
141
|
+
list_keys = _fields_by_kind("list")
|
|
142
|
+
|
|
143
|
+
unknown = set(data) - (str_keys | list_keys)
|
|
144
|
+
if unknown:
|
|
145
|
+
raise PubGateError(f"Unknown keys in {CONFIG_FILE}: {', '.join(sorted(unknown))}")
|
|
146
|
+
|
|
147
|
+
kwargs: dict[str, object] = {}
|
|
148
|
+
for key, val in data.items():
|
|
149
|
+
if key in str_keys:
|
|
150
|
+
if not isinstance(val, str):
|
|
151
|
+
raise PubGateError(f"{CONFIG_FILE}: '{key}' must be a string")
|
|
152
|
+
elif key in list_keys:
|
|
153
|
+
if not isinstance(val, list) or not all(isinstance(v, str) for v in val):
|
|
154
|
+
raise PubGateError(f"{CONFIG_FILE}: '{key}' must be a list of strings")
|
|
155
|
+
kwargs[key] = val
|
|
156
|
+
|
|
157
|
+
return Config(**kwargs) # type: ignore[arg-type]
|