lambda-watcher 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.
Files changed (38) hide show
  1. lambda_watcher/__init__.py +4 -0
  2. lambda_watcher/__main__.py +4 -0
  3. lambda_watcher/analysis/__init__.py +115 -0
  4. lambda_watcher/analysis/deps.py +291 -0
  5. lambda_watcher/analysis/envvars.py +80 -0
  6. lambda_watcher/analysis/handler.py +111 -0
  7. lambda_watcher/analysis/inventory.py +118 -0
  8. lambda_watcher/analysis/runtime.py +117 -0
  9. lambda_watcher/analysis/secrets.py +178 -0
  10. lambda_watcher/analysis/services.py +76 -0
  11. lambda_watcher/cli.py +1406 -0
  12. lambda_watcher/config.py +324 -0
  13. lambda_watcher/db.py +466 -0
  14. lambda_watcher/diffing/__init__.py +14 -0
  15. lambda_watcher/diffing/build.py +51 -0
  16. lambda_watcher/diffing/compare.py +525 -0
  17. lambda_watcher/diffing/highlight.py +312 -0
  18. lambda_watcher/diffing/icons.py +132 -0
  19. lambda_watcher/diffing/intraline.py +162 -0
  20. lambda_watcher/diffing/render_html.py +697 -0
  21. lambda_watcher/diffing/render_text.py +198 -0
  22. lambda_watcher/extract.py +227 -0
  23. lambda_watcher/gitmirror.py +151 -0
  24. lambda_watcher/identify.py +201 -0
  25. lambda_watcher/ingest.py +480 -0
  26. lambda_watcher/notify.py +59 -0
  27. lambda_watcher/reindex.py +158 -0
  28. lambda_watcher/service.py +553 -0
  29. lambda_watcher/store.py +209 -0
  30. lambda_watcher/templates.py +124 -0
  31. lambda_watcher/utils.py +314 -0
  32. lambda_watcher/watcher.py +241 -0
  33. lambda_watcher-0.1.0.dist-info/METADATA +409 -0
  34. lambda_watcher-0.1.0.dist-info/RECORD +38 -0
  35. lambda_watcher-0.1.0.dist-info/WHEEL +5 -0
  36. lambda_watcher-0.1.0.dist-info/entry_points.txt +3 -0
  37. lambda_watcher-0.1.0.dist-info/licenses/LICENSE +201 -0
  38. lambda_watcher-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,198 @@
1
+ """Terminal rendering of a version diff, using rich."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from rich.console import Console, Group
6
+ from rich.panel import Panel
7
+ from rich.rule import Rule
8
+ from rich.syntax import Syntax
9
+ from rich.table import Table
10
+ from rich.text import Text
11
+
12
+ from ..utils import human_size, rename_label, signed
13
+ from .compare import VersionDiff
14
+
15
+ _KIND_STYLE = {
16
+ "added": "green",
17
+ "removed": "red",
18
+ "modified": "yellow",
19
+ "renamed": "cyan",
20
+ "mode-changed": "magenta",
21
+ }
22
+ _SEVERITY_STYLE = {"high": "bold red", "medium": "yellow", "low": "dim"}
23
+
24
+
25
+ def _label(change) -> str:
26
+ """How one file is named in a listing.
27
+
28
+ A rename writes the two paths as one, with the part that moved in braces —
29
+ the alternative is 90 characters of identical path twice over, wrapped
30
+ across two rows, for a version number that changed in the middle.
31
+ """
32
+ if change.kind != "renamed" or not change.old_path:
33
+ return change.path
34
+ head, was, now, tail = rename_label(change.old_path, change.path)
35
+ return f"{head}{{{was} \u2192 {now}}}{tail}"
36
+
37
+
38
+ def _stat_line(diff: VersionDiff) -> Text:
39
+ counts = diff.counts()
40
+ text = Text()
41
+ for kind in ("added", "removed", "modified", "renamed"):
42
+ if counts.get(kind):
43
+ text.append(f"{counts[kind]} {kind} ", style=_KIND_STYLE[kind])
44
+ if diff.vendor_files_changed:
45
+ text.append(f"{diff.vendor_files_changed} vendored (hidden) ", style="dim")
46
+ if diff.diffs_computed:
47
+ text.append(f"+{diff.total_added_lines}", style="green")
48
+ text.append(" / ")
49
+ text.append(f"-{diff.total_removed_lines}", style="red")
50
+ text.append(" lines")
51
+ else:
52
+ text.append("line counts skipped (--no-patch)", style="dim")
53
+ return text
54
+
55
+
56
+ def render_summary(console: Console, diff: VersionDiff) -> None:
57
+ header = Text()
58
+ header.append(diff.function_name, style="bold")
59
+ header.append(f" v{diff.a_seq:04d} → v{diff.b_seq:04d}", style="bold cyan")
60
+ console.print(Panel(Group(header, _stat_line(diff)), border_style="cyan", expand=False))
61
+
62
+ if diff.runtime_change:
63
+ console.print(f" [bold]runtime[/bold] {diff.runtime_change[0]} → {diff.runtime_change[1]}")
64
+ if diff.handler_change:
65
+ before, after = diff.handler_change
66
+ console.print(f" [bold]handler[/bold] {before or '?'} → {after or '?'}")
67
+
68
+ size_a = diff.a_meta.get("total_size", 0)
69
+ size_b = diff.b_meta.get("total_size", 0)
70
+ if size_a or size_b:
71
+ console.print(
72
+ f" [bold]size[/bold] {human_size(size_a)} → {human_size(size_b)} "
73
+ f"([{'green' if size_b <= size_a else 'yellow'}]{signed(size_b - size_a)} B[/])"
74
+ )
75
+
76
+
77
+ def render_dependencies(console: Console, diff: VersionDiff) -> None:
78
+ if not diff.deps:
79
+ return
80
+ table = Table(title="Dependencies", title_justify="left", header_style="bold", box=None,
81
+ padding=(0, 2, 0, 0))
82
+ table.add_column("")
83
+ table.add_column("manager", style="dim")
84
+ table.add_column("package")
85
+ table.add_column("from", style="red")
86
+ table.add_column("to", style="green")
87
+ table.add_column("origin", style="dim")
88
+
89
+ marks = {"added": ("+", "green"), "removed": ("−", "red"), "changed": ("~", "yellow")}
90
+ for change in diff.deps:
91
+ mark, style = marks[change.kind]
92
+ table.add_row(
93
+ Text(mark, style=style),
94
+ change.manager,
95
+ change.name,
96
+ change.old_version or "—",
97
+ change.new_version or "—",
98
+ "declared" if change.is_declared else "installed",
99
+ )
100
+ console.print()
101
+ console.print(table)
102
+
103
+
104
+ def render_context(console: Console, diff: VersionDiff) -> None:
105
+ rows: list[tuple[str, str, str]] = []
106
+ if diff.env_added:
107
+ rows.append(("Env vars added", ", ".join(diff.env_added), "green"))
108
+ if diff.env_removed:
109
+ rows.append(("Env vars removed", ", ".join(diff.env_removed), "red"))
110
+ if diff.services_added:
111
+ rows.append(("AWS services added", ", ".join(diff.services_added), "green"))
112
+ if diff.services_removed:
113
+ rows.append(("AWS services removed", ", ".join(diff.services_removed), "red"))
114
+ if not rows:
115
+ return
116
+ console.print()
117
+ for label, value, style in rows:
118
+ console.print(f" [bold]{label}:[/bold] [{style}]{value}[/{style}]")
119
+ if diff.env_added:
120
+ console.print(
121
+ " [dim]↑ these need to exist in the function's environment configuration[/dim]"
122
+ )
123
+
124
+
125
+ def render_findings(console: Console, diff: VersionDiff) -> None:
126
+ if not diff.findings_new and not diff.findings_fixed:
127
+ return
128
+ console.print()
129
+ if diff.findings_new:
130
+ table = Table(title="New findings", title_justify="left", title_style="bold red",
131
+ header_style="bold", box=None, padding=(0, 2, 0, 0), show_header=False)
132
+ table.add_column("", justify="right")
133
+ table.add_column("kind")
134
+ table.add_column("where", style="dim")
135
+ table.add_column("detail", style="dim")
136
+ for finding in diff.findings_new[:25]:
137
+ table.add_row(
138
+ Text(finding["severity"], style=_SEVERITY_STYLE.get(finding["severity"], "")),
139
+ finding["kind"],
140
+ f"{finding['path']}:{finding['line']}",
141
+ finding["detail"],
142
+ )
143
+ console.print(table)
144
+ if diff.findings_fixed:
145
+ console.print(f"[green]Resolved findings:[/green] {len(diff.findings_fixed)}")
146
+
147
+
148
+ def render_files(console: Console, diff: VersionDiff, show_diffs: bool = True,
149
+ max_files: int = 200) -> None:
150
+ if not diff.files:
151
+ console.print("\n[dim]No file-level changes.[/dim]")
152
+ return
153
+
154
+ console.print()
155
+ table = Table(title="Files", title_justify="left", header_style="bold", box=None,
156
+ padding=(0, 2, 0, 0))
157
+ table.add_column("")
158
+ table.add_column("path")
159
+ table.add_column("+", justify="right", style="green")
160
+ table.add_column("−", justify="right", style="red")
161
+ table.add_column("size", justify="right", style="dim")
162
+
163
+ marks = {"added": "+", "removed": "−", "modified": "~", "renamed": "→", "mode-changed": "m"}
164
+ for change in diff.files[:max_files]:
165
+ table.add_row(
166
+ Text(marks.get(change.kind, "?"), style=_KIND_STYLE.get(change.kind, "")),
167
+ Text(_label(change), style="dim" if change.is_vendor else ""),
168
+ str(change.added_lines or ""),
169
+ str(change.removed_lines or ""),
170
+ signed(change.size_delta) if change.size_delta else "",
171
+ )
172
+ console.print(table)
173
+ if len(diff.files) > max_files:
174
+ console.print(f"[dim]… and {len(diff.files) - max_files} more files[/dim]")
175
+
176
+ if not show_diffs:
177
+ return
178
+
179
+ for change in diff.files:
180
+ if not change.diff_lines:
181
+ continue
182
+ console.print()
183
+ console.print(Rule(f"[bold]{_label(change)}[/bold]",
184
+ style=_KIND_STYLE.get(change.kind, "white")))
185
+ body = "\n".join(change.diff_lines)
186
+ console.print(Syntax(body, "diff", theme="ansi_dark", word_wrap=False, background_color="default"))
187
+ if change.truncated:
188
+ console.print("[dim]… diff truncated (raise diff.max_diff_lines to see more)[/dim]")
189
+
190
+
191
+ def render(console: Console, diff: VersionDiff, show_diffs: bool = True) -> None:
192
+ render_summary(console, diff)
193
+ render_dependencies(console, diff)
194
+ render_context(console, diff)
195
+ render_findings(console, diff)
196
+ render_files(console, diff, show_diffs=show_diffs)
197
+ if diff.is_empty:
198
+ console.print("\n[green]These two versions are identical.[/green]")
@@ -0,0 +1,227 @@
1
+ """Safe extraction of Lambda deployment zips.
2
+
3
+ Deployment packages are downloaded from a trusted account, but they are still
4
+ archives from the internet: this module refuses path traversal, absolute paths,
5
+ symlinks pointing outside the tree, and archives that would explode on disk.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import os
11
+ import stat
12
+ import zipfile
13
+ from dataclasses import dataclass, field
14
+ from pathlib import Path, PurePosixPath
15
+
16
+ from .utils import LOG, rmtree
17
+
18
+
19
+ class ExtractError(RuntimeError):
20
+ """The archive could not be safely extracted."""
21
+
22
+
23
+ @dataclass
24
+ class ExtractResult:
25
+ dest: Path
26
+ file_count: int = 0
27
+ dir_count: int = 0
28
+ total_uncompressed: int = 0
29
+ total_compressed: int = 0
30
+ skipped: list[str] = field(default_factory=list)
31
+ top_level: list[str] = field(default_factory=list)
32
+ is_encrypted: bool = False
33
+ #: Name of the single wrapping directory that was lifted away, if any.
34
+ wrapper_dir: str | None = None
35
+
36
+ @property
37
+ def compression_ratio(self) -> float:
38
+ if not self.total_compressed:
39
+ return 0.0
40
+ return self.total_uncompressed / self.total_compressed
41
+
42
+
43
+ def _is_within(base: Path, target: Path) -> bool:
44
+ try:
45
+ target.resolve().relative_to(base.resolve())
46
+ return True
47
+ except ValueError:
48
+ return False
49
+
50
+
51
+ def safe_member_path(base: Path, name: str) -> Path | None:
52
+ """Resolve an archive member to a path inside ``base``, or None if unsafe."""
53
+ if not name or name.startswith("/") or name.startswith("\\"):
54
+ return None
55
+ # Windows-created archives sometimes use backslashes as separators.
56
+ normalised = name.replace("\\", "/")
57
+ pure = PurePosixPath(normalised)
58
+ if pure.is_absolute() or any(part == ".." for part in pure.parts):
59
+ return None
60
+ # Drive letters (C:/...) are traversal on Windows.
61
+ if len(pure.parts) and ":" in pure.parts[0]:
62
+ return None
63
+ target = base / Path(*pure.parts)
64
+ if not _is_within(base, target):
65
+ return None
66
+ return target
67
+
68
+
69
+ def peek_top_level(zip_path: Path) -> list[str]:
70
+ """Top-level entries of a zip without extracting it."""
71
+ try:
72
+ with zipfile.ZipFile(zip_path) as zf:
73
+ names = zf.namelist()
74
+ except (zipfile.BadZipFile, OSError):
75
+ return []
76
+ tops: list[str] = []
77
+ for name in names:
78
+ head = name.replace("\\", "/").split("/")[0]
79
+ if head and head not in tops and not head.startswith("__MACOSX"):
80
+ tops.append(head)
81
+ return tops
82
+
83
+
84
+ def strip_wrapper_dir(dest: Path) -> str | None:
85
+ """Lift a lone wrapping directory's contents up into ``dest``.
86
+
87
+ GitHub - and npm, and anything built by ``git archive`` - wraps the whole
88
+ tree in one directory named after the ref: ``myrepo-main/``,
89
+ ``myrepo-1.2.3/``, ``myrepo-a1b2c3d/``. That name changes with every
90
+ download, and both the tree hash and the file diff key off paths, so
91
+ without this a re-download of the same project reads as "every file
92
+ removed, every file added" - and never as ``unchanged``.
93
+
94
+ Exactly one level is ever removed. A package whose real layout is a single
95
+ ``src/`` directory keeps it, because collapsing further would start
96
+ discarding structure the archive actually meant.
97
+
98
+ Returns the name of the directory that was removed, or None if the tree was
99
+ left alone.
100
+ """
101
+ try:
102
+ entries = list(dest.iterdir())
103
+ except OSError:
104
+ return None
105
+ if len(entries) != 1:
106
+ return None
107
+ wrapper = entries[0]
108
+ if wrapper.is_symlink() or not wrapper.is_dir():
109
+ return None
110
+
111
+ # Three renames rather than a move per child: the wrapper steps out to a
112
+ # sibling, the emptied dest goes away, and the wrapper takes its place.
113
+ # Cost is the same whether the tree holds ten files or ten thousand.
114
+ staged = dest.parent / f"{dest.name}.unwrapped"
115
+ if staged.exists():
116
+ rmtree(staged)
117
+ try:
118
+ wrapper.rename(staged)
119
+ dest.rmdir()
120
+ staged.rename(dest)
121
+ except OSError as exc:
122
+ LOG.warning("could not unwrap %s, keeping the tree as extracted: %s", wrapper.name, exc)
123
+ # Undo whichever half of the swap went through.
124
+ if staged.exists():
125
+ dest.mkdir(parents=True, exist_ok=True)
126
+ try:
127
+ staged.rename(dest / wrapper.name)
128
+ except OSError:
129
+ LOG.error("left an unwrapped tree at %s", staged)
130
+ return None
131
+ return wrapper.name
132
+
133
+
134
+ def extract_zip(
135
+ zip_path: Path,
136
+ dest: Path,
137
+ max_uncompressed_bytes: int = 2 * 1024**3,
138
+ max_files: int = 200_000,
139
+ strip_wrapper: bool = True,
140
+ ) -> ExtractResult:
141
+ """Extract ``zip_path`` into ``dest``, enforcing the safety limits."""
142
+ dest.mkdir(parents=True, exist_ok=True)
143
+ result = ExtractResult(dest=dest)
144
+
145
+ try:
146
+ zf = zipfile.ZipFile(zip_path)
147
+ except zipfile.BadZipFile as exc:
148
+ raise ExtractError(f"not a valid zip archive: {exc}") from exc
149
+ except OSError as exc:
150
+ raise ExtractError(f"could not open archive: {exc}") from exc
151
+
152
+ with zf:
153
+ infos = zf.infolist()
154
+ if len(infos) > max_files:
155
+ raise ExtractError(f"archive has {len(infos)} entries (limit {max_files})")
156
+ planned = sum(i.file_size for i in infos)
157
+ if planned > max_uncompressed_bytes:
158
+ raise ExtractError(
159
+ f"archive expands to {planned / 1024 ** 2:.0f} MB "
160
+ f"(limit {max_uncompressed_bytes / 1024 ** 2:.0f} MB)"
161
+ )
162
+
163
+ written = 0
164
+ for info in infos:
165
+ name = info.filename
166
+ if name.startswith("__MACOSX/") or PurePosixPath(name).name == ".DS_Store":
167
+ result.skipped.append(name)
168
+ continue
169
+ if info.flag_bits & 0x1:
170
+ result.is_encrypted = True
171
+ raise ExtractError("archive is password protected")
172
+
173
+ target = safe_member_path(dest, name)
174
+ if target is None:
175
+ LOG.warning("skipping unsafe archive member %r in %s", name, zip_path.name)
176
+ result.skipped.append(name)
177
+ continue
178
+
179
+ mode = info.external_attr >> 16
180
+ if stat.S_ISLNK(mode):
181
+ # Symlinks in a deployment package are almost always vendored
182
+ # binaries; store the link text as a regular file so the tree
183
+ # stays self-contained and cannot escape the store.
184
+ target.parent.mkdir(parents=True, exist_ok=True)
185
+ link_target = zf.read(info).decode("utf-8", "replace")
186
+ target.write_text(link_target, encoding="utf-8")
187
+ result.file_count += 1
188
+ continue
189
+
190
+ if info.is_dir():
191
+ target.mkdir(parents=True, exist_ok=True)
192
+ result.dir_count += 1
193
+ continue
194
+
195
+ target.parent.mkdir(parents=True, exist_ok=True)
196
+ with zf.open(info) as src, target.open("wb") as out:
197
+ remaining = max_uncompressed_bytes - written
198
+ chunk_size = 1 << 20
199
+ while True:
200
+ chunk = src.read(chunk_size)
201
+ if not chunk:
202
+ break
203
+ remaining -= len(chunk)
204
+ if remaining < 0:
205
+ raise ExtractError("archive exceeded the uncompressed size limit")
206
+ out.write(chunk)
207
+ written += len(chunk)
208
+
209
+ result.file_count += 1
210
+ result.total_uncompressed += info.file_size
211
+ result.total_compressed += info.compress_size
212
+
213
+ # Preserve the executable bit; Lambda custom runtimes rely on it.
214
+ if mode and (mode & 0o111):
215
+ try:
216
+ os.chmod(target, (target.stat().st_mode | 0o111) & 0o777)
217
+ except OSError:
218
+ pass
219
+
220
+ if strip_wrapper:
221
+ result.wrapper_dir = strip_wrapper_dir(dest)
222
+
223
+ # Recorded after unwrapping: this is the tree everything downstream sees.
224
+ result.top_level = sorted(
225
+ {p.name for p in dest.iterdir()} if dest.exists() else set()
226
+ )
227
+ return result
@@ -0,0 +1,151 @@
1
+ """Optional per-function git repository, one commit per archived version.
2
+
3
+ This is the shortest path to a review workflow you already know: every version
4
+ is a commit tagged ``v0007``, so ``git diff v0002 v0010``, ``git log -p``, VS
5
+ Code's diff viewer and any git GUI all work on a single function's history
6
+ without ten unrelated Lambdas mixed into the same repo. It lives at
7
+ ``functions/<slug>/repo/``, and ``lambda-watcher open`` hands that folder to an
8
+ editor.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import shutil
14
+ import subprocess
15
+ from dataclasses import dataclass
16
+ from pathlib import Path
17
+
18
+ from .config import GitMirrorConfig
19
+ from .utils import LOG, matches_any, rmtree
20
+
21
+
22
+ class GitUnavailable(RuntimeError):
23
+ """git is not installed or not usable."""
24
+
25
+
26
+ @dataclass
27
+ class MirrorResult:
28
+ repo: Path
29
+ commit: str | None
30
+ tag: str | None
31
+ created_repo: bool = False
32
+
33
+
34
+ def git_available() -> bool:
35
+ return shutil.which("git") is not None
36
+
37
+
38
+ def _run(repo: Path, *args: str, check: bool = True, env_extra: dict[str, str] | None = None) -> str:
39
+ import os
40
+
41
+ env = os.environ.copy()
42
+ # Keep the mirror hermetic: no user hooks, no global config surprises.
43
+ env.update({"GIT_CONFIG_NOSYSTEM": "1", "GIT_TERMINAL_PROMPT": "0"})
44
+ if env_extra:
45
+ env.update(env_extra)
46
+ proc = subprocess.run(
47
+ ["git", *args],
48
+ cwd=str(repo),
49
+ capture_output=True,
50
+ text=True,
51
+ env=env,
52
+ timeout=300,
53
+ )
54
+ if check and proc.returncode != 0:
55
+ raise RuntimeError(f"git {' '.join(args)} failed: {proc.stderr.strip() or proc.stdout.strip()}")
56
+ return proc.stdout.strip()
57
+
58
+
59
+ def ensure_repo(repo: Path, cfg: GitMirrorConfig) -> bool:
60
+ """Create the mirror repo if needed. Returns True when it was created."""
61
+ if not git_available():
62
+ raise GitUnavailable("git executable not found on PATH")
63
+ created = False
64
+ if not (repo / ".git").exists():
65
+ repo.mkdir(parents=True, exist_ok=True)
66
+ _run(repo, "init", "-q", "-b", "main")
67
+ _run(repo, "config", "user.name", cfg.author_name)
68
+ _run(repo, "config", "user.email", cfg.author_email)
69
+ _run(repo, "config", "core.autocrlf", "false")
70
+ # Deployment packages contain binaries; keep git from mangling them.
71
+ (repo / ".gitattributes").write_text("* -text\n", encoding="utf-8")
72
+ created = True
73
+ return created
74
+
75
+
76
+ def _clear_worktree(repo: Path) -> None:
77
+ for entry in repo.iterdir():
78
+ if entry.name == ".git":
79
+ continue
80
+ if entry.is_dir():
81
+ rmtree(entry)
82
+ else:
83
+ try:
84
+ entry.unlink()
85
+ except OSError:
86
+ pass
87
+
88
+
89
+ def _copy_tree(src: Path, repo: Path, vendor_globs: list[str], include_vendor: bool) -> int:
90
+ copied = 0
91
+ for path in src.rglob("*"):
92
+ if path.is_dir():
93
+ continue
94
+ rel = path.relative_to(src).as_posix()
95
+ if rel.startswith(".git/") or rel == ".git":
96
+ continue
97
+ if not include_vendor and matches_any(rel, vendor_globs):
98
+ continue
99
+ target = repo / rel
100
+ target.parent.mkdir(parents=True, exist_ok=True)
101
+ try:
102
+ shutil.copy2(path, target)
103
+ copied += 1
104
+ except OSError as exc:
105
+ LOG.debug("git mirror skipped %s: %s", rel, exc)
106
+ return copied
107
+
108
+
109
+ def commit_version(
110
+ repo: Path,
111
+ code_dir: Path,
112
+ cfg: GitMirrorConfig,
113
+ seq: int,
114
+ message: str,
115
+ when_iso: str | None = None,
116
+ vendor_globs: list[str] | None = None,
117
+ ) -> MirrorResult:
118
+ """Replace the worktree with ``code_dir`` and commit it as version ``seq``."""
119
+ created = ensure_repo(repo, cfg)
120
+ _clear_worktree(repo)
121
+ # .gitattributes is part of the repo, not of any version; restore it.
122
+ (repo / ".gitattributes").write_text("* -text\n", encoding="utf-8")
123
+ _copy_tree(code_dir, repo, vendor_globs or [], cfg.include_vendor)
124
+
125
+ _run(repo, "add", "-A")
126
+ status = _run(repo, "status", "--porcelain")
127
+ tag = f"{cfg.tag_prefix}{seq:04d}"
128
+ if not status:
129
+ # Identical content: still tag it so `git diff v2 v10` never 404s.
130
+ try:
131
+ head = _run(repo, "rev-parse", "HEAD")
132
+ _run(repo, "tag", "-f", tag, head, check=False)
133
+ return MirrorResult(repo, head, tag, created)
134
+ except RuntimeError:
135
+ return MirrorResult(repo, None, None, created)
136
+
137
+ env_extra = {}
138
+ if when_iso:
139
+ env_extra = {"GIT_AUTHOR_DATE": when_iso, "GIT_COMMITTER_DATE": when_iso}
140
+ _run(repo, "-c", f"user.name={cfg.author_name}", "-c", f"user.email={cfg.author_email}",
141
+ "commit", "-q", "-m", message, env_extra=env_extra)
142
+ head = _run(repo, "rev-parse", "HEAD")
143
+ _run(repo, "tag", "-f", tag, head, check=False)
144
+ return MirrorResult(repo, head, tag, created)
145
+
146
+
147
+ def diff(repo: Path, tag_a: str, tag_b: str, extra_args: list[str] | None = None) -> str:
148
+ args = ["diff", tag_a, tag_b]
149
+ if extra_args:
150
+ args = ["diff", *extra_args, tag_a, tag_b]
151
+ return _run(repo, *args, check=False)