FossilRepo

Fix diff rendering: use fossil native diff instead of Python difflib; fix ticket_create 500

ragelink 2026-04-08 13:37 trunk
Commit d50a555e5d654bafed2d0de3f00cf4b70ad6e9857c2ed127fe1b3634704ce71c
--- config/__pycache__/settings.cpython-314.pyc
+++ config/__pycache__/settings.cpython-314.pyc
cannot compute difference between binary files
11
--- config/__pycache__/settings.cpython-314.pyc
+++ config/__pycache__/settings.cpython-314.pyc
0 annot compute difference between binary files
1
--- config/__pycache__/settings.cpython-314.pyc
+++ config/__pycache__/settings.cpython-314.pyc
0 annot compute difference between binary files
1
--- fossil/__pycache__/cli.cpython-314.pyc
+++ fossil/__pycache__/cli.cpython-314.pyc
cannot compute difference between binary files
11
--- fossil/__pycache__/cli.cpython-314.pyc
+++ fossil/__pycache__/cli.cpython-314.pyc
0 annot compute difference between binary files
1
--- fossil/__pycache__/cli.cpython-314.pyc
+++ fossil/__pycache__/cli.cpython-314.pyc
0 annot compute difference between binary files
1
--- fossil/__pycache__/views.cpython-314.pyc
+++ fossil/__pycache__/views.cpython-314.pyc
cannot compute difference between binary files
11
--- fossil/__pycache__/views.cpython-314.pyc
+++ fossil/__pycache__/views.cpython-314.pyc
0 annot compute difference between binary files
1
--- fossil/__pycache__/views.cpython-314.pyc
+++ fossil/__pycache__/views.cpython-314.pyc
0 annot compute difference between binary files
1
--- fossil/cli.py
+++ fossil/cli.py
@@ -87,10 +87,24 @@
8787
if result.returncode == 0:
8888
return result.stdout
8989
except (FileNotFoundError, subprocess.TimeoutExpired):
9090
pass
9191
return ""
92
+
93
+ def diff(self, repo_path: Path, from_version: str, to_version: str) -> str:
94
+ """Run fossil diff between two versions. Returns raw unified diff output."""
95
+ try:
96
+ result = subprocess.run(
97
+ [self.binary, "diff", "--from", from_version, "--to", to_version, "-R", str(repo_path)],
98
+ capture_output=True,
99
+ text=True,
100
+ timeout=60,
101
+ env=self._env,
102
+ )
103
+ return result.stdout
104
+ except (FileNotFoundError, subprocess.TimeoutExpired):
105
+ return ""
92106
93107
def tarball(self, repo_path: Path, checkin: str) -> bytes:
94108
"""Generate a tar.gz archive of a checkin. Returns raw bytes."""
95109
result = subprocess.run(
96110
[self.binary, "tarball", checkin, "-R", str(repo_path), "/dev/stdout"],
97111
--- fossil/cli.py
+++ fossil/cli.py
@@ -87,10 +87,24 @@
87 if result.returncode == 0:
88 return result.stdout
89 except (FileNotFoundError, subprocess.TimeoutExpired):
90 pass
91 return ""
 
 
 
 
 
 
 
 
 
 
 
 
 
 
92
93 def tarball(self, repo_path: Path, checkin: str) -> bytes:
94 """Generate a tar.gz archive of a checkin. Returns raw bytes."""
95 result = subprocess.run(
96 [self.binary, "tarball", checkin, "-R", str(repo_path), "/dev/stdout"],
97
--- fossil/cli.py
+++ fossil/cli.py
@@ -87,10 +87,24 @@
87 if result.returncode == 0:
88 return result.stdout
89 except (FileNotFoundError, subprocess.TimeoutExpired):
90 pass
91 return ""
92
93 def diff(self, repo_path: Path, from_version: str, to_version: str) -> str:
94 """Run fossil diff between two versions. Returns raw unified diff output."""
95 try:
96 result = subprocess.run(
97 [self.binary, "diff", "--from", from_version, "--to", to_version, "-R", str(repo_path)],
98 capture_output=True,
99 text=True,
100 timeout=60,
101 env=self._env,
102 )
103 return result.stdout
104 except (FileNotFoundError, subprocess.TimeoutExpired):
105 return ""
106
107 def tarball(self, repo_path: Path, checkin: str) -> bytes:
108 """Generate a tar.gz archive of a checkin. Returns raw bytes."""
109 result = subprocess.run(
110 [self.binary, "tarball", checkin, "-R", str(repo_path), "/dev/stdout"],
111
+213 -169
--- fossil/views.py
+++ fossil/views.py
@@ -441,11 +441,103 @@
441441
"active_tab": "code",
442442
},
443443
)
444444
445445
446
-# --- Split-diff helper ---
446
+# --- Diff helpers ---
447
+
448
+
449
+def _parse_unified_diff_lines(raw_lines):
450
+ """Parse raw unified diff output lines into structured diff_lines list.
451
+
452
+ Works with both fossil diff and difflib output.
453
+ Returns (diff_lines, additions, deletions) tuple.
454
+ """
455
+ diff_lines = []
456
+ additions = 0
457
+ deletions = 0
458
+ old_line = 0
459
+ new_line = 0
460
+
461
+ for line in raw_lines:
462
+ if line.startswith("====="):
463
+ continue
464
+
465
+ line_type = "context"
466
+ old_num = ""
467
+ new_num = ""
468
+
469
+ if line.startswith("+++") or line.startswith("---"):
470
+ line_type = "header"
471
+ elif line.startswith("@@"):
472
+ line_type = "hunk"
473
+ hunk_match = re.match(r"@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@", line)
474
+ if hunk_match:
475
+ old_line = int(hunk_match.group(1))
476
+ new_line = int(hunk_match.group(2))
477
+ elif line.startswith("+"):
478
+ line_type = "add"
479
+ additions += 1
480
+ new_num = new_line
481
+ new_line += 1
482
+ elif line.startswith("-"):
483
+ line_type = "del"
484
+ deletions += 1
485
+ old_num = old_line
486
+ old_line += 1
487
+ else:
488
+ old_num = old_line
489
+ new_num = new_line
490
+ old_line += 1
491
+ new_line += 1
492
+
493
+ if line_type in ("add", "del", "context") and len(line) > 0:
494
+ prefix = line[0]
495
+ code = line[1:]
496
+ else:
497
+ prefix = ""
498
+ code = line
499
+
500
+ diff_lines.append(
501
+ {
502
+ "text": line,
503
+ "type": line_type,
504
+ "old_num": old_num,
505
+ "new_num": new_num,
506
+ "prefix": prefix,
507
+ "code": code,
508
+ }
509
+ )
510
+
511
+ return diff_lines, additions, deletions
512
+
513
+
514
+def _parse_fossil_diff_output(raw_output):
515
+ """Split multi-file fossil diff output into per-file parsed diffs.
516
+
517
+ Returns dict mapping filename -> (diff_lines, additions, deletions).
518
+ """
519
+ if not raw_output or not raw_output.strip():
520
+ return {}
521
+
522
+ result = {}
523
+ current_name = None
524
+ current_lines = []
525
+
526
+ for line in raw_output.splitlines():
527
+ if line.startswith("Index: "):
528
+ if current_name is not None:
529
+ result[current_name] = _parse_unified_diff_lines(current_lines)
530
+ current_name = line[7:].strip()
531
+ current_lines = []
532
+ elif current_name is not None:
533
+ current_lines.append(line)
534
+
535
+ if current_name is not None:
536
+ result[current_name] = _parse_unified_diff_lines(current_lines)
537
+
538
+ return result
447539
448540
449541
def _compute_split_lines(diff_lines):
450542
"""Convert unified diff lines into parallel left/right arrays for split view.
451543
@@ -507,95 +599,60 @@
507599
with reader:
508600
checkin = reader.get_checkin_detail(checkin_uuid)
509601
if not checkin:
510602
raise Http404("Checkin not found")
511603
512
- # Compute diffs for each changed file
513
- import difflib
604
+ # Try fossil native diff first for accurate results matching fossil-scm.org
605
+ fossil_diffs = {}
606
+ if checkin.parent_uuid:
607
+ try:
608
+ from .cli import FossilCLI
609
+
610
+ cli = FossilCLI()
611
+ raw_diff = cli.diff(fossil_repo.full_path, checkin.parent_uuid, checkin.uuid)
612
+ if raw_diff:
613
+ fossil_diffs = _parse_fossil_diff_output(raw_diff)
614
+ except Exception:
615
+ pass
514616
515617
file_diffs = []
516618
for f in checkin.files_changed:
517
- old_text = ""
518
- new_text = ""
519
- if f["prev_uuid"]:
520
- try:
521
- old_bytes = reader.get_file_content(f["prev_uuid"])
522
- old_text = old_bytes.decode("utf-8", errors="replace")
523
- except Exception:
524
- old_text = ""
525
- if f["uuid"]:
526
- try:
527
- new_bytes = reader.get_file_content(f["uuid"])
528
- new_text = new_bytes.decode("utf-8", errors="replace")
529
- except Exception:
530
- new_text = ""
531
-
532
- # Check if binary
533
- is_binary = "\x00" in old_text[:1024] or "\x00" in new_text[:1024]
534
- diff_lines = []
535
- additions = 0
536
- deletions = 0
537
-
538
- if not is_binary and (old_text or new_text):
539
- diff = difflib.unified_diff(
540
- old_text.splitlines(keepends=True),
541
- new_text.splitlines(keepends=True),
542
- fromfile=f"a/{f['name']}",
543
- tofile=f"b/{f['name']}",
544
- lineterm="",
545
- n=3,
546
- )
547
- old_line = 0
548
- new_line = 0
549
- for line in diff:
550
- line_type = "context"
551
- old_num = ""
552
- new_num = ""
553
- if line.startswith("+++") or line.startswith("---"):
554
- line_type = "header"
555
- elif line.startswith("@@"):
556
- line_type = "hunk"
557
- # Parse @@ -old_start,old_count +new_start,new_count @@
558
- hunk_match = re.match(r"@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@", line)
559
- if hunk_match:
560
- old_line = int(hunk_match.group(1))
561
- new_line = int(hunk_match.group(2))
562
- elif line.startswith("+"):
563
- line_type = "add"
564
- additions += 1
565
- new_num = new_line
566
- new_line += 1
567
- elif line.startswith("-"):
568
- line_type = "del"
569
- deletions += 1
570
- old_num = old_line
571
- old_line += 1
572
- else:
573
- old_num = old_line
574
- new_num = new_line
575
- old_line += 1
576
- new_line += 1
577
- # Separate prefix character from code text for syntax highlighting
578
- if line_type in ("add", "del", "context") and len(line) > 0:
579
- prefix = line[0]
580
- code = line[1:]
581
- else:
582
- prefix = ""
583
- code = line
584
- diff_lines.append(
585
- {
586
- "text": line,
587
- "type": line_type,
588
- "old_num": old_num,
589
- "new_num": new_num,
590
- "prefix": prefix,
591
- "code": code,
592
- }
593
- )
594
-
595
- split_left, split_right = _compute_split_lines(diff_lines)
596
- ext = f["name"].rsplit(".", 1)[-1] if "." in f["name"] else ""
619
+ ext = f["name"].rsplit(".", 1)[-1] if "." in f["name"] else ""
620
+
621
+ if f["name"] in fossil_diffs:
622
+ diff_lines, additions, deletions = fossil_diffs[f["name"]]
623
+ is_binary = False
624
+ else:
625
+ # Fallback: difflib for files fossil skipped (binary, no parent, etc.)
626
+ import difflib
627
+
628
+ old_text = ""
629
+ new_text = ""
630
+ if f["prev_uuid"]:
631
+ with contextlib.suppress(Exception):
632
+ old_text = reader.get_file_content(f["prev_uuid"]).decode("utf-8", errors="replace")
633
+ if f["uuid"]:
634
+ with contextlib.suppress(Exception):
635
+ new_text = reader.get_file_content(f["uuid"]).decode("utf-8", errors="replace")
636
+
637
+ is_binary = "\x00" in old_text[:1024] or "\x00" in new_text[:1024]
638
+ diff_lines = []
639
+ additions = 0
640
+ deletions = 0
641
+
642
+ if not is_binary and (old_text or new_text):
643
+ diff = difflib.unified_diff(
644
+ old_text.splitlines(keepends=True),
645
+ new_text.splitlines(keepends=True),
646
+ fromfile=f"a/{f['name']}",
647
+ tofile=f"b/{f['name']}",
648
+ lineterm="",
649
+ n=3,
650
+ )
651
+ diff_lines, additions, deletions = _parse_unified_diff_lines(list(diff))
652
+
653
+ split_left, split_right = _compute_split_lines(diff_lines)
597654
file_diffs.append(
598655
{
599656
"name": f["name"],
600657
"change_type": f["change_type"],
601658
"uuid": f["uuid"],
@@ -1284,11 +1341,14 @@
12841341
def ticket_create(request, slug):
12851342
project, fossil_repo, reader = _get_repo_and_reader(slug, request, "write")
12861343
12871344
from fossil.ticket_fields import TicketFieldDefinition
12881345
1289
- custom_fields = TicketFieldDefinition.objects.filter(repository=fossil_repo)
1346
+ try:
1347
+ custom_fields = list(TicketFieldDefinition.objects.filter(repository=fossil_repo))
1348
+ except Exception:
1349
+ custom_fields = []
12901350
12911351
if request.method == "POST":
12921352
title = request.POST.get("title", "").strip()
12931353
body = request.POST.get("body", "")
12941354
ticket_type = request.POST.get("type", "Code_Defect")
@@ -1328,11 +1388,14 @@
13281388
def ticket_edit(request, slug, ticket_uuid):
13291389
project, fossil_repo, reader = _get_repo_and_reader(slug, request, "write")
13301390
13311391
from fossil.ticket_fields import TicketFieldDefinition
13321392
1333
- custom_fields = TicketFieldDefinition.objects.filter(repository=fossil_repo)
1393
+ try:
1394
+ custom_fields = list(TicketFieldDefinition.objects.filter(repository=fossil_repo))
1395
+ except Exception:
1396
+ custom_fields = []
13341397
13351398
with reader:
13361399
ticket = reader.get_ticket_detail(ticket_uuid)
13371400
if not ticket:
13381401
raise Http404("Ticket not found")
@@ -2304,88 +2367,24 @@
23042367
with reader:
23052368
from_detail = reader.get_checkin_detail(from_uuid)
23062369
to_detail = reader.get_checkin_detail(to_uuid)
23072370
23082371
if from_detail and to_detail:
2309
- # Get all files from both checkins and compute diffs
2310
- from_files = {f["name"]: f for f in from_detail.files_changed}
2311
- to_files = {f["name"]: f for f in to_detail.files_changed}
2312
- all_files = sorted(set(list(from_files.keys()) + list(to_files.keys())))
2313
-
2314
- import difflib
2315
-
2316
- for fname in all_files[:20]: # Limit to 20 files for performance
2317
- old_text = ""
2318
- new_text = ""
2319
- f_from = from_files.get(fname, {})
2320
- f_to = to_files.get(fname, {})
2321
-
2322
- if f_from.get("uuid"):
2323
- with contextlib.suppress(Exception):
2324
- old_text = reader.get_file_content(f_from["uuid"]).decode("utf-8", errors="replace")
2325
- if f_to.get("uuid"):
2326
- with contextlib.suppress(Exception):
2327
- new_text = reader.get_file_content(f_to["uuid"]).decode("utf-8", errors="replace")
2328
-
2329
- if old_text != new_text:
2330
- diff = difflib.unified_diff(
2331
- old_text.splitlines(keepends=True),
2332
- new_text.splitlines(keepends=True),
2333
- fromfile=f"a/{fname}",
2334
- tofile=f"b/{fname}",
2335
- n=3,
2336
- )
2337
- diff_lines = []
2338
- old_line = 0
2339
- new_line = 0
2340
- additions = 0
2341
- deletions = 0
2342
- for line in diff:
2343
- line_type = "context"
2344
- old_num = ""
2345
- new_num = ""
2346
- if line.startswith("+++") or line.startswith("---"):
2347
- line_type = "header"
2348
- elif line.startswith("@@"):
2349
- line_type = "hunk"
2350
- hunk_match = re.match(r"@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@", line)
2351
- if hunk_match:
2352
- old_line = int(hunk_match.group(1))
2353
- new_line = int(hunk_match.group(2))
2354
- elif line.startswith("+"):
2355
- line_type = "add"
2356
- additions += 1
2357
- new_num = new_line
2358
- new_line += 1
2359
- elif line.startswith("-"):
2360
- line_type = "del"
2361
- deletions += 1
2362
- old_num = old_line
2363
- old_line += 1
2364
- else:
2365
- old_num = old_line
2366
- new_num = new_line
2367
- old_line += 1
2368
- new_line += 1
2369
- # Separate prefix from code text for syntax highlighting
2370
- if line_type in ("add", "del", "context") and len(line) > 0:
2371
- prefix = line[0]
2372
- code = line[1:]
2373
- else:
2374
- prefix = ""
2375
- code = line
2376
- diff_lines.append(
2377
- {
2378
- "text": line,
2379
- "type": line_type,
2380
- "old_num": old_num,
2381
- "new_num": new_num,
2382
- "prefix": prefix,
2383
- "code": code,
2384
- }
2385
- )
2386
-
2372
+ # Try fossil native diff first
2373
+ fossil_diffs = {}
2374
+ try:
2375
+ from .cli import FossilCLI
2376
+
2377
+ cli = FossilCLI()
2378
+ raw_diff = cli.diff(fossil_repo.full_path, from_uuid, to_uuid)
2379
+ if raw_diff:
2380
+ fossil_diffs = _parse_fossil_diff_output(raw_diff)
2381
+ except Exception:
2382
+ pass
2383
+
2384
+ if fossil_diffs:
2385
+ for fname, (diff_lines, additions, deletions) in fossil_diffs.items():
23872386
if diff_lines:
23882387
split_left, split_right = _compute_split_lines(diff_lines)
23892388
file_diffs.append(
23902389
{
23912390
"name": fname,
@@ -2394,10 +2393,53 @@
23942393
"split_right": split_right,
23952394
"additions": additions,
23962395
"deletions": deletions,
23972396
}
23982397
)
2398
+ else:
2399
+ # Fallback to difflib
2400
+ import difflib
2401
+
2402
+ from_files = {f["name"]: f for f in from_detail.files_changed}
2403
+ to_files = {f["name"]: f for f in to_detail.files_changed}
2404
+ all_files = sorted(set(list(from_files.keys()) + list(to_files.keys())))
2405
+
2406
+ for fname in all_files[:20]:
2407
+ old_text = ""
2408
+ new_text = ""
2409
+ f_from = from_files.get(fname, {})
2410
+ f_to = to_files.get(fname, {})
2411
+
2412
+ if f_from.get("uuid"):
2413
+ with contextlib.suppress(Exception):
2414
+ old_text = reader.get_file_content(f_from["uuid"]).decode("utf-8", errors="replace")
2415
+ if f_to.get("uuid"):
2416
+ with contextlib.suppress(Exception):
2417
+ new_text = reader.get_file_content(f_to["uuid"]).decode("utf-8", errors="replace")
2418
+
2419
+ if old_text != new_text:
2420
+ diff = difflib.unified_diff(
2421
+ old_text.splitlines(keepends=True),
2422
+ new_text.splitlines(keepends=True),
2423
+ fromfile=f"a/{fname}",
2424
+ tofile=f"b/{fname}",
2425
+ n=3,
2426
+ )
2427
+ diff_lines, additions, deletions = _parse_unified_diff_lines(list(diff))
2428
+
2429
+ if diff_lines:
2430
+ split_left, split_right = _compute_split_lines(diff_lines)
2431
+ file_diffs.append(
2432
+ {
2433
+ "name": fname,
2434
+ "diff_lines": diff_lines,
2435
+ "split_left": split_left,
2436
+ "split_right": split_right,
2437
+ "additions": additions,
2438
+ "deletions": deletions,
2439
+ }
2440
+ )
23992441
24002442
return render(
24012443
request,
24022444
"fossil/compare.html",
24032445
{
@@ -3733,16 +3775,18 @@
37333775
"""List custom ticket field definitions for a project. Admin only."""
37343776
project, fossil_repo = _get_project_and_repo(slug, request, "admin")
37353777
37363778
from fossil.ticket_fields import TicketFieldDefinition
37373779
3738
- fields = TicketFieldDefinition.objects.filter(repository=fossil_repo)
3739
-
3740
- search = request.GET.get("search", "").strip()
3741
- if search:
3742
- fields = fields.filter(label__icontains=search) | fields.filter(name__icontains=search)
3743
- fields = fields.distinct()
3780
+ try:
3781
+ fields = TicketFieldDefinition.objects.filter(repository=fossil_repo)
3782
+ search = request.GET.get("search", "").strip()
3783
+ if search:
3784
+ fields = fields.filter(label__icontains=search) | fields.filter(name__icontains=search)
3785
+ fields = fields.distinct()
3786
+ except Exception:
3787
+ fields = TicketFieldDefinition.objects.none()
37443788
37453789
per_page = get_per_page(request)
37463790
paginator = Paginator(fields, per_page)
37473791
page_obj = paginator.get_page(request.GET.get("page", 1))
37483792
37493793
--- fossil/views.py
+++ fossil/views.py
@@ -441,11 +441,103 @@
441 "active_tab": "code",
442 },
443 )
444
445
446 # --- Split-diff helper ---
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
447
448
449 def _compute_split_lines(diff_lines):
450 """Convert unified diff lines into parallel left/right arrays for split view.
451
@@ -507,95 +599,60 @@
507 with reader:
508 checkin = reader.get_checkin_detail(checkin_uuid)
509 if not checkin:
510 raise Http404("Checkin not found")
511
512 # Compute diffs for each changed file
513 import difflib
 
 
 
 
 
 
 
 
 
 
514
515 file_diffs = []
516 for f in checkin.files_changed:
517 old_text = ""
518 new_text = ""
519 if f["prev_uuid"]:
520 try:
521 old_bytes = reader.get_file_content(f["prev_uuid"])
522 old_text = old_bytes.decode("utf-8", errors="replace")
523 except Exception:
524 old_text = ""
525 if f["uuid"]:
526 try:
527 new_bytes = reader.get_file_content(f["uuid"])
528 new_text = new_bytes.decode("utf-8", errors="replace")
529 except Exception:
530 new_text = ""
531
532 # Check if binary
533 is_binary = "\x00" in old_text[:1024] or "\x00" in new_text[:1024]
534 diff_lines = []
535 additions = 0
536 deletions = 0
537
538 if not is_binary and (old_text or new_text):
539 diff = difflib.unified_diff(
540 old_text.splitlines(keepends=True),
541 new_text.splitlines(keepends=True),
542 fromfile=f"a/{f['name']}",
543 tofile=f"b/{f['name']}",
544 lineterm="",
545 n=3,
546 )
547 old_line = 0
548 new_line = 0
549 for line in diff:
550 line_type = "context"
551 old_num = ""
552 new_num = ""
553 if line.startswith("+++") or line.startswith("---"):
554 line_type = "header"
555 elif line.startswith("@@"):
556 line_type = "hunk"
557 # Parse @@ -old_start,old_count +new_start,new_count @@
558 hunk_match = re.match(r"@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@", line)
559 if hunk_match:
560 old_line = int(hunk_match.group(1))
561 new_line = int(hunk_match.group(2))
562 elif line.startswith("+"):
563 line_type = "add"
564 additions += 1
565 new_num = new_line
566 new_line += 1
567 elif line.startswith("-"):
568 line_type = "del"
569 deletions += 1
570 old_num = old_line
571 old_line += 1
572 else:
573 old_num = old_line
574 new_num = new_line
575 old_line += 1
576 new_line += 1
577 # Separate prefix character from code text for syntax highlighting
578 if line_type in ("add", "del", "context") and len(line) > 0:
579 prefix = line[0]
580 code = line[1:]
581 else:
582 prefix = ""
583 code = line
584 diff_lines.append(
585 {
586 "text": line,
587 "type": line_type,
588 "old_num": old_num,
589 "new_num": new_num,
590 "prefix": prefix,
591 "code": code,
592 }
593 )
594
595 split_left, split_right = _compute_split_lines(diff_lines)
596 ext = f["name"].rsplit(".", 1)[-1] if "." in f["name"] else ""
597 file_diffs.append(
598 {
599 "name": f["name"],
600 "change_type": f["change_type"],
601 "uuid": f["uuid"],
@@ -1284,11 +1341,14 @@
1284 def ticket_create(request, slug):
1285 project, fossil_repo, reader = _get_repo_and_reader(slug, request, "write")
1286
1287 from fossil.ticket_fields import TicketFieldDefinition
1288
1289 custom_fields = TicketFieldDefinition.objects.filter(repository=fossil_repo)
 
 
 
1290
1291 if request.method == "POST":
1292 title = request.POST.get("title", "").strip()
1293 body = request.POST.get("body", "")
1294 ticket_type = request.POST.get("type", "Code_Defect")
@@ -1328,11 +1388,14 @@
1328 def ticket_edit(request, slug, ticket_uuid):
1329 project, fossil_repo, reader = _get_repo_and_reader(slug, request, "write")
1330
1331 from fossil.ticket_fields import TicketFieldDefinition
1332
1333 custom_fields = TicketFieldDefinition.objects.filter(repository=fossil_repo)
 
 
 
1334
1335 with reader:
1336 ticket = reader.get_ticket_detail(ticket_uuid)
1337 if not ticket:
1338 raise Http404("Ticket not found")
@@ -2304,88 +2367,24 @@
2304 with reader:
2305 from_detail = reader.get_checkin_detail(from_uuid)
2306 to_detail = reader.get_checkin_detail(to_uuid)
2307
2308 if from_detail and to_detail:
2309 # Get all files from both checkins and compute diffs
2310 from_files = {f["name"]: f for f in from_detail.files_changed}
2311 to_files = {f["name"]: f for f in to_detail.files_changed}
2312 all_files = sorted(set(list(from_files.keys()) + list(to_files.keys())))
2313
2314 import difflib
2315
2316 for fname in all_files[:20]: # Limit to 20 files for performance
2317 old_text = ""
2318 new_text = ""
2319 f_from = from_files.get(fname, {})
2320 f_to = to_files.get(fname, {})
2321
2322 if f_from.get("uuid"):
2323 with contextlib.suppress(Exception):
2324 old_text = reader.get_file_content(f_from["uuid"]).decode("utf-8", errors="replace")
2325 if f_to.get("uuid"):
2326 with contextlib.suppress(Exception):
2327 new_text = reader.get_file_content(f_to["uuid"]).decode("utf-8", errors="replace")
2328
2329 if old_text != new_text:
2330 diff = difflib.unified_diff(
2331 old_text.splitlines(keepends=True),
2332 new_text.splitlines(keepends=True),
2333 fromfile=f"a/{fname}",
2334 tofile=f"b/{fname}",
2335 n=3,
2336 )
2337 diff_lines = []
2338 old_line = 0
2339 new_line = 0
2340 additions = 0
2341 deletions = 0
2342 for line in diff:
2343 line_type = "context"
2344 old_num = ""
2345 new_num = ""
2346 if line.startswith("+++") or line.startswith("---"):
2347 line_type = "header"
2348 elif line.startswith("@@"):
2349 line_type = "hunk"
2350 hunk_match = re.match(r"@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@", line)
2351 if hunk_match:
2352 old_line = int(hunk_match.group(1))
2353 new_line = int(hunk_match.group(2))
2354 elif line.startswith("+"):
2355 line_type = "add"
2356 additions += 1
2357 new_num = new_line
2358 new_line += 1
2359 elif line.startswith("-"):
2360 line_type = "del"
2361 deletions += 1
2362 old_num = old_line
2363 old_line += 1
2364 else:
2365 old_num = old_line
2366 new_num = new_line
2367 old_line += 1
2368 new_line += 1
2369 # Separate prefix from code text for syntax highlighting
2370 if line_type in ("add", "del", "context") and len(line) > 0:
2371 prefix = line[0]
2372 code = line[1:]
2373 else:
2374 prefix = ""
2375 code = line
2376 diff_lines.append(
2377 {
2378 "text": line,
2379 "type": line_type,
2380 "old_num": old_num,
2381 "new_num": new_num,
2382 "prefix": prefix,
2383 "code": code,
2384 }
2385 )
2386
2387 if diff_lines:
2388 split_left, split_right = _compute_split_lines(diff_lines)
2389 file_diffs.append(
2390 {
2391 "name": fname,
@@ -2394,10 +2393,53 @@
2394 "split_right": split_right,
2395 "additions": additions,
2396 "deletions": deletions,
2397 }
2398 )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2399
2400 return render(
2401 request,
2402 "fossil/compare.html",
2403 {
@@ -3733,16 +3775,18 @@
3733 """List custom ticket field definitions for a project. Admin only."""
3734 project, fossil_repo = _get_project_and_repo(slug, request, "admin")
3735
3736 from fossil.ticket_fields import TicketFieldDefinition
3737
3738 fields = TicketFieldDefinition.objects.filter(repository=fossil_repo)
3739
3740 search = request.GET.get("search", "").strip()
3741 if search:
3742 fields = fields.filter(label__icontains=search) | fields.filter(name__icontains=search)
3743 fields = fields.distinct()
 
 
3744
3745 per_page = get_per_page(request)
3746 paginator = Paginator(fields, per_page)
3747 page_obj = paginator.get_page(request.GET.get("page", 1))
3748
3749
--- fossil/views.py
+++ fossil/views.py
@@ -441,11 +441,103 @@
441 "active_tab": "code",
442 },
443 )
444
445
446 # --- Diff helpers ---
447
448
449 def _parse_unified_diff_lines(raw_lines):
450 """Parse raw unified diff output lines into structured diff_lines list.
451
452 Works with both fossil diff and difflib output.
453 Returns (diff_lines, additions, deletions) tuple.
454 """
455 diff_lines = []
456 additions = 0
457 deletions = 0
458 old_line = 0
459 new_line = 0
460
461 for line in raw_lines:
462 if line.startswith("====="):
463 continue
464
465 line_type = "context"
466 old_num = ""
467 new_num = ""
468
469 if line.startswith("+++") or line.startswith("---"):
470 line_type = "header"
471 elif line.startswith("@@"):
472 line_type = "hunk"
473 hunk_match = re.match(r"@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@", line)
474 if hunk_match:
475 old_line = int(hunk_match.group(1))
476 new_line = int(hunk_match.group(2))
477 elif line.startswith("+"):
478 line_type = "add"
479 additions += 1
480 new_num = new_line
481 new_line += 1
482 elif line.startswith("-"):
483 line_type = "del"
484 deletions += 1
485 old_num = old_line
486 old_line += 1
487 else:
488 old_num = old_line
489 new_num = new_line
490 old_line += 1
491 new_line += 1
492
493 if line_type in ("add", "del", "context") and len(line) > 0:
494 prefix = line[0]
495 code = line[1:]
496 else:
497 prefix = ""
498 code = line
499
500 diff_lines.append(
501 {
502 "text": line,
503 "type": line_type,
504 "old_num": old_num,
505 "new_num": new_num,
506 "prefix": prefix,
507 "code": code,
508 }
509 )
510
511 return diff_lines, additions, deletions
512
513
514 def _parse_fossil_diff_output(raw_output):
515 """Split multi-file fossil diff output into per-file parsed diffs.
516
517 Returns dict mapping filename -> (diff_lines, additions, deletions).
518 """
519 if not raw_output or not raw_output.strip():
520 return {}
521
522 result = {}
523 current_name = None
524 current_lines = []
525
526 for line in raw_output.splitlines():
527 if line.startswith("Index: "):
528 if current_name is not None:
529 result[current_name] = _parse_unified_diff_lines(current_lines)
530 current_name = line[7:].strip()
531 current_lines = []
532 elif current_name is not None:
533 current_lines.append(line)
534
535 if current_name is not None:
536 result[current_name] = _parse_unified_diff_lines(current_lines)
537
538 return result
539
540
541 def _compute_split_lines(diff_lines):
542 """Convert unified diff lines into parallel left/right arrays for split view.
543
@@ -507,95 +599,60 @@
599 with reader:
600 checkin = reader.get_checkin_detail(checkin_uuid)
601 if not checkin:
602 raise Http404("Checkin not found")
603
604 # Try fossil native diff first for accurate results matching fossil-scm.org
605 fossil_diffs = {}
606 if checkin.parent_uuid:
607 try:
608 from .cli import FossilCLI
609
610 cli = FossilCLI()
611 raw_diff = cli.diff(fossil_repo.full_path, checkin.parent_uuid, checkin.uuid)
612 if raw_diff:
613 fossil_diffs = _parse_fossil_diff_output(raw_diff)
614 except Exception:
615 pass
616
617 file_diffs = []
618 for f in checkin.files_changed:
619 ext = f["name"].rsplit(".", 1)[-1] if "." in f["name"] else ""
620
621 if f["name"] in fossil_diffs:
622 diff_lines, additions, deletions = fossil_diffs[f["name"]]
623 is_binary = False
624 else:
625 # Fallback: difflib for files fossil skipped (binary, no parent, etc.)
626 import difflib
627
628 old_text = ""
629 new_text = ""
630 if f["prev_uuid"]:
631 with contextlib.suppress(Exception):
632 old_text = reader.get_file_content(f["prev_uuid"]).decode("utf-8", errors="replace")
633 if f["uuid"]:
634 with contextlib.suppress(Exception):
635 new_text = reader.get_file_content(f["uuid"]).decode("utf-8", errors="replace")
636
637 is_binary = "\x00" in old_text[:1024] or "\x00" in new_text[:1024]
638 diff_lines = []
639 additions = 0
640 deletions = 0
641
642 if not is_binary and (old_text or new_text):
643 diff = difflib.unified_diff(
644 old_text.splitlines(keepends=True),
645 new_text.splitlines(keepends=True),
646 fromfile=f"a/{f['name']}",
647 tofile=f"b/{f['name']}",
648 lineterm="",
649 n=3,
650 )
651 diff_lines, additions, deletions = _parse_unified_diff_lines(list(diff))
652
653 split_left, split_right = _compute_split_lines(diff_lines)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
654 file_diffs.append(
655 {
656 "name": f["name"],
657 "change_type": f["change_type"],
658 "uuid": f["uuid"],
@@ -1284,11 +1341,14 @@
1341 def ticket_create(request, slug):
1342 project, fossil_repo, reader = _get_repo_and_reader(slug, request, "write")
1343
1344 from fossil.ticket_fields import TicketFieldDefinition
1345
1346 try:
1347 custom_fields = list(TicketFieldDefinition.objects.filter(repository=fossil_repo))
1348 except Exception:
1349 custom_fields = []
1350
1351 if request.method == "POST":
1352 title = request.POST.get("title", "").strip()
1353 body = request.POST.get("body", "")
1354 ticket_type = request.POST.get("type", "Code_Defect")
@@ -1328,11 +1388,14 @@
1388 def ticket_edit(request, slug, ticket_uuid):
1389 project, fossil_repo, reader = _get_repo_and_reader(slug, request, "write")
1390
1391 from fossil.ticket_fields import TicketFieldDefinition
1392
1393 try:
1394 custom_fields = list(TicketFieldDefinition.objects.filter(repository=fossil_repo))
1395 except Exception:
1396 custom_fields = []
1397
1398 with reader:
1399 ticket = reader.get_ticket_detail(ticket_uuid)
1400 if not ticket:
1401 raise Http404("Ticket not found")
@@ -2304,88 +2367,24 @@
2367 with reader:
2368 from_detail = reader.get_checkin_detail(from_uuid)
2369 to_detail = reader.get_checkin_detail(to_uuid)
2370
2371 if from_detail and to_detail:
2372 # Try fossil native diff first
2373 fossil_diffs = {}
2374 try:
2375 from .cli import FossilCLI
2376
2377 cli = FossilCLI()
2378 raw_diff = cli.diff(fossil_repo.full_path, from_uuid, to_uuid)
2379 if raw_diff:
2380 fossil_diffs = _parse_fossil_diff_output(raw_diff)
2381 except Exception:
2382 pass
2383
2384 if fossil_diffs:
2385 for fname, (diff_lines, additions, deletions) in fossil_diffs.items():
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2386 if diff_lines:
2387 split_left, split_right = _compute_split_lines(diff_lines)
2388 file_diffs.append(
2389 {
2390 "name": fname,
@@ -2394,10 +2393,53 @@
2393 "split_right": split_right,
2394 "additions": additions,
2395 "deletions": deletions,
2396 }
2397 )
2398 else:
2399 # Fallback to difflib
2400 import difflib
2401
2402 from_files = {f["name"]: f for f in from_detail.files_changed}
2403 to_files = {f["name"]: f for f in to_detail.files_changed}
2404 all_files = sorted(set(list(from_files.keys()) + list(to_files.keys())))
2405
2406 for fname in all_files[:20]:
2407 old_text = ""
2408 new_text = ""
2409 f_from = from_files.get(fname, {})
2410 f_to = to_files.get(fname, {})
2411
2412 if f_from.get("uuid"):
2413 with contextlib.suppress(Exception):
2414 old_text = reader.get_file_content(f_from["uuid"]).decode("utf-8", errors="replace")
2415 if f_to.get("uuid"):
2416 with contextlib.suppress(Exception):
2417 new_text = reader.get_file_content(f_to["uuid"]).decode("utf-8", errors="replace")
2418
2419 if old_text != new_text:
2420 diff = difflib.unified_diff(
2421 old_text.splitlines(keepends=True),
2422 new_text.splitlines(keepends=True),
2423 fromfile=f"a/{fname}",
2424 tofile=f"b/{fname}",
2425 n=3,
2426 )
2427 diff_lines, additions, deletions = _parse_unified_diff_lines(list(diff))
2428
2429 if diff_lines:
2430 split_left, split_right = _compute_split_lines(diff_lines)
2431 file_diffs.append(
2432 {
2433 "name": fname,
2434 "diff_lines": diff_lines,
2435 "split_left": split_left,
2436 "split_right": split_right,
2437 "additions": additions,
2438 "deletions": deletions,
2439 }
2440 )
2441
2442 return render(
2443 request,
2444 "fossil/compare.html",
2445 {
@@ -3733,16 +3775,18 @@
3775 """List custom ticket field definitions for a project. Admin only."""
3776 project, fossil_repo = _get_project_and_repo(slug, request, "admin")
3777
3778 from fossil.ticket_fields import TicketFieldDefinition
3779
3780 try:
3781 fields = TicketFieldDefinition.objects.filter(repository=fossil_repo)
3782 search = request.GET.get("search", "").strip()
3783 if search:
3784 fields = fields.filter(label__icontains=search) | fields.filter(name__icontains=search)
3785 fields = fields.distinct()
3786 except Exception:
3787 fields = TicketFieldDefinition.objects.none()
3788
3789 per_page = get_per_page(request)
3790 paginator = Paginator(fields, per_page)
3791 page_obj = paginator.get_page(request.GET.get("page", 1))
3792
3793

Keyboard Shortcuts

Open search /
Next entry (timeline) j
Previous entry (timeline) k
Open focused entry Enter
Show this help ?
Toggle theme Top nav button