diffstory 0.4.1__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: diffstory
3
- Version: 0.4.1
3
+ Version: 0.4.2
4
4
  Summary: Transform Git diffs into rich, interactive, self-contained HTML reports
5
5
  License: MIT
6
6
  Project-URL: Homepage, https://github.com/lakshayjindal/diffstory
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "diffstory"
7
- version = "0.4.1"
7
+ version = "0.4.2"
8
8
  description = "Transform Git diffs into rich, interactive, self-contained HTML reports"
9
9
  readme = "README.md"
10
10
  license = {text = "MIT"}
@@ -1,3 +1,3 @@
1
1
  """DiffStory — Transform Git diffs into rich, interactive HTML reports."""
2
2
 
3
- __version__ = "0.4.1"
3
+ __version__ = "0.4.2"
@@ -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
- if verbose:
393
- rev_desc = "staged" if args.staged else "working tree"
394
- if commit_a and commit_b:
395
- rev_desc = f"{commit_a}..{commit_b}"
396
- elif commit_a:
397
- rev_desc = commit_a
398
- print(f" Diff: {rev_desc}")
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
- try:
401
- # Get diff
402
- diff_text = get_diff_with_renames(
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
- if not diff_text.strip():
413
- print("No changes detected.")
414
- sys.exit(0)
409
+ spinner.update("Parsing diff...")
410
+ files = parse_diff(diff_text)
415
411
 
416
- if verbose:
417
- print(f" Diff size: {len(diff_text)} bytes")
412
+ if not files:
413
+ spinner.fail("No parseable diff files found")
414
+ sys.exit(0)
418
415
 
419
- # Parse diff
420
- files = parse_diff(diff_text)
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
- if not files:
423
- print("No parseable diff files found.")
424
- sys.exit(0)
421
+ # Progress callback for per-file blame tracking
422
+ total_files = len(files)
425
423
 
426
- if verbose:
427
- print(f" Files changed: {len(files)}")
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
- # Generate exports if requested
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
- # Always generate HTML report
435
- try:
436
- report_path = generate_report(
437
- files,
438
- output_path=output_path,
439
- staged=args.staged,
440
- commit_a=commit_a,
441
- commit_b=commit_b,
442
- verbose=verbose,
443
- )
444
- except Exception as e:
445
- if debug:
446
- import traceback
447
- traceback.print_exc()
448
- print(f"Error generating report: {e}", file=sys.stderr)
449
- sys.exit(1)
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
- print(f"\\n HTML: {report_path}")
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()
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: diffstory
3
- Version: 0.4.1
3
+ Version: 0.4.2
4
4
  Summary: Transform Git diffs into rich, interactive, self-contained HTML reports
5
5
  License: MIT
6
6
  Project-URL: Homepage, https://github.com/lakshayjindal/diffstory
@@ -6,6 +6,7 @@ src/diffstory/cli.py
6
6
  src/diffstory/diff_parser.py
7
7
  src/diffstory/git_utils.py
8
8
  src/diffstory/html_generator.py
9
+ src/diffstory/loader.py
9
10
  src/diffstory/syntax.py
10
11
  src/diffstory.egg-info/PKG-INFO
11
12
  src/diffstory.egg-info/SOURCES.txt
File without changes
File without changes