diffstory 0.3.0__tar.gz → 0.4.0__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.3.0 → diffstory-0.4.0}/PKG-INFO +1 -1
- {diffstory-0.3.0 → diffstory-0.4.0}/pyproject.toml +1 -1
- {diffstory-0.3.0 → diffstory-0.4.0}/src/diffstory/__init__.py +1 -1
- {diffstory-0.3.0 → diffstory-0.4.0}/src/diffstory/git_utils.py +41 -5
- {diffstory-0.3.0 → diffstory-0.4.0}/src/diffstory/html_generator.py +76 -24
- {diffstory-0.3.0 → diffstory-0.4.0}/src/diffstory.egg-info/PKG-INFO +1 -1
- {diffstory-0.3.0 → diffstory-0.4.0}/README.md +0 -0
- {diffstory-0.3.0 → diffstory-0.4.0}/setup.cfg +0 -0
- {diffstory-0.3.0 → diffstory-0.4.0}/src/diffstory/__main__.py +0 -0
- {diffstory-0.3.0 → diffstory-0.4.0}/src/diffstory/cli.py +0 -0
- {diffstory-0.3.0 → diffstory-0.4.0}/src/diffstory/diff_parser.py +0 -0
- {diffstory-0.3.0 → diffstory-0.4.0}/src/diffstory/syntax.py +0 -0
- {diffstory-0.3.0 → diffstory-0.4.0}/src/diffstory.egg-info/SOURCES.txt +0 -0
- {diffstory-0.3.0 → diffstory-0.4.0}/src/diffstory.egg-info/dependency_links.txt +0 -0
- {diffstory-0.3.0 → diffstory-0.4.0}/src/diffstory.egg-info/entry_points.txt +0 -0
- {diffstory-0.3.0 → diffstory-0.4.0}/src/diffstory.egg-info/requires.txt +0 -0
- {diffstory-0.3.0 → diffstory-0.4.0}/src/diffstory.egg-info/top_level.txt +0 -0
|
@@ -243,6 +243,9 @@ def get_commit_info(commit_hash: str, cwd: Optional[Path] = None) -> dict:
|
|
|
243
243
|
Returns dict with: hash, author, author_email, author_date,
|
|
244
244
|
committer, committer_email, committer_date, subject, body,
|
|
245
245
|
parents, files_changed, insertions, deletions.
|
|
246
|
+
|
|
247
|
+
Handles root commits (no parent), merge commits, and
|
|
248
|
+
the all-zero hash (uncommitted/staged changes).
|
|
246
249
|
"""
|
|
247
250
|
# Handle all-zero hash (uncommitted/staged changes)
|
|
248
251
|
if all(c == "0" for c in commit_hash):
|
|
@@ -278,16 +281,49 @@ def get_commit_info(commit_hash: str, cwd: Optional[Path] = None) -> dict:
|
|
|
278
281
|
if len(parts) < 9:
|
|
279
282
|
return {"hash": commit_hash, "subject": "unknown"}
|
|
280
283
|
|
|
284
|
+
parents_list = parts[7].split() if parts[7] else []
|
|
285
|
+
|
|
281
286
|
# Count files changed, insertions, deletions (skip for all-zero/uncommitted)
|
|
282
287
|
files_changed = 0
|
|
283
288
|
insertions = 0
|
|
284
289
|
deletions = 0
|
|
285
290
|
if not all(c == "0" for c in commit_hash):
|
|
286
291
|
try:
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
292
|
+
if parents_list:
|
|
293
|
+
# Has parents — use diff with first parent
|
|
294
|
+
parent_ref = parents_list[0]
|
|
295
|
+
stat_output = _run_git(
|
|
296
|
+
["diff", "--stat", f"{parent_ref}..{commit_hash}", "--"],
|
|
297
|
+
cwd=cwd,
|
|
298
|
+
)
|
|
299
|
+
else:
|
|
300
|
+
# Root commit — count files via log --name-status
|
|
301
|
+
stat_output = _run_git(
|
|
302
|
+
["log", "-1", "--format=", "--name-status", commit_hash],
|
|
303
|
+
cwd=cwd,
|
|
304
|
+
)
|
|
305
|
+
# Parse added files from root commit
|
|
306
|
+
for line in stat_output.strip().splitlines():
|
|
307
|
+
if line.strip():
|
|
308
|
+
files_changed += 1
|
|
309
|
+
if line.startswith("A"):
|
|
310
|
+
insertions += 1 # approximate
|
|
311
|
+
return {
|
|
312
|
+
"hash": parts[0],
|
|
313
|
+
"author": parts[1],
|
|
314
|
+
"author_email": parts[2],
|
|
315
|
+
"author_date": parts[3],
|
|
316
|
+
"committer": parts[4],
|
|
317
|
+
"committer_email": parts[5],
|
|
318
|
+
"committer_date": parts[6],
|
|
319
|
+
"parents": parents_list,
|
|
320
|
+
"subject": parts[8],
|
|
321
|
+
"body": body.strip(),
|
|
322
|
+
"files_changed": files_changed,
|
|
323
|
+
"insertions": insertions,
|
|
324
|
+
"deletions": deletions,
|
|
325
|
+
}
|
|
326
|
+
|
|
291
327
|
stat_lines = stat_output.strip().split("\n") if stat_output.strip() else []
|
|
292
328
|
for line in stat_lines:
|
|
293
329
|
m = re.search(r"(\d+) file[s]? changed", line)
|
|
@@ -310,7 +346,7 @@ def get_commit_info(commit_hash: str, cwd: Optional[Path] = None) -> dict:
|
|
|
310
346
|
"committer": parts[4],
|
|
311
347
|
"committer_email": parts[5],
|
|
312
348
|
"committer_date": parts[6],
|
|
313
|
-
"parents":
|
|
349
|
+
"parents": parents_list,
|
|
314
350
|
"subject": parts[8],
|
|
315
351
|
"body": body.strip(),
|
|
316
352
|
"files_changed": files_changed,
|
|
@@ -356,6 +356,7 @@ def _collect_blame_data(
|
|
|
356
356
|
staged: bool = False,
|
|
357
357
|
commit_a: Optional[str] = None,
|
|
358
358
|
commit_b: Optional[str] = None,
|
|
359
|
+
verbose: bool = False,
|
|
359
360
|
) -> dict:
|
|
360
361
|
"""Collect blame and commit metadata for all changed lines.
|
|
361
362
|
|
|
@@ -364,15 +365,12 @@ def _collect_blame_data(
|
|
|
364
365
|
- commits: dict mapping commit_hash to commit metadata
|
|
365
366
|
|
|
366
367
|
Handles renamed files by using the old path for deletion blame
|
|
367
|
-
and the new path for addition/context blame.
|
|
368
|
-
no revision info is available
|
|
368
|
+
and the new path for addition/context blame. Falls back to
|
|
369
|
+
blaming the working tree when no revision info is available.
|
|
369
370
|
"""
|
|
370
|
-
# If no commit info at all, skip blame entirely (e.g. --diff mode)
|
|
371
|
-
if not staged and commit_a is None and commit_b is None:
|
|
372
|
-
return {"line_blame": {}, "commits": {}}
|
|
373
|
-
|
|
374
371
|
line_blame: dict = {}
|
|
375
372
|
all_commits: set = set()
|
|
373
|
+
blame_attempted = False
|
|
376
374
|
|
|
377
375
|
for fi, file in enumerate(files):
|
|
378
376
|
new_filepath = file.display_path
|
|
@@ -394,55 +392,100 @@ def _collect_blame_data(
|
|
|
394
392
|
elif staged:
|
|
395
393
|
old_revision = "HEAD"
|
|
396
394
|
|
|
395
|
+
# File exists — attempt blame. For working tree (all None), get_blame
|
|
396
|
+
# runs on the working tree. For --diff mode (no git repo), the per-file
|
|
397
|
+
# try/except catches the GitError gracefully.
|
|
398
|
+
blame_attempted = True
|
|
399
|
+
|
|
400
|
+
if verbose:
|
|
401
|
+
new_rev_str = str(new_revision) if new_revision else "working tree"
|
|
402
|
+
old_rev_str = str(old_revision) if old_revision else "working tree"
|
|
403
|
+
print(f" Blaming {file.display_path} (new: {new_rev_str}, old: {old_rev_str})...")
|
|
404
|
+
|
|
397
405
|
# Get blame for current (new) version — skip if file doesn't exist at revision
|
|
398
406
|
blame_new: dict = {}
|
|
407
|
+
new_blame_ok = False
|
|
399
408
|
if new_filepath != "/dev/null":
|
|
400
409
|
try:
|
|
401
410
|
blame_new = get_blame_for_revision(new_filepath, revision=new_revision)
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
411
|
+
new_blame_ok = True
|
|
412
|
+
if verbose:
|
|
413
|
+
print(f" Got {len(blame_new)} blame entries for new version")
|
|
414
|
+
except Exception as e:
|
|
415
|
+
if verbose:
|
|
416
|
+
print(f" Warning: could not get blame for {new_filepath} at {new_revision}: {e}")
|
|
417
|
+
|
|
418
|
+
# Get blame for old version if different from new (e.g. renames, different revision)
|
|
406
419
|
blame_old: dict = blame_new
|
|
420
|
+
old_blame_ok = new_blame_ok
|
|
407
421
|
if old_revision and old_filepath != new_filepath:
|
|
408
422
|
try:
|
|
409
423
|
blame_old = get_blame_for_revision(old_filepath, revision=old_revision)
|
|
410
|
-
|
|
424
|
+
old_blame_ok = True
|
|
425
|
+
if verbose:
|
|
426
|
+
print(f" Got {len(blame_old)} blame entries for old version (renamed path)")
|
|
427
|
+
except Exception as e:
|
|
411
428
|
blame_old = {}
|
|
429
|
+
old_blame_ok = False
|
|
430
|
+
if verbose:
|
|
431
|
+
print(f" Warning: could not get blame for {old_filepath} at {old_revision}: {e}")
|
|
412
432
|
elif old_revision and old_filepath == new_filepath and old_revision != new_revision:
|
|
413
433
|
# Same path but different revision — re-blame
|
|
414
434
|
try:
|
|
415
435
|
blame_old = get_blame_for_revision(old_filepath, revision=old_revision)
|
|
416
|
-
|
|
417
|
-
|
|
436
|
+
old_blame_ok = True
|
|
437
|
+
if verbose:
|
|
438
|
+
print(f" Got {len(blame_old)} blame entries for old version (different revision)")
|
|
439
|
+
except Exception as e:
|
|
440
|
+
old_blame_ok = new_blame_ok
|
|
441
|
+
if verbose:
|
|
442
|
+
print(f" Warning: could not get blame for {old_filepath} at {old_revision}: {e}")
|
|
418
443
|
|
|
419
444
|
# Map blame by line number for additions and context
|
|
445
|
+
mapped_count = 0
|
|
420
446
|
for hunk in file.hunks:
|
|
421
447
|
for line in hunk.lines:
|
|
422
448
|
if line.line_type in ("addition", "context") and line.new_lineno:
|
|
423
|
-
entry = blame_new.get(line.new_lineno)
|
|
449
|
+
entry = blame_new.get(line.new_lineno) if new_blame_ok else None
|
|
424
450
|
if entry:
|
|
425
451
|
key = str(fi) + ":" + str(line.new_lineno)
|
|
426
452
|
line_blame[key] = entry
|
|
427
453
|
all_commits.add(entry["commit"])
|
|
454
|
+
mapped_count += 1
|
|
428
455
|
|
|
429
456
|
elif line.line_type == "deletion" and line.old_lineno:
|
|
430
|
-
if
|
|
457
|
+
if old_blame_ok:
|
|
431
458
|
entry = blame_old.get(line.old_lineno)
|
|
432
459
|
if entry:
|
|
433
460
|
key = str(fi) + ":" + str(line.old_lineno)
|
|
434
461
|
line_blame[key] = entry
|
|
435
462
|
all_commits.add(entry["commit"])
|
|
463
|
+
mapped_count += 1
|
|
464
|
+
|
|
465
|
+
if verbose:
|
|
466
|
+
print(f" Mapped {mapped_count} lines to blame data")
|
|
436
467
|
|
|
437
468
|
# Collect commit metadata for all unique commits
|
|
438
469
|
commits: dict = {}
|
|
439
|
-
|
|
440
|
-
if
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
470
|
+
if blame_attempted and all_commits:
|
|
471
|
+
if verbose:
|
|
472
|
+
print(f" Collecting metadata for {len(all_commits)} unique commits...")
|
|
473
|
+
for chash in all_commits:
|
|
474
|
+
if chash and len(chash) == 40:
|
|
475
|
+
try:
|
|
476
|
+
info = get_commit_info(chash)
|
|
477
|
+
commits[chash] = info
|
|
478
|
+
except Exception as e:
|
|
479
|
+
if verbose:
|
|
480
|
+
print(f" Warning: could not get commit info for {chash[:8]}: {e}")
|
|
481
|
+
|
|
482
|
+
# Warn if blame was attempted but produced no data at all
|
|
483
|
+
if blame_attempted and not line_blame:
|
|
484
|
+
import sys as _sys
|
|
485
|
+
_sys.stderr.write(
|
|
486
|
+
"Warning: git blame could not collect data for any files. "
|
|
487
|
+
"Try --verbose for details.\n"
|
|
488
|
+
)
|
|
446
489
|
|
|
447
490
|
return {
|
|
448
491
|
"line_blame": line_blame,
|
|
@@ -500,10 +543,19 @@ def generate_report(
|
|
|
500
543
|
|
|
501
544
|
# Collect blame data
|
|
502
545
|
try:
|
|
503
|
-
blame_data_dict = _collect_blame_data(
|
|
504
|
-
|
|
546
|
+
blame_data_dict = _collect_blame_data(
|
|
547
|
+
files, staged=staged, commit_a=commit_a, commit_b=commit_b, verbose=verbose,
|
|
548
|
+
)
|
|
549
|
+
except Exception as e:
|
|
550
|
+
if verbose:
|
|
551
|
+
print(f" Warning: blame collection failed: {e}")
|
|
505
552
|
blame_data_dict = {"line_blame": {}, "commits": {}}
|
|
506
553
|
|
|
554
|
+
blame_line_count = len(blame_data_dict["line_blame"])
|
|
555
|
+
blame_commit_count = len(blame_data_dict["commits"])
|
|
556
|
+
if verbose:
|
|
557
|
+
print(f" Blame data: {blame_line_count} lines mapped, {blame_commit_count} commits")
|
|
558
|
+
|
|
507
559
|
stats = _compute_stats(files, blame_data=blame_data_dict["line_blame"], commits_data=blame_data_dict["commits"])
|
|
508
560
|
|
|
509
561
|
# Collect search data
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|