| | @@ -441,11 +441,103 @@ |
| 441 | 441 | "active_tab": "code", |
| 442 | 442 | }, |
| 443 | 443 | ) |
| 444 | 444 | |
| 445 | 445 | |
| 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 |
| 447 | 539 | |
| 448 | 540 | |
| 449 | 541 | def _compute_split_lines(diff_lines): |
| 450 | 542 | """Convert unified diff lines into parallel left/right arrays for split view. |
| 451 | 543 | |
| | @@ -507,95 +599,60 @@ |
| 507 | 599 | with reader: |
| 508 | 600 | checkin = reader.get_checkin_detail(checkin_uuid) |
| 509 | 601 | if not checkin: |
| 510 | 602 | raise Http404("Checkin not found") |
| 511 | 603 | |
| 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 |
| 514 | 616 | |
| 515 | 617 | file_diffs = [] |
| 516 | 618 | 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) |
| 597 | 654 | file_diffs.append( |
| 598 | 655 | { |
| 599 | 656 | "name": f["name"], |
| 600 | 657 | "change_type": f["change_type"], |
| 601 | 658 | "uuid": f["uuid"], |
| | @@ -1284,11 +1341,14 @@ |
| 1284 | 1341 | def ticket_create(request, slug): |
| 1285 | 1342 | project, fossil_repo, reader = _get_repo_and_reader(slug, request, "write") |
| 1286 | 1343 | |
| 1287 | 1344 | from fossil.ticket_fields import TicketFieldDefinition |
| 1288 | 1345 | |
| 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 = [] |
| 1290 | 1350 | |
| 1291 | 1351 | if request.method == "POST": |
| 1292 | 1352 | title = request.POST.get("title", "").strip() |
| 1293 | 1353 | body = request.POST.get("body", "") |
| 1294 | 1354 | ticket_type = request.POST.get("type", "Code_Defect") |
| | @@ -1328,11 +1388,14 @@ |
| 1328 | 1388 | def ticket_edit(request, slug, ticket_uuid): |
| 1329 | 1389 | project, fossil_repo, reader = _get_repo_and_reader(slug, request, "write") |
| 1330 | 1390 | |
| 1331 | 1391 | from fossil.ticket_fields import TicketFieldDefinition |
| 1332 | 1392 | |
| 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 = [] |
| 1334 | 1397 | |
| 1335 | 1398 | with reader: |
| 1336 | 1399 | ticket = reader.get_ticket_detail(ticket_uuid) |
| 1337 | 1400 | if not ticket: |
| 1338 | 1401 | raise Http404("Ticket not found") |
| | @@ -2304,88 +2367,24 @@ |
| 2304 | 2367 | with reader: |
| 2305 | 2368 | from_detail = reader.get_checkin_detail(from_uuid) |
| 2306 | 2369 | to_detail = reader.get_checkin_detail(to_uuid) |
| 2307 | 2370 | |
| 2308 | 2371 | 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(): |
| 2387 | 2386 | if diff_lines: |
| 2388 | 2387 | split_left, split_right = _compute_split_lines(diff_lines) |
| 2389 | 2388 | file_diffs.append( |
| 2390 | 2389 | { |
| 2391 | 2390 | "name": fname, |
| | @@ -2394,10 +2393,53 @@ |
| 2394 | 2393 | "split_right": split_right, |
| 2395 | 2394 | "additions": additions, |
| 2396 | 2395 | "deletions": deletions, |
| 2397 | 2396 | } |
| 2398 | 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 | + ) |
| 2399 | 2441 | |
| 2400 | 2442 | return render( |
| 2401 | 2443 | request, |
| 2402 | 2444 | "fossil/compare.html", |
| 2403 | 2445 | { |
| | @@ -3733,16 +3775,18 @@ |
| 3733 | 3775 | """List custom ticket field definitions for a project. Admin only.""" |
| 3734 | 3776 | project, fossil_repo = _get_project_and_repo(slug, request, "admin") |
| 3735 | 3777 | |
| 3736 | 3778 | from fossil.ticket_fields import TicketFieldDefinition |
| 3737 | 3779 | |
| 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() |
| 3744 | 3788 | |
| 3745 | 3789 | per_page = get_per_page(request) |
| 3746 | 3790 | paginator = Paginator(fields, per_page) |
| 3747 | 3791 | page_obj = paginator.get_page(request.GET.get("page", 1)) |
| 3748 | 3792 | |
| 3749 | 3793 | |