gitmole 0.3.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.
gitmole/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """gitmole: offline git repository analysis with a terminal report."""
2
+
3
+ __version__ = "0.3.0"
gitmole/__main__.py ADDED
@@ -0,0 +1,6 @@
1
+ """`python -m gitmole` entry point."""
2
+ import sys
3
+
4
+ from .cli import main
5
+
6
+ sys.exit(main())
gitmole/backtest.py ADDED
@@ -0,0 +1,82 @@
1
+ #!/usr/bin/env python3
2
+ """The repository as it was at a cut-off date, for checking the watch list against what came after.
3
+
4
+ Runs as a pipeline step: `python -m gitmole.backtest OUT_DIR --until T [--repo DIR]`, from inside
5
+ the repository. Reruns the change analysis over OUT_DIR/log.txt with the window ending at T and T as
6
+ the reference date, exports the tree at the last commit before T and runs scc on it, and writes it
7
+ all under OUT_DIR/backtest/ with a meta.json the loader accepts.
8
+
9
+ The last commit before T is chosen by committer date (trend.rev_before with end_of_day=False),
10
+ since "the tree as of T" is a committer-date notion. The change analysis windows commits by author
11
+ date instead, so the two only disagree for commits that were rebased or cherry-picked after their
12
+ original authoring."""
13
+ from __future__ import annotations
14
+
15
+ import argparse
16
+ import json
17
+ import os
18
+ import subprocess
19
+ import sys
20
+ import tempfile
21
+
22
+ from . import filetypes, load, maat, trend
23
+
24
+
25
+ def size_at(repo: str, rev: str, out_dir: str) -> str:
26
+ """scc's --by-file JSON over the tree at rev, exported through a temporary index so no
27
+ archive is held in memory and export-ignore attributes do not thin the tree.
28
+
29
+ The tree is written under `out_dir`, not the system temp directory: a SIGKILL cannot run the
30
+ cleanup, and a checkout left next to the report is one the next run clears away."""
31
+ with tempfile.TemporaryDirectory(dir=out_dir, prefix=".backtest-tree-") as tmp:
32
+ tree = os.path.join(tmp, "tree")
33
+ os.makedirs(tree)
34
+ env = dict(os.environ, GIT_INDEX_FILE=os.path.join(tmp, "index"))
35
+ subprocess.run(["git", "read-tree", rev], cwd=repo, env=env, check=True, capture_output=True, text=True)
36
+ subprocess.run(["git", "checkout-index", "-a", f"--prefix={tree}/"], cwd=repo, env=env, check=True, capture_output=True, text=True)
37
+ return subprocess.run(["scc", "--by-file", "--format", "json"], cwd=tree, capture_output=True, text=True, check=True).stdout
38
+
39
+
40
+ def main(argv=None) -> int:
41
+ p = argparse.ArgumentParser(description=__doc__.split("\n")[0])
42
+ p.add_argument("out")
43
+ p.add_argument("--until", required=True)
44
+ p.add_argument("--repo", default=".")
45
+ args = p.parse_args(argv)
46
+ try:
47
+ until = maat.validate_now(args.until)
48
+ except ValueError as e:
49
+ print(f"backtest: {e}", file=sys.stderr)
50
+ return 2
51
+ log_path = os.path.join(args.out, "log.txt")
52
+ meta = json.loads(load._read(args.out, "meta.json") or "{}")
53
+ if not meta or not os.path.exists(log_path):
54
+ print("backtest: meta.json and log.txt are needed", file=sys.stderr)
55
+ return 2
56
+ try:
57
+ rev = trend.rev_before(args.repo, until, end_of_day=False)
58
+ except RuntimeError as e:
59
+ print(f"backtest: {e}", file=sys.stderr)
60
+ return 2
61
+ if not rev:
62
+ print(f"backtest: no commit before {until}", file=sys.stderr)
63
+ return 2
64
+ sub = os.path.join(args.out, "backtest")
65
+ os.makedirs(sub, exist_ok=True)
66
+ types = filetypes.parse(meta.get("file_types"))
67
+ maat.write_all(log_path, sub, os.path.join(args.out, "meta.json") if "aliases" in meta else None, types, now=until, until=until)
68
+ try:
69
+ size = size_at(args.repo, rev, args.out)
70
+ except subprocess.CalledProcessError as e:
71
+ first = ((e.stderr or "").strip().splitlines() or [f"{' '.join(e.cmd)} exited {e.returncode}"])[0]
72
+ print(f"backtest: {first}", file=sys.stderr)
73
+ return 2
74
+ with open(os.path.join(sub, "size.json"), "w", encoding="utf-8") as fh:
75
+ fh.write(size)
76
+ with open(os.path.join(sub, "meta.json"), "w", encoding="utf-8") as fh:
77
+ json.dump({"now": until, "last_date": until, "file_types": meta.get("file_types"), "aliases": meta.get("aliases", {})}, fh)
78
+ return 0
79
+
80
+
81
+ if __name__ == "__main__":
82
+ sys.exit(main())
gitmole/banner.py ADDED
@@ -0,0 +1,107 @@
1
+ """The MOLE banner, in neon, with a pixel mole beside it."""
2
+ from __future__ import annotations
3
+
4
+ from rich.color import Color
5
+ from rich.style import Style
6
+ from rich.text import Text
7
+
8
+ ART = """\
9
+ ███╗ ███╗ ██████╗ ██╗ ███████╗
10
+ ████╗ ████║██╔═══██╗██║ ██╔════╝
11
+ ██╔████╔██║██║ ██║██║ █████╗
12
+ ██║╚██╔╝██║██║ ██║██║ ██╔══╝
13
+ ██║ ╚═╝ ██║╚██████╔╝███████╗███████╗
14
+ ╚═╝ ╚═╝ ╚═════╝ ╚══════╝╚══════╝"""
15
+
16
+ LETTERS_WIDTH = 36
17
+ GAP = 2
18
+
19
+ # hot magenta -> pink -> electric cyan, one stop per row
20
+ NEON = ["#ff00ff", "#ff2ee6", "#ff5cc8", "#c86cff", "#5ad0ff", "#00ffff"]
21
+
22
+ # Pixel mole, 16 x 12. Two pixel rows per text line via half blocks.
23
+ PALETTE = {
24
+ "C": "#5ff0ff", # cyan outline / mound rim
25
+ "M": "#ff4fe0", # magenta cap
26
+ "P": "#ff7fc8", # pink face
27
+ "V": "#c07cff", # violet body
28
+ "B": "#7fd8f0", # mound
29
+ "D": "#4aa8c8", # mound speckle
30
+ "W": "#ffffff", # muzzle, paws
31
+ "K": "#1b1b2b", # pupils, nose
32
+ }
33
+ # "K" on rows 4-5 are the pupils; `look` slides them one column: 0 = inner (toward the letters), 1 = outer.
34
+ SPRITE = [
35
+ "....CCCCCCCC....",
36
+ "..CCMMMMMMMMCC..",
37
+ ".CMMMMMMMMMMMMC.",
38
+ ".CPCCCPWWPCCCPC.",
39
+ "CPPKKCPWWPKKCPPC",
40
+ "CPPKKCPWWPKKCPPC",
41
+ "CPPCCCPKKPCCCPPC",
42
+ "CVVVVVVVVVVVVVVC",
43
+ "CVVWWVVVVVVWWVVC",
44
+ "CVVWWVVVVVVWWVVC",
45
+ "CBBBBBDBBBBBDBBC",
46
+ ".CBBDBBBBBDBBBC.",
47
+ ]
48
+ SPRITE_WIDTH = len(SPRITE[0])
49
+ EYE_ROWS = (4, 5)
50
+ EYES = ((3, 6), (10, 13)) # column ranges of the two eyes
51
+
52
+
53
+ def sprite_grid(look: int = 0) -> list:
54
+ """The pixel grid with pupils shifted to the inner (0) or outer (1) side of each eye."""
55
+ grid = [list(row) for row in SPRITE]
56
+ for r in EYE_ROWS:
57
+ for start, end in EYES:
58
+ for c in range(start, end):
59
+ grid[r][c] = "C"
60
+ if look == 0:
61
+ grid[r][start] = grid[r][start + 1] = "K"
62
+ else:
63
+ grid[r][start + 1] = grid[r][start + 2] = "K"
64
+ return ["".join(row) for row in grid]
65
+
66
+
67
+ def _sprite_rows(look: int = 0) -> list:
68
+ """Six Text rows, each packing two pixel rows with ▀ / ▄ and fg/bg colours."""
69
+ grid = sprite_grid(look)
70
+ rows = []
71
+ for top, bottom in zip(grid[0::2], grid[1::2]):
72
+ line = Text()
73
+ for t, b in zip(top, bottom):
74
+ if t == "." and b == ".":
75
+ line.append(" ")
76
+ elif b == ".":
77
+ line.append("▀", style=Style(color=Color.parse(PALETTE[t])))
78
+ elif t == ".":
79
+ line.append("▄", style=Style(color=Color.parse(PALETTE[b])))
80
+ else:
81
+ line.append("▀", style=Style(color=Color.parse(PALETTE[t]), bgcolor=Color.parse(PALETTE[b])))
82
+ rows.append(line)
83
+ return rows
84
+
85
+
86
+ def neon(offset: int = 0, look: int = 0) -> Text:
87
+ """The banner with the palette rotated down by `offset` rows, and the mole beside it."""
88
+ text = Text()
89
+ sprite = _sprite_rows(look)
90
+ for i, row in enumerate(ART.split("\n")):
91
+ colour = NEON[(i - offset) % len(NEON)]
92
+ text.append(row, style=Style(color=Color.parse(colour), bold=True))
93
+ text.append(" " * GAP)
94
+ text.append_text(sprite[i])
95
+ text.append("\n")
96
+ return text
97
+
98
+
99
+ LOOK_EVERY = 5 # frames per glance; at 10 fps the eyes move every half second
100
+
101
+
102
+ def frames():
103
+ """Endless generator of banner frames: gradient flowing down, eyes glancing side to side."""
104
+ n = 0
105
+ while True:
106
+ yield neon(offset=n % len(NEON), look=(n // LOOK_EVERY) % 2)
107
+ n += 1
gitmole/blame.py ADDED
@@ -0,0 +1,135 @@
1
+ #!/usr/bin/env python3
2
+ """Code age from one `git blame` per tracked text file at HEAD.
3
+
4
+ Writes theseus/cohorts.json and theseus/authors.json in the layout
5
+ git-of-theseus produces (one sample, dated now), so the loader and the
6
+ "surviving code by year" table work unchanged. Standalone on purpose:
7
+ gitmole runs it as `python3 blame.py REPO OUT_DIR [--procs N] [--ignore GLOB]... [--aliases META_JSON]`.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import datetime as dt
12
+ import fnmatch
13
+ import json
14
+ import os
15
+ import subprocess
16
+ import sys
17
+ import time
18
+ from collections import Counter
19
+ from multiprocessing import Pool
20
+
21
+
22
+ def default_procs(cpu: int = None) -> int:
23
+ """Leave two cores free so the machine stays usable while blame runs."""
24
+ cpu = cpu or os.cpu_count() or 2
25
+ return max(1, cpu - 2)
26
+
27
+
28
+ def _low_priority():
29
+ try:
30
+ os.nice(10)
31
+ except OSError:
32
+ pass
33
+
34
+
35
+ try:
36
+ from . import filetypes
37
+ except ImportError: # run as a script: the package directory is sys.path[0]
38
+ import filetypes
39
+
40
+
41
+ def text_files(repo: str, ignore=()) -> list:
42
+ """Tracked, non-binary files, minus ignore globs."""
43
+ files = filetypes.git_paths(repo, "grep", "-I", "--name-only", "--cached", "-e", "")
44
+ return [f for f in files if not any(fnmatch.fnmatch(f, g) for g in ignore)]
45
+
46
+
47
+ def code_files(repo: str, ignore=(), types=filetypes.DEFAULT) -> list:
48
+ """text_files() restricted to source file types (None = no restriction)."""
49
+ return [f for f in text_files(repo, ignore) if filetypes.matches(f, types)]
50
+
51
+
52
+ def blame_file(repo: str, path: str) -> dict:
53
+ """{(year, author): lines} for one file at HEAD."""
54
+ proc = subprocess.run(["git", "blame", "--line-porcelain", "HEAD", "--", path], cwd=repo, capture_output=True, text=True, errors="replace")
55
+ if proc.returncode != 0:
56
+ return {}
57
+ counts, author, year = Counter(), None, None
58
+ for line in proc.stdout.split("\n"):
59
+ if line.startswith("author "):
60
+ author = line[7:]
61
+ elif line.startswith("author-time "):
62
+ year = str(dt.datetime.fromtimestamp(int(line[12:]), dt.timezone.utc).year)
63
+ elif line.startswith("\t"):
64
+ counts[(year, author)] += 1
65
+ return dict(counts)
66
+
67
+
68
+ def _job(args):
69
+ return blame_file(*args)
70
+
71
+
72
+ def estimate(repo: str, files: list = None, ignore=(), sample: int = 25, procs: int = None, timer=time.monotonic, types=filetypes.DEFAULT) -> dict:
73
+ """Project the wall time of the pass by timing a spread of `sample` blames single-threaded."""
74
+ files = code_files(repo, ignore, types) if files is None else files
75
+ procs = procs or default_procs()
76
+ n = len(files)
77
+ if not n or not sample:
78
+ return {"files": n, "seconds": 0.0, "sampled": 0}
79
+ step = max(1, n // sample)
80
+ picked = files[::step][:sample]
81
+ t0 = timer()
82
+ for f in picked:
83
+ blame_file(repo, f)
84
+ per_file = (timer() - t0) / len(picked)
85
+ return {"files": n, "seconds": per_file * n / procs, "sampled": len(picked)}
86
+
87
+
88
+ def aliases_from_meta(path: str) -> dict:
89
+ with open(path) as fh:
90
+ meta = json.load(fh)
91
+ if "aliases" in meta:
92
+ return dict(meta["aliases"])
93
+ return {a["name"]: i["name"] for i in meta.get("identities", []) for a in i.get("aliases", [])}
94
+
95
+
96
+ def _series(counter: Counter, label) -> dict:
97
+ now = dt.datetime.now().replace(microsecond=0).isoformat()
98
+ items = sorted(counter.items(), key=lambda kv: (-kv[1], kv[0]))
99
+ return {"labels": [label(k) for k, _ in items], "ts": [now], "y": [[n] for _, n in items]}
100
+
101
+
102
+ def write_all(repo: str, out_dir: str, ignore=(), aliases_path: str = None, procs: int = None, types=filetypes.DEFAULT) -> dict:
103
+ aliases = aliases_from_meta(aliases_path) if aliases_path else {}
104
+ files = code_files(repo, ignore, types)
105
+ years, authors = Counter(), Counter()
106
+ with Pool(procs or default_procs(), initializer=_low_priority) as pool:
107
+ for counts in pool.imap_unordered(_job, [(repo, f) for f in files], chunksize=8):
108
+ for (year, author), n in counts.items():
109
+ years[year] += n
110
+ authors[aliases.get(author, author)] += n
111
+ os.makedirs(os.path.join(out_dir, "theseus"), exist_ok=True)
112
+ cohorts = _series(years, lambda y: f"Code added in {y}")
113
+ order = sorted(range(len(cohorts["labels"])), key=lambda i: cohorts["labels"][i])
114
+ cohorts = {"labels": [cohorts["labels"][i] for i in order], "ts": cohorts["ts"], "y": [cohorts["y"][i] for i in order]}
115
+ with open(os.path.join(out_dir, "theseus", "cohorts.json"), "w", encoding="utf-8") as fh:
116
+ json.dump(cohorts, fh)
117
+ with open(os.path.join(out_dir, "theseus", "authors.json"), "w", encoding="utf-8") as fh:
118
+ json.dump(_series(authors, lambda a: a), fh)
119
+ return {"files": len(files), "lines": sum(years.values())}
120
+
121
+
122
+ if __name__ == "__main__":
123
+ args = sys.argv[1:]
124
+ procs, aliases, ignore, types = None, None, [], filetypes.DEFAULT
125
+ while "--types" in args:
126
+ i = args.index("--types"); types = filetypes.parse(args[i + 1]); del args[i:i + 2]
127
+ while "--procs" in args:
128
+ i = args.index("--procs"); procs = int(args[i + 1]); del args[i:i + 2]
129
+ while "--aliases" in args:
130
+ i = args.index("--aliases"); aliases = args[i + 1]; del args[i:i + 2]
131
+ while "--ignore" in args:
132
+ i = args.index("--ignore"); ignore.append(args[i + 1]); del args[i:i + 2]
133
+ if len(args) != 2:
134
+ sys.exit("usage: blame.py REPO OUT_DIR [--procs N] [--ignore GLOB]... [--aliases META_JSON] [--types LIST|all]")
135
+ print(json.dumps(write_all(args[0], args[1], ignore, aliases, procs, types)))