diffstory 0.4.0__tar.gz → 0.4.2__tar.gz
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.
- {diffstory-0.4.0 → diffstory-0.4.2}/PKG-INFO +1 -1
- {diffstory-0.4.0 → diffstory-0.4.2}/pyproject.toml +1 -1
- {diffstory-0.4.0 → diffstory-0.4.2}/src/diffstory/__init__.py +1 -1
- {diffstory-0.4.0 → diffstory-0.4.2}/src/diffstory/cli.py +51 -53
- {diffstory-0.4.0 → diffstory-0.4.2}/src/diffstory/diff_parser.py +13 -1
- {diffstory-0.4.0 → diffstory-0.4.2}/src/diffstory/git_utils.py +40 -0
- {diffstory-0.4.0 → diffstory-0.4.2}/src/diffstory/html_generator.py +13 -2
- diffstory-0.4.2/src/diffstory/loader.py +98 -0
- {diffstory-0.4.0 → diffstory-0.4.2}/src/diffstory.egg-info/PKG-INFO +1 -1
- {diffstory-0.4.0 → diffstory-0.4.2}/src/diffstory.egg-info/SOURCES.txt +1 -0
- {diffstory-0.4.0 → diffstory-0.4.2}/README.md +0 -0
- {diffstory-0.4.0 → diffstory-0.4.2}/setup.cfg +0 -0
- {diffstory-0.4.0 → diffstory-0.4.2}/src/diffstory/__main__.py +0 -0
- {diffstory-0.4.0 → diffstory-0.4.2}/src/diffstory/syntax.py +0 -0
- {diffstory-0.4.0 → diffstory-0.4.2}/src/diffstory.egg-info/dependency_links.txt +0 -0
- {diffstory-0.4.0 → diffstory-0.4.2}/src/diffstory.egg-info/entry_points.txt +0 -0
- {diffstory-0.4.0 → diffstory-0.4.2}/src/diffstory.egg-info/requires.txt +0 -0
- {diffstory-0.4.0 → diffstory-0.4.2}/src/diffstory.egg-info/top_level.txt +0 -0
|
@@ -13,11 +13,11 @@ from diffstory.diff_parser import parse_diff
|
|
|
13
13
|
from diffstory.git_utils import (
|
|
14
14
|
GitError,
|
|
15
15
|
check_git_repo,
|
|
16
|
-
get_diff,
|
|
17
16
|
get_diff_with_renames,
|
|
18
17
|
get_git_root,
|
|
19
18
|
)
|
|
20
19
|
from diffstory.html_generator import generate_report
|
|
20
|
+
from diffstory.loader import Spinner
|
|
21
21
|
|
|
22
22
|
|
|
23
23
|
# ---------------------------------------------------------------------------
|
|
@@ -389,67 +389,65 @@ def main() -> None:
|
|
|
389
389
|
# Parse revisions
|
|
390
390
|
commit_a, commit_b, paths = _parse_revisions(args)
|
|
391
391
|
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
392
|
+
with Spinner() as spinner:
|
|
393
|
+
spinner.update("Fetching diff...")
|
|
394
|
+
try:
|
|
395
|
+
diff_text = get_diff_with_renames(
|
|
396
|
+
staged=args.staged,
|
|
397
|
+
commit_a=commit_a,
|
|
398
|
+
commit_b=commit_b,
|
|
399
|
+
paths=paths,
|
|
400
|
+
)
|
|
401
|
+
except GitError as e:
|
|
402
|
+
spinner.fail(f"Error fetching diff: {e}")
|
|
403
|
+
sys.exit(1)
|
|
399
404
|
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
staged=args.staged,
|
|
404
|
-
commit_a=commit_a,
|
|
405
|
-
commit_b=commit_b,
|
|
406
|
-
paths=paths,
|
|
407
|
-
)
|
|
408
|
-
except GitError as e:
|
|
409
|
-
print(f"Error fetching diff: {e}", file=sys.stderr)
|
|
410
|
-
sys.exit(1)
|
|
405
|
+
if not diff_text.strip():
|
|
406
|
+
spinner.fail("No changes detected")
|
|
407
|
+
sys.exit(0)
|
|
411
408
|
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
sys.exit(0)
|
|
409
|
+
spinner.update("Parsing diff...")
|
|
410
|
+
files = parse_diff(diff_text)
|
|
415
411
|
|
|
416
|
-
|
|
417
|
-
|
|
412
|
+
if not files:
|
|
413
|
+
spinner.fail("No parseable diff files found")
|
|
414
|
+
sys.exit(0)
|
|
418
415
|
|
|
419
|
-
|
|
420
|
-
|
|
416
|
+
# Generate exports if requested
|
|
417
|
+
has_exports = args.json or args.md or args.csv
|
|
418
|
+
if has_exports:
|
|
419
|
+
generate_exports(files, output_path, args.json, args.md, args.csv)
|
|
421
420
|
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
sys.exit(0)
|
|
421
|
+
# Progress callback for per-file blame tracking
|
|
422
|
+
total_files = len(files)
|
|
425
423
|
|
|
426
|
-
|
|
427
|
-
|
|
424
|
+
def on_blame_progress(current, total, filepath):
|
|
425
|
+
spinner.update(
|
|
426
|
+
f"Blamed {current}/{total} files",
|
|
427
|
+
suffix=Path(filepath).name,
|
|
428
|
+
)
|
|
428
429
|
|
|
429
|
-
|
|
430
|
-
has_exports = args.json or args.md or args.csv
|
|
431
|
-
if has_exports:
|
|
432
|
-
generate_exports(files, output_path, args.json, args.md, args.csv)
|
|
430
|
+
spinner.update(f"Blaming {total_files} files...")
|
|
433
431
|
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
432
|
+
# Always generate HTML report
|
|
433
|
+
try:
|
|
434
|
+
report_path = generate_report(
|
|
435
|
+
files,
|
|
436
|
+
output_path=output_path,
|
|
437
|
+
staged=args.staged,
|
|
438
|
+
commit_a=commit_a,
|
|
439
|
+
commit_b=commit_b,
|
|
440
|
+
verbose=verbose,
|
|
441
|
+
progress_callback=on_blame_progress if total_files > 1 else None,
|
|
442
|
+
)
|
|
443
|
+
except Exception as e:
|
|
444
|
+
if debug:
|
|
445
|
+
import traceback
|
|
446
|
+
traceback.print_exc()
|
|
447
|
+
spinner.fail(f"Error generating report: {e}")
|
|
448
|
+
sys.exit(1)
|
|
450
449
|
|
|
451
|
-
|
|
452
|
-
print(" Report generated successfully!")
|
|
450
|
+
spinner.succeed(f"Report saved to {Path(report_path).name}")
|
|
453
451
|
|
|
454
452
|
# Open in browser unless --no-open
|
|
455
453
|
if not args.no_open:
|
|
@@ -199,13 +199,25 @@ def parse_diff(diff_text: str) -> list[DiffFile]:
|
|
|
199
199
|
|
|
200
200
|
|
|
201
201
|
def _parse_diff_header(line: str) -> DiffFile:
|
|
202
|
-
"""Parse 'diff --git a/path b/path' header.
|
|
202
|
+
"""Parse 'diff --git a/path b/path' header.
|
|
203
|
+
|
|
204
|
+
Git uses 'dev/null' (no leading slash) in the diff --git header
|
|
205
|
+
for added/deleted files. We normalize this to '/dev/null' to
|
|
206
|
+
match the convention used in ---/+++ lines and throughout the
|
|
207
|
+
rest of the codebase.
|
|
208
|
+
"""
|
|
203
209
|
# Extract paths after 'diff --git '
|
|
204
210
|
rest = line[11:]
|
|
205
211
|
parts = rest.split(" b/", 1)
|
|
206
212
|
if len(parts) == 2:
|
|
207
213
|
old_path = parts[0][2:] if parts[0].startswith("a/") else parts[0]
|
|
208
214
|
new_path = parts[1]
|
|
215
|
+
# Git uses 'dev/null' (no leading slash) in the diff --git header.
|
|
216
|
+
# Normalize to '/dev/null' for consistency with our checks.
|
|
217
|
+
if old_path == "dev/null":
|
|
218
|
+
old_path = "/dev/null"
|
|
219
|
+
if new_path == "dev/null":
|
|
220
|
+
new_path = "/dev/null"
|
|
209
221
|
else:
|
|
210
222
|
old_path = rest
|
|
211
223
|
new_path = rest
|
|
@@ -12,14 +12,54 @@ class GitError(Exception):
|
|
|
12
12
|
"""Raised when a git command fails."""
|
|
13
13
|
|
|
14
14
|
|
|
15
|
+
# Cache the git repo root once discovered to avoid repeated rev-parse calls.
|
|
16
|
+
# This is safe because the cwd doesn't change during a single diffstory run.
|
|
17
|
+
_GIT_ROOT_CACHE: Optional[str] = None
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _get_repo_root(cwd: Path) -> Optional[str]:
|
|
21
|
+
"""Detect and cache the git repository root directory.
|
|
22
|
+
|
|
23
|
+
Returns None if cwd is not inside a git repository.
|
|
24
|
+
"""
|
|
25
|
+
global _GIT_ROOT_CACHE
|
|
26
|
+
if _GIT_ROOT_CACHE is not None:
|
|
27
|
+
return _GIT_ROOT_CACHE
|
|
28
|
+
try:
|
|
29
|
+
result = subprocess.run(
|
|
30
|
+
["git", "rev-parse", "--show-toplevel"],
|
|
31
|
+
cwd=cwd,
|
|
32
|
+
capture_output=True,
|
|
33
|
+
text=True,
|
|
34
|
+
check=True,
|
|
35
|
+
)
|
|
36
|
+
_GIT_ROOT_CACHE = result.stdout.strip()
|
|
37
|
+
return _GIT_ROOT_CACHE
|
|
38
|
+
except (subprocess.CalledProcessError, FileNotFoundError):
|
|
39
|
+
return None
|
|
40
|
+
|
|
41
|
+
|
|
15
42
|
def _run_git(args: list[str], cwd: Optional[Path] = None) -> str:
|
|
16
43
|
"""Run a git command and return stdout.
|
|
17
44
|
|
|
45
|
+
Automatically resolves cwd to the git repository root so that all
|
|
46
|
+
git commands execute from the top-level directory. This is critical
|
|
47
|
+
because paths from git diff are always repo-relative, but git blame,
|
|
48
|
+
git log, etc. resolve paths relative to the current working directory.
|
|
49
|
+
Without this, running diffstory from a subdirectory would cause
|
|
50
|
+
"fatal: cannot stat path 'src/foo.py'" errors.
|
|
51
|
+
|
|
18
52
|
Raises GitError on non-zero exit.
|
|
19
53
|
"""
|
|
20
54
|
if cwd is None:
|
|
21
55
|
cwd = Path.cwd()
|
|
22
56
|
|
|
57
|
+
# Resolve to repo root so that repo-relative paths (from diff output)
|
|
58
|
+
# work correctly with all git commands (blame, log, diff, etc.)
|
|
59
|
+
repo_root = _get_repo_root(cwd)
|
|
60
|
+
if repo_root is not None:
|
|
61
|
+
cwd = Path(repo_root)
|
|
62
|
+
|
|
23
63
|
try:
|
|
24
64
|
result = subprocess.run(
|
|
25
65
|
["git"] + args,
|
|
@@ -174,8 +174,8 @@ def _render_sidebyside_hunk(hunk: Hunk, filepath: str, lexer_cache: dict) -> str
|
|
|
174
174
|
|
|
175
175
|
rows += (
|
|
176
176
|
'<div class="sbs-row">'
|
|
177
|
-
'<div class="sbs-left ' + left_class + '">' + left_content + '</div>'
|
|
178
|
-
'<div class="sbs-right ' + right_class + '">' + right_content + '</div>'
|
|
177
|
+
'<div class="sbs-left ' + left_class + ' diff-line" data-old="' + (str(left.old_lineno) if left and left.old_lineno else '') + '" data-new="' + (str(left.new_lineno) if left and left.new_lineno else '') + '">' + left_content + '</div>'
|
|
178
|
+
'<div class="sbs-right ' + right_class + ' diff-line" data-old="' + (str(right.old_lineno) if right and right.old_lineno else '') + '" data-new="' + (str(right.new_lineno) if right and right.new_lineno else '') + '">' + right_content + '</div>'
|
|
179
179
|
'</div>\n'
|
|
180
180
|
)
|
|
181
181
|
|
|
@@ -357,6 +357,7 @@ def _collect_blame_data(
|
|
|
357
357
|
commit_a: Optional[str] = None,
|
|
358
358
|
commit_b: Optional[str] = None,
|
|
359
359
|
verbose: bool = False,
|
|
360
|
+
progress_callback: Optional[callable] = None,
|
|
360
361
|
) -> dict:
|
|
361
362
|
"""Collect blame and commit metadata for all changed lines.
|
|
362
363
|
|
|
@@ -391,6 +392,10 @@ def _collect_blame_data(
|
|
|
391
392
|
old_revision = commit_a
|
|
392
393
|
elif staged:
|
|
393
394
|
old_revision = "HEAD"
|
|
395
|
+
elif not commit_a and not commit_b:
|
|
396
|
+
# Working tree diff: blame deletions against HEAD so that
|
|
397
|
+
# old_lineno lookups correctly reference the committed version
|
|
398
|
+
old_revision = "HEAD"
|
|
394
399
|
|
|
395
400
|
# File exists — attempt blame. For working tree (all None), get_blame
|
|
396
401
|
# runs on the working tree. For --diff mode (no git repo), the per-file
|
|
@@ -465,6 +470,10 @@ def _collect_blame_data(
|
|
|
465
470
|
if verbose:
|
|
466
471
|
print(f" Mapped {mapped_count} lines to blame data")
|
|
467
472
|
|
|
473
|
+
# Report progress after each file
|
|
474
|
+
if progress_callback is not None:
|
|
475
|
+
progress_callback(fi + 1, len(files), file.display_path)
|
|
476
|
+
|
|
468
477
|
# Collect commit metadata for all unique commits
|
|
469
478
|
commits: dict = {}
|
|
470
479
|
if blame_attempted and all_commits:
|
|
@@ -522,6 +531,7 @@ def generate_report(
|
|
|
522
531
|
commit_a: Optional[str] = None,
|
|
523
532
|
commit_b: Optional[str] = None,
|
|
524
533
|
verbose: bool = False,
|
|
534
|
+
progress_callback: Optional[callable] = None,
|
|
525
535
|
) -> str:
|
|
526
536
|
"""Generate a self-contained HTML report.
|
|
527
537
|
|
|
@@ -545,6 +555,7 @@ def generate_report(
|
|
|
545
555
|
try:
|
|
546
556
|
blame_data_dict = _collect_blame_data(
|
|
547
557
|
files, staged=staged, commit_a=commit_a, commit_b=commit_b, verbose=verbose,
|
|
558
|
+
progress_callback=progress_callback,
|
|
548
559
|
)
|
|
549
560
|
except Exception as e:
|
|
550
561
|
if verbose:
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Animated CLI spinner with step tracking and elapsed time.
|
|
3
|
+
|
|
4
|
+
Shows a spinning animation with a status message and elapsed time,
|
|
5
|
+
then resolves to a ✓ or ✗ on completion.
|
|
6
|
+
|
|
7
|
+
Usage:
|
|
8
|
+
spinner = Spinner()
|
|
9
|
+
spinner.start("Fetching diff...")
|
|
10
|
+
# ... do work ...
|
|
11
|
+
spinner.succeed("Diff fetched") # ✓ Diff fetched (0.5s)
|
|
12
|
+
|
|
13
|
+
Or as a context manager:
|
|
14
|
+
with Spinner("Fetching diff...") as s:
|
|
15
|
+
s.update("Blamed 3/5 files...")
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import sys
|
|
21
|
+
import threading
|
|
22
|
+
import time
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class Spinner:
|
|
26
|
+
"""An animated CLI spinner with status updates and elapsed time."""
|
|
27
|
+
|
|
28
|
+
# Braille dot-dash spinner frames
|
|
29
|
+
_FRAMES = ("⣾", "⣽", "⣻", "⢿", "⡿", "⣟", "⣯", "⣷")
|
|
30
|
+
|
|
31
|
+
def __init__(self, message: str = ""):
|
|
32
|
+
self._message = message
|
|
33
|
+
self._running = False
|
|
34
|
+
self._thread: threading.Thread | None = None
|
|
35
|
+
self._start: float = 0.0
|
|
36
|
+
self._suffix: str = ""
|
|
37
|
+
|
|
38
|
+
# ── public API ──────────────────────────────────────────────
|
|
39
|
+
|
|
40
|
+
def start(self, message: str = "") -> None:
|
|
41
|
+
"""Start the spinner with an optional initial message."""
|
|
42
|
+
if message:
|
|
43
|
+
self._message = message
|
|
44
|
+
self._running = True
|
|
45
|
+
self._start = time.monotonic()
|
|
46
|
+
self._thread = threading.Thread(target=self._spin, daemon=True)
|
|
47
|
+
self._thread.start()
|
|
48
|
+
|
|
49
|
+
def update(self, message: str, suffix: str = "") -> None:
|
|
50
|
+
"""Change the status message shown next to the spinner."""
|
|
51
|
+
self._message = message
|
|
52
|
+
if suffix:
|
|
53
|
+
self._suffix = suffix
|
|
54
|
+
|
|
55
|
+
def succeed(self, message: str | None = None) -> None:
|
|
56
|
+
"""Stop the spinner and show a green-ish checkmark."""
|
|
57
|
+
self._stop("✓", message or self._message)
|
|
58
|
+
|
|
59
|
+
def fail(self, message: str | None = None) -> None:
|
|
60
|
+
"""Stop the spinner and show a red-ish cross."""
|
|
61
|
+
self._stop("✗", message or self._message)
|
|
62
|
+
|
|
63
|
+
# ── context manager ─────────────────────────────────────────
|
|
64
|
+
|
|
65
|
+
def __enter__(self) -> "Spinner":
|
|
66
|
+
self.start()
|
|
67
|
+
return self
|
|
68
|
+
|
|
69
|
+
def __exit__(self, exc_type, exc_val, exc_tb) -> None:
|
|
70
|
+
if exc_type is None:
|
|
71
|
+
self.succeed()
|
|
72
|
+
else:
|
|
73
|
+
self.fail()
|
|
74
|
+
|
|
75
|
+
# ── internals ───────────────────────────────────────────────
|
|
76
|
+
|
|
77
|
+
def _spin(self) -> None:
|
|
78
|
+
idx = 0
|
|
79
|
+
while self._running:
|
|
80
|
+
elapsed = time.monotonic() - self._start
|
|
81
|
+
frame = self._FRAMES[idx % len(self._FRAMES)]
|
|
82
|
+
extra = f" {self._suffix}" if self._suffix else ""
|
|
83
|
+
# \033[K clears to end of line so we overwrite cleanly
|
|
84
|
+
sys.stdout.write(f"\r\033[K{frame} {self._message}{extra} ")
|
|
85
|
+
sys.stdout.flush()
|
|
86
|
+
idx += 1
|
|
87
|
+
time.sleep(0.08)
|
|
88
|
+
|
|
89
|
+
def _stop(self, icon: str, message: str) -> None:
|
|
90
|
+
# Idempotent — safe to call multiple times (e.g. explicit fail + __exit__)
|
|
91
|
+
if not self._running:
|
|
92
|
+
return
|
|
93
|
+
self._running = False
|
|
94
|
+
if self._thread:
|
|
95
|
+
self._thread.join(timeout=0.3)
|
|
96
|
+
elapsed = time.monotonic() - self._start
|
|
97
|
+
sys.stdout.write(f"\r\033[K{icon} {message} ({elapsed:.1f}s)\n")
|
|
98
|
+
sys.stdout.flush()
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|