bingo-light 2.0.0

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.
@@ -0,0 +1,77 @@
1
+ """
2
+ bingo_core — Complete Python core library for bingo-light.
3
+
4
+ AI-native fork maintenance: manages customizations as a clean patch stack
5
+ on top of upstream. Every public method returns a dict with {"ok": True, ...}
6
+ or raises BingoError.
7
+
8
+ Python 3.8+ stdlib only. No pip dependencies.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import re
14
+
15
+ # --- Constants ---
16
+
17
+ VERSION = "2.0.0"
18
+ PATCH_PREFIX = "[bl]"
19
+ CONFIG_FILE = ".bingolight"
20
+ BINGO_DIR = ".bingo"
21
+ DEFAULT_TRACKING = "upstream-tracking"
22
+ DEFAULT_PATCHES = "bingo-patches"
23
+ MAX_PATCHES = 100
24
+ MAX_DIFF_SIZE = 50000
25
+ PATCH_NAME_RE = re.compile(r"^[a-zA-Z0-9][a-zA-Z0-9_-]*$")
26
+ PATCH_NAME_MAX = 100
27
+ CIRCUIT_BREAKER_LIMIT = 3
28
+ RERERE_MAX_ITER = 50
29
+ MAX_RESOLVE_ITER = 20
30
+ SYNC_HISTORY_MAX = 50
31
+
32
+ # --- Re-exports (keep `from bingo_core import X` working) ---
33
+
34
+ from bingo_core.exceptions import ( # noqa: E402
35
+ BingoError,
36
+ GitError,
37
+ NotGitRepoError,
38
+ NotInitializedError,
39
+ DirtyTreeError,
40
+ )
41
+ from bingo_core.models import PatchInfo, ConflictInfo # noqa: E402
42
+ from bingo_core.git import Git # noqa: E402
43
+ from bingo_core.config import Config # noqa: E402
44
+ from bingo_core.state import State # noqa: E402
45
+ from bingo_core.repo import Repo # noqa: E402
46
+
47
+ __all__ = [
48
+ # Constants
49
+ "VERSION",
50
+ "PATCH_PREFIX",
51
+ "CONFIG_FILE",
52
+ "BINGO_DIR",
53
+ "DEFAULT_TRACKING",
54
+ "DEFAULT_PATCHES",
55
+ "MAX_PATCHES",
56
+ "MAX_DIFF_SIZE",
57
+ "PATCH_NAME_RE",
58
+ "PATCH_NAME_MAX",
59
+ "CIRCUIT_BREAKER_LIMIT",
60
+ "RERERE_MAX_ITER",
61
+ "MAX_RESOLVE_ITER",
62
+ "SYNC_HISTORY_MAX",
63
+ # Exceptions
64
+ "BingoError",
65
+ "GitError",
66
+ "NotGitRepoError",
67
+ "NotInitializedError",
68
+ "DirtyTreeError",
69
+ # Data classes
70
+ "PatchInfo",
71
+ "ConflictInfo",
72
+ # Classes
73
+ "Git",
74
+ "Config",
75
+ "State",
76
+ "Repo",
77
+ ]
@@ -0,0 +1,110 @@
1
+ """
2
+ bingo_core.config — Configuration management for bingo-light.
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ import os
8
+ import subprocess
9
+ from typing import Optional
10
+
11
+ from bingo_core import CONFIG_FILE, DEFAULT_PATCHES, DEFAULT_TRACKING
12
+ from bingo_core.exceptions import NotInitializedError
13
+
14
+
15
+ class Config:
16
+ """Manages the .bingolight configuration file (git config format)."""
17
+
18
+ def __init__(self, repo_dir: str):
19
+ self.repo_dir = repo_dir
20
+ self.config_path = os.path.join(repo_dir, CONFIG_FILE)
21
+
22
+ def exists(self) -> bool:
23
+ """Check if config file exists."""
24
+ return os.path.isfile(self.config_path)
25
+
26
+ def load(self) -> dict:
27
+ """Load all bingo-light config values.
28
+
29
+ Returns:
30
+ dict with upstream_url, upstream_branch, patches_branch, tracking_branch
31
+
32
+ Raises:
33
+ NotInitializedError: if config file doesn't exist
34
+ """
35
+ if not self.exists():
36
+ raise NotInitializedError()
37
+
38
+ return {
39
+ "upstream_url": self.get("upstream-url") or "",
40
+ "upstream_branch": self.get("upstream-branch") or "main",
41
+ "patches_branch": self.get("patches-branch") or DEFAULT_PATCHES,
42
+ "tracking_branch": self.get("tracking-branch") or DEFAULT_TRACKING,
43
+ }
44
+
45
+ def save(
46
+ self,
47
+ url: str,
48
+ branch: str,
49
+ patches_branch: str = DEFAULT_PATCHES,
50
+ tracking_branch: str = DEFAULT_TRACKING,
51
+ ) -> None:
52
+ """Write config values."""
53
+ self.set("upstream-url", url)
54
+ self.set("upstream-branch", branch)
55
+ self.set("patches-branch", patches_branch)
56
+ self.set("tracking-branch", tracking_branch)
57
+
58
+ def get(self, key: str) -> Optional[str]:
59
+ """Get a config value. Returns None if not found."""
60
+ try:
61
+ result = subprocess.run(
62
+ ["git", "config", "--file", self.config_path, f"bingolight.{key}"],
63
+ cwd=self.repo_dir,
64
+ capture_output=True,
65
+ text=True,
66
+ )
67
+ if result.returncode == 0:
68
+ return result.stdout.strip()
69
+ # Try without bingolight prefix
70
+ result = subprocess.run(
71
+ ["git", "config", "--file", self.config_path, key],
72
+ cwd=self.repo_dir,
73
+ capture_output=True,
74
+ text=True,
75
+ )
76
+ if result.returncode == 0:
77
+ return result.stdout.strip()
78
+ return None
79
+ except Exception:
80
+ return None
81
+
82
+ def set(self, key: str, value: str) -> None:
83
+ """Set a config value."""
84
+ subprocess.run(
85
+ ["git", "config", "--file", self.config_path, f"bingolight.{key}", value],
86
+ cwd=self.repo_dir,
87
+ capture_output=True,
88
+ text=True,
89
+ check=True,
90
+ )
91
+
92
+ def list_all(self) -> dict:
93
+ """List all config values as a dict."""
94
+ try:
95
+ result = subprocess.run(
96
+ ["git", "config", "--file", self.config_path, "--list"],
97
+ cwd=self.repo_dir,
98
+ capture_output=True,
99
+ text=True,
100
+ )
101
+ if result.returncode != 0:
102
+ return {}
103
+ items = {}
104
+ for line in result.stdout.strip().splitlines():
105
+ if "=" in line:
106
+ k, v = line.split("=", 1)
107
+ items[k] = v
108
+ return items
109
+ except Exception:
110
+ return {}
@@ -0,0 +1,48 @@
1
+ """
2
+ bingo_core.exceptions — Exception classes for bingo-light.
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ from typing import List
8
+
9
+
10
+ class BingoError(Exception):
11
+ """Base error for bingo-light operations."""
12
+
13
+
14
+ class GitError(BingoError):
15
+ """A git command failed."""
16
+
17
+ def __init__(self, cmd: List[str], returncode: int, stderr: str):
18
+ self.cmd = cmd
19
+ self.returncode = returncode
20
+ self.stderr = stderr
21
+ super().__init__(
22
+ f"git command failed (exit {returncode}): {' '.join(cmd)}\n{stderr}"
23
+ )
24
+
25
+
26
+ class NotGitRepoError(BingoError):
27
+ """Not inside a git repository."""
28
+
29
+ def __init__(self):
30
+ super().__init__("Not a git repository. Run this inside a git repo.")
31
+
32
+
33
+ class NotInitializedError(BingoError):
34
+ """bingo-light not initialized in this repo."""
35
+
36
+ def __init__(self):
37
+ super().__init__(
38
+ "bingo-light not initialized. Run: bingo-light init <upstream-url>"
39
+ )
40
+
41
+
42
+ class DirtyTreeError(BingoError):
43
+ """Working tree has uncommitted changes."""
44
+
45
+ def __init__(self, msg: str = ""):
46
+ super().__init__(
47
+ msg or "Working tree is dirty. Commit or stash your changes first."
48
+ )
@@ -0,0 +1,194 @@
1
+ """
2
+ bingo_core.git — Git subprocess wrapper.
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ import os
8
+ import re
9
+ import subprocess
10
+ from typing import List, Optional
11
+
12
+ from bingo_core.exceptions import GitError
13
+ from bingo_core.models import PatchInfo
14
+
15
+
16
+ class Git:
17
+ """Unified git subprocess wrapper. All git calls go through here."""
18
+
19
+ def __init__(self, cwd: Optional[str] = None):
20
+ self.cwd = cwd or os.getcwd()
21
+
22
+ def run(self, *args: str, check: bool = True) -> str:
23
+ """Run a git command and return stdout (stripped).
24
+
25
+ Args:
26
+ *args: git subcommand and arguments (e.g. "rev-parse", "HEAD")
27
+ check: if True, raise GitError on non-zero exit
28
+
29
+ Returns:
30
+ stdout as stripped string
31
+
32
+ Raises:
33
+ GitError: if check=True and command fails
34
+ """
35
+ cmd = ["git"] + list(args)
36
+ result = subprocess.run(
37
+ cmd,
38
+ cwd=self.cwd,
39
+ capture_output=True,
40
+ text=True,
41
+ )
42
+ if check and result.returncode != 0:
43
+ raise GitError(cmd, result.returncode, result.stderr.strip())
44
+ return result.stdout.strip()
45
+
46
+ def run_ok(self, *args: str) -> bool:
47
+ """Run a git command and return True if it succeeds."""
48
+ try:
49
+ cmd = ["git"] + list(args)
50
+ result = subprocess.run(
51
+ cmd,
52
+ cwd=self.cwd,
53
+ capture_output=True,
54
+ text=True,
55
+ )
56
+ return result.returncode == 0
57
+ except Exception:
58
+ return False
59
+
60
+ def run_unchecked(self, *args: str) -> subprocess.CompletedProcess:
61
+ """Run a git command and return the full CompletedProcess."""
62
+ cmd = ["git"] + list(args)
63
+ return subprocess.run(
64
+ cmd,
65
+ cwd=self.cwd,
66
+ capture_output=True,
67
+ text=True,
68
+ )
69
+
70
+ def rev_parse(self, ref: str) -> Optional[str]:
71
+ """Resolve a ref to a commit hash. Returns None if missing."""
72
+ try:
73
+ return self.run("rev-parse", ref)
74
+ except GitError:
75
+ return None
76
+
77
+ def rev_parse_short(self, ref: str) -> str:
78
+ """Resolve a ref to a short commit hash."""
79
+ try:
80
+ return self.run("rev-parse", "--short", ref)
81
+ except GitError:
82
+ return ""
83
+
84
+ def rev_list_count(self, range_spec: str) -> int:
85
+ """Count commits in a range. Returns 0 on error."""
86
+ try:
87
+ return int(self.run("rev-list", "--count", range_spec))
88
+ except (GitError, ValueError):
89
+ return 0
90
+
91
+ def fetch(self, remote: str) -> bool:
92
+ """Fetch from a remote. Returns True on success."""
93
+ return self.run_ok("fetch", remote)
94
+
95
+ def is_clean(self) -> bool:
96
+ """Check if working tree is clean (no staged or unstaged changes)."""
97
+ return (
98
+ self.run_ok("diff", "--quiet", "HEAD")
99
+ and self.run_ok("diff", "--cached", "--quiet")
100
+ )
101
+
102
+ def ls_files_unmerged(self) -> List[str]:
103
+ """Return list of unmerged file paths (sorted, unique)."""
104
+ try:
105
+ output = self.run("ls-files", "--unmerged", check=False)
106
+ if not output:
107
+ return []
108
+ files = set()
109
+ for line in output.splitlines():
110
+ # format: mode hash stage\tfilename
111
+ parts = line.split("\t", 1)
112
+ if len(parts) == 2:
113
+ files.add(parts[1])
114
+ return sorted(files)
115
+ except Exception:
116
+ return []
117
+
118
+ def diff_names(self, range_spec: str) -> List[str]:
119
+ """Return list of changed file names in a range."""
120
+ try:
121
+ output = self.run("diff", "--name-only", range_spec, check=False)
122
+ if not output:
123
+ return []
124
+ return sorted(set(output.splitlines()))
125
+ except Exception:
126
+ return []
127
+
128
+ def merge_base(self, ref1: str, ref2: str) -> Optional[str]:
129
+ """Find merge base of two refs. Returns None if not found."""
130
+ try:
131
+ return self.run("merge-base", ref1, ref2)
132
+ except GitError:
133
+ return None
134
+
135
+ def current_branch(self) -> str:
136
+ """Return name of current branch."""
137
+ try:
138
+ return self.run("branch", "--show-current")
139
+ except GitError:
140
+ return ""
141
+
142
+ def log_patches(self, base: str, branch: str) -> List[PatchInfo]:
143
+ """Parse patches from git log in a single pass.
144
+
145
+ Returns list of PatchInfo for commits in base..branch.
146
+ """
147
+ try:
148
+ output = self.run(
149
+ "log",
150
+ "--format=PATCH\t%h\t%s",
151
+ "--shortstat",
152
+ "--numstat",
153
+ "--reverse",
154
+ f"{base}..{branch}",
155
+ )
156
+ except GitError:
157
+ return []
158
+
159
+ patches: List[PatchInfo] = []
160
+ current: Optional[PatchInfo] = None
161
+
162
+ for line in output.splitlines():
163
+ if line.startswith("PATCH\t"):
164
+ if current is not None:
165
+ patches.append(current)
166
+ parts = line.split("\t", 2)
167
+ hash_val = parts[1] if len(parts) > 1 else ""
168
+ subject = parts[2] if len(parts) > 2 else ""
169
+ name = ""
170
+ m = re.match(r"^\[bl\] ([^:]+):", subject)
171
+ if m:
172
+ name = m.group(1)
173
+ current = PatchInfo(
174
+ name=name, hash=hash_val, subject=subject, files=0, stat=""
175
+ )
176
+ elif current is not None:
177
+ # numstat line: add\tdel\tfile (binary shows -\t-\tfile)
178
+ if re.match(r"^(\d+|-)\t(\d+|-)\t", line):
179
+ current.files += 1
180
+ parts = line.split("\t", 2)
181
+ if len(parts) >= 2:
182
+ try:
183
+ current.insertions += int(parts[0])
184
+ current.deletions += int(parts[1])
185
+ except ValueError:
186
+ pass # binary: -\t-\t — counted but no line stats
187
+ # shortstat line
188
+ elif re.match(r"^\s*\d+ file", line):
189
+ current.stat = line.strip()
190
+
191
+ if current is not None:
192
+ patches.append(current)
193
+
194
+ return patches
@@ -0,0 +1,37 @@
1
+ """
2
+ bingo_core.models — Data classes for bingo-light.
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ from dataclasses import dataclass, asdict
8
+
9
+
10
+ @dataclass
11
+ class PatchInfo:
12
+ """Information about a single patch in the stack."""
13
+
14
+ name: str
15
+ hash: str
16
+ subject: str
17
+ files: int = 0
18
+ stat: str = ""
19
+ insertions: int = 0
20
+ deletions: int = 0
21
+
22
+ def to_dict(self) -> dict:
23
+ return asdict(self)
24
+
25
+
26
+ @dataclass
27
+ class ConflictInfo:
28
+ """Information about a conflict in a single file."""
29
+
30
+ file: str
31
+ ours: str = ""
32
+ theirs: str = ""
33
+ conflict_count: int = 0
34
+ merge_hint: str = ""
35
+
36
+ def to_dict(self) -> dict:
37
+ return asdict(self)