ferrenium4e844bb
History: log, commit pages and patches (M3)
- Log with cursor paging, day groups, grep and path filters; path history follows git's default history simplification. - Commit page: metadata, mailmap, diffstat, highlighted unified diff against the first parent with renames, whitespace and context options, and size limits. - `.patch` output matching `git diff` (Myers, funcname hunk headers). - Skip highlighting for minified files (very long lines).
+3046 −34
27 files changed
- M assets/css/repo.css +313 −0
- M dist/ferrenium.toml +5 −0
- M src/cache.rs +56 −1
- M src/config.rs +26 −0
- A src/git/diff.rs +408 −0
- A src/git/log.rs +267 −0
- M src/git/mod.rs +101 −0
- M src/git/tree.rs +23 −1
- M src/handlers/blob.rs +10 −18
- A src/handlers/commit.rs +375 −0
- A src/handlers/log.rs +166 −0
- M src/handlers/mod.rs +2 −0
- M src/handlers/repo.rs +48 −6
- M src/handlers/summary.rs +11 −2
- M src/handlers/tree.rs +2 −0
- A src/render/diff.rs +484 −0
- M src/render/highlight.rs +41 −1
- M src/render/mod.rs +1 −0
- M src/render/time.rs +30 −0
- M templates/blob.html +1 −0
- A templates/commit.html +111 −0
- A templates/log.html +47 −0
- M templates/repo_nav.html +1 −0
- M templates/summary.html +1 −1
- M templates/tree.html +1 −1
- M tests/browse.rs +6 −3
- A tests/history.rs +509 −0
| @@ -554,6 +554,319 @@ html:not(.js) .copy-status { | |||
| 554 | 554 | white-space: nowrap; | |
| 555 | 555 | } | |
| 556 | 556 | ||
| 557 | + | /* ---- Log ------------------------------------------------------------------ */ | |
| 558 | + | ||
| 559 | + | .log-tools { | |
| 560 | + | margin-top: var(--space-lg); | |
| 561 | + | max-width: 420px; | |
| 562 | + | } | |
| 563 | + | ||
| 564 | + | .log-day { | |
| 565 | + | margin-top: var(--space-xl); | |
| 566 | + | } | |
| 567 | + | ||
| 568 | + | .log { | |
| 569 | + | list-style: none; | |
| 570 | + | border-top: 1px solid var(--border); | |
| 571 | + | } | |
| 572 | + | ||
| 573 | + | .log-row { | |
| 574 | + | display: flex; | |
| 575 | + | align-items: baseline; | |
| 576 | + | justify-content: space-between; | |
| 577 | + | gap: var(--space-md); | |
| 578 | + | padding-block: 12px; | |
| 579 | + | border-bottom: 1px solid var(--border); | |
| 580 | + | } | |
| 581 | + | ||
| 582 | + | .log-main { | |
| 583 | + | min-width: 0; | |
| 584 | + | } | |
| 585 | + | ||
| 586 | + | .log-subject { | |
| 587 | + | color: var(--text-primary); | |
| 588 | + | overflow-wrap: anywhere; | |
| 589 | + | } | |
| 590 | + | ||
| 591 | + | .log-meta { | |
| 592 | + | margin-top: var(--space-2xs); | |
| 593 | + | font-family: var(--font-mono); | |
| 594 | + | font-size: var(--caption); | |
| 595 | + | color: var(--text-secondary); | |
| 596 | + | } | |
| 597 | + | ||
| 598 | + | .log-sha { | |
| 599 | + | flex: none; | |
| 600 | + | font-size: var(--caption); | |
| 601 | + | color: var(--interactive); | |
| 602 | + | } | |
| 603 | + | ||
| 604 | + | .log-note { | |
| 605 | + | margin-top: var(--space-lg); | |
| 606 | + | } | |
| 607 | + | ||
| 608 | + | .pager { | |
| 609 | + | display: flex; | |
| 610 | + | justify-content: space-between; | |
| 611 | + | gap: var(--space-md); | |
| 612 | + | margin-top: var(--space-xl); | |
| 613 | + | } | |
| 614 | + | ||
| 615 | + | .pager a, | |
| 616 | + | .pager span { | |
| 617 | + | display: inline-flex; | |
| 618 | + | align-items: center; | |
| 619 | + | min-height: 44px; | |
| 620 | + | } | |
| 621 | + | ||
| 622 | + | /* ---- Commit --------------------------------------------------------------- */ | |
| 623 | + | ||
| 624 | + | .commit-head { | |
| 625 | + | margin-top: var(--space-lg); | |
| 626 | + | } | |
| 627 | + | ||
| 628 | + | .commit-subject { | |
| 629 | + | font-size: var(--heading); | |
| 630 | + | font-weight: 400; | |
| 631 | + | line-height: 1.25; | |
| 632 | + | color: var(--text-display); | |
| 633 | + | overflow-wrap: anywhere; | |
| 634 | + | } | |
| 635 | + | ||
| 636 | + | .commit-body { | |
| 637 | + | max-width: 72ch; | |
| 638 | + | margin-top: var(--space-md); | |
| 639 | + | font-family: var(--font-body); | |
| 640 | + | font-size: var(--body); | |
| 641 | + | white-space: pre-wrap; | |
| 642 | + | overflow-wrap: anywhere; | |
| 643 | + | color: var(--text-secondary); | |
| 644 | + | } | |
| 645 | + | ||
| 646 | + | .commit-grid { | |
| 647 | + | display: grid; | |
| 648 | + | gap: var(--space-xl); | |
| 649 | + | margin-top: var(--space-xl); | |
| 650 | + | } | |
| 651 | + | ||
| 652 | + | @media (min-width: 768px) { | |
| 653 | + | .commit-grid { | |
| 654 | + | grid-template-columns: minmax(0, 1fr) auto; | |
| 655 | + | align-items: end; | |
| 656 | + | } | |
| 657 | + | } | |
| 658 | + | ||
| 659 | + | /* The one break on the commit page: a hero diffstat. */ | |
| 660 | + | .diffstat-hero { | |
| 661 | + | text-align: right; | |
| 662 | + | } | |
| 663 | + | ||
| 664 | + | .diffstat-num { | |
| 665 | + | font-family: var(--font-mono); | |
| 666 | + | font-size: 48px; | |
| 667 | + | line-height: 1; | |
| 668 | + | letter-spacing: -0.02em; | |
| 669 | + | color: var(--text-display); | |
| 670 | + | white-space: nowrap; | |
| 671 | + | } | |
| 672 | + | ||
| 673 | + | .diffstat-hero .label { | |
| 674 | + | margin-top: var(--space-sm); | |
| 675 | + | } | |
| 676 | + | ||
| 677 | + | .add { | |
| 678 | + | color: var(--success); | |
| 679 | + | } | |
| 680 | + | ||
| 681 | + | .del { | |
| 682 | + | color: var(--accent); | |
| 683 | + | } | |
| 684 | + | ||
| 685 | + | .commit-actions { | |
| 686 | + | display: flex; | |
| 687 | + | flex-wrap: wrap; | |
| 688 | + | align-items: center; | |
| 689 | + | justify-content: space-between; | |
| 690 | + | gap: var(--space-md); | |
| 691 | + | margin-top: var(--space-xl); | |
| 692 | + | } | |
| 693 | + | ||
| 694 | + | .context-ctl { | |
| 695 | + | display: inline-flex; | |
| 696 | + | align-items: center; | |
| 697 | + | gap: var(--space-sm); | |
| 698 | + | } | |
| 699 | + | ||
| 700 | + | .context-ctl a, | |
| 701 | + | .context-ctl > span:first-child, | |
| 702 | + | .context-ctl > span:last-child { | |
| 703 | + | display: inline-flex; | |
| 704 | + | align-items: center; | |
| 705 | + | justify-content: center; | |
| 706 | + | min-width: 28px; | |
| 707 | + | min-height: 28px; | |
| 708 | + | } | |
| 709 | + | ||
| 710 | + | .file-list { | |
| 711 | + | list-style: none; | |
| 712 | + | margin-top: var(--space-lg); | |
| 713 | + | border-top: 1px solid var(--border); | |
| 714 | + | } | |
| 715 | + | ||
| 716 | + | .file-row { | |
| 717 | + | display: flex; | |
| 718 | + | align-items: center; | |
| 719 | + | gap: var(--space-md); | |
| 720 | + | min-height: 40px; | |
| 721 | + | padding-block: var(--space-xs); | |
| 722 | + | border-bottom: 1px solid var(--border); | |
| 723 | + | } | |
| 724 | + | ||
| 725 | + | .file-status { | |
| 726 | + | flex: none; | |
| 727 | + | width: 1.5em; | |
| 728 | + | font-family: var(--font-mono); | |
| 729 | + | font-size: var(--caption); | |
| 730 | + | color: var(--text-secondary); | |
| 731 | + | } | |
| 732 | + | ||
| 733 | + | .file-path { | |
| 734 | + | flex: 1; | |
| 735 | + | min-width: 0; | |
| 736 | + | font-family: var(--font-mono); | |
| 737 | + | font-size: var(--body-sm); | |
| 738 | + | color: var(--text-primary); | |
| 739 | + | overflow-wrap: anywhere; | |
| 740 | + | } | |
| 741 | + | ||
| 742 | + | .file-path .dim, | |
| 743 | + | .diff-path .dim { | |
| 744 | + | color: var(--text-disabled); | |
| 745 | + | } | |
| 746 | + | ||
| 747 | + | .file-counts { | |
| 748 | + | flex: none; | |
| 749 | + | font-family: var(--font-mono); | |
| 750 | + | font-size: var(--caption); | |
| 751 | + | white-space: nowrap; | |
| 752 | + | } | |
| 753 | + | ||
| 754 | + | .blocks { | |
| 755 | + | display: inline-flex; | |
| 756 | + | flex: none; | |
| 757 | + | gap: 2px; | |
| 758 | + | } | |
| 759 | + | ||
| 760 | + | .blocks i { | |
| 761 | + | width: 8px; | |
| 762 | + | height: 8px; | |
| 763 | + | background: var(--segment-empty); | |
| 764 | + | } | |
| 765 | + | ||
| 766 | + | .blocks i.add { | |
| 767 | + | background: var(--success); | |
| 768 | + | } | |
| 769 | + | ||
| 770 | + | .blocks i.del { | |
| 771 | + | background: var(--accent); | |
| 772 | + | } | |
| 773 | + | ||
| 774 | + | .diff-file { | |
| 775 | + | margin-top: var(--space-2xl); | |
| 776 | + | } | |
| 777 | + | ||
| 778 | + | .diff-head { | |
| 779 | + | position: sticky; | |
| 780 | + | top: 0; | |
| 781 | + | z-index: 1; | |
| 782 | + | display: flex; | |
| 783 | + | align-items: center; | |
| 784 | + | gap: var(--space-md); | |
| 785 | + | min-height: 44px; | |
| 786 | + | background: var(--black); | |
| 787 | + | border-bottom: 1px solid var(--border-visible); | |
| 788 | + | } | |
| 789 | + | ||
| 790 | + | .diff-path { | |
| 791 | + | flex: 1; | |
| 792 | + | min-width: 0; | |
| 793 | + | font-family: var(--font-mono); | |
| 794 | + | font-size: var(--body-sm); | |
| 795 | + | color: var(--text-display); | |
| 796 | + | overflow-wrap: anywhere; | |
| 797 | + | } | |
| 798 | + | ||
| 799 | + | .diff-view { | |
| 800 | + | flex: none; | |
| 801 | + | } | |
| 802 | + | ||
| 803 | + | .diff-note { | |
| 804 | + | padding-block: var(--space-md); | |
| 805 | + | border-bottom: 1px solid var(--border); | |
| 806 | + | overflow-wrap: anywhere; | |
| 807 | + | } | |
| 808 | + | ||
| 809 | + | .diff-file .code-wrap { | |
| 810 | + | margin-top: 0; | |
| 811 | + | border-top: 0; | |
| 812 | + | } | |
| 813 | + | ||
| 814 | + | .code.diff .ln { | |
| 815 | + | padding: 0 var(--space-sm); | |
| 816 | + | color: var(--text-disabled); | |
| 817 | + | font-size: 12px; | |
| 818 | + | } | |
| 819 | + | ||
| 820 | + | .code.diff .ln + .ln { | |
| 821 | + | border-left: 0; | |
| 822 | + | } | |
| 823 | + | ||
| 824 | + | .code.diff .sign { | |
| 825 | + | width: 1%; | |
| 826 | + | padding: 0 var(--space-xs) 0 var(--space-sm); | |
| 827 | + | color: var(--text-disabled); | |
| 828 | + | user-select: none; | |
| 829 | + | -webkit-user-select: none; | |
| 830 | + | } | |
| 831 | + | ||
| 832 | + | .code.diff .lc { | |
| 833 | + | padding-left: 0; | |
| 834 | + | } | |
| 835 | + | ||
| 836 | + | .code.diff tr.add .ln:first-child { | |
| 837 | + | border-left-color: var(--success); | |
| 838 | + | } | |
| 839 | + | ||
| 840 | + | .code.diff tr.add .sign { | |
| 841 | + | color: var(--success); | |
| 842 | + | } | |
| 843 | + | ||
| 844 | + | .code.diff tr.del .ln:first-child { | |
| 845 | + | border-left-color: var(--accent); | |
| 846 | + | } | |
| 847 | + | ||
| 848 | + | .code.diff tr.del .sign { | |
| 849 | + | color: var(--accent); | |
| 850 | + | } | |
| 851 | + | ||
| 852 | + | .code.diff tr.del .lc { | |
| 853 | + | opacity: 0.55; | |
| 854 | + | } | |
| 855 | + | ||
| 856 | + | .code.diff tr.hunk td { | |
| 857 | + | padding-block: var(--space-xs); | |
| 858 | + | color: var(--text-disabled); | |
| 859 | + | } | |
| 860 | + | ||
| 861 | + | .code.diff tr.hunk .lc { | |
| 862 | + | font-family: var(--font-mono); | |
| 863 | + | font-size: 12px; | |
| 864 | + | } | |
| 865 | + | ||
| 866 | + | .code.diff tr.hunk:not(:first-child) td { | |
| 867 | + | border-top: 1px solid var(--border); | |
| 868 | + | } | |
| 869 | + | ||
| 557 | 870 | /* ---- Markdown ------------------------------------------------------------ */ | |
| 558 | 871 | ||
| 559 | 872 | .markdown { | |
| @@ -42,6 +42,7 @@ theme = "auto" # auto | dark | light | |||
| 42 | 42 | code_font = "Space Mono" | |
| 43 | 43 | max_blob_kb = 1024 | |
| 44 | 44 | max_highlight_kb = 256 | |
| 45 | + | log_page = 50 # commits per log page | |
| 45 | 46 | ||
| 46 | 47 | [cache] | |
| 47 | 48 | size_mb = 64 | |
| @@ -55,6 +56,10 @@ poll_interval_secs = 300 # 0 disables the periodic full rescan | |||
| 55 | 56 | git_parallel = 0 # 0 = 2 × CPUs | |
| 56 | 57 | heavy_parallel = 2 | |
| 57 | 58 | request_timeout_secs = 30 | |
| 59 | + | log_scan = 10000 # commits examined per page by path/grep log filters | |
| 60 | + | diff_files = 300 # larger commits show the file list only | |
| 61 | + | diff_lines = 10000 # changed lines rendered per commit | |
| 62 | + | diff_file_lines = 2000 # changed lines rendered per file | |
| 58 | 63 | ||
| 59 | 64 | [log] | |
| 60 | 65 | level = "info" # RUST_LOG takes precedence when set | |
| @@ -6,9 +6,11 @@ | |||
| 6 | 6 | ||
| 7 | 7 | use std::sync::Arc; | |
| 8 | 8 | ||
| 9 | − | use crate::git::ObjectId; | |
| 9 | + | use crate::git::log::{LogPage, LogQuery}; | |
| 10 | 10 | use crate::git::refs::RefList; | |
| 11 | 11 | use crate::git::tree::TreeEntry; | |
| 12 | + | use crate::git::{Mailmap, ObjectId}; | |
| 13 | + | use crate::render::diff::{CommitDiff, Options as DiffOptions}; | |
| 12 | 14 | ||
| 13 | 15 | #[derive(Debug, Clone, PartialEq, Eq, Hash)] | |
| 14 | 16 | pub enum Key { | |
| @@ -28,6 +30,15 @@ pub enum Key { | |||
| 28 | 30 | repo: String, | |
| 29 | 31 | generation: u64, | |
| 30 | 32 | }, | |
| 33 | + | Log { | |
| 34 | + | tip: ObjectId, | |
| 35 | + | query: LogQuery, | |
| 36 | + | }, | |
| 37 | + | Diff { | |
| 38 | + | commit: ObjectId, | |
| 39 | + | options: DiffOptions, | |
| 40 | + | }, | |
| 41 | + | Mailmap(ObjectId), | |
| 31 | 42 | } | |
| 32 | 43 | ||
| 33 | 44 | #[derive(Clone)] | |
| @@ -37,6 +48,9 @@ pub enum Value { | |||
| 37 | 48 | Tree(Arc<Vec<TreeEntry>>), | |
| 38 | 49 | Count(u64), | |
| 39 | 50 | Refs(Arc<RefList>), | |
| 51 | + | Log(Arc<LogPage>), | |
| 52 | + | Diff(Arc<CommitDiff>), | |
| 53 | + | Mailmap(Arc<Mailmap>), | |
| 40 | 54 | } | |
| 41 | 55 | ||
| 42 | 56 | impl Value { | |
| @@ -47,6 +61,9 @@ impl Value { | |||
| 47 | 61 | Value::Tree(entries) => entries.iter().map(|e| e.name.len() + 64).sum(), | |
| 48 | 62 | Value::Count(_) => 8, | |
| 49 | 63 | Value::Refs(refs) => refs.weight(), | |
| 64 | + | Value::Log(page) => page.weight(), | |
| 65 | + | Value::Diff(diff) => diff.weight(), | |
| 66 | + | Value::Mailmap(map) => map.weight(), | |
| 50 | 67 | } | |
| 51 | 68 | } | |
| 52 | 69 | } | |
| @@ -64,6 +81,9 @@ impl Cache { | |||
| 64 | 81 | Key::Highlight { syntax, .. } => syntax.len(), | |
| 65 | 82 | Key::Markdown { context, .. } => context.len(), | |
| 66 | 83 | Key::Refs { repo, .. } => repo.len(), | |
| 84 | + | Key::Log { query, .. } => { | |
| 85 | + | query.path.len() + query.grep.as_ref().map_or(0, String::len) | |
| 86 | + | } | |
| 67 | 87 | _ => 0, | |
| 68 | 88 | }; | |
| 69 | 89 | u32::try_from(64 + key + value.weight()).unwrap_or(u32::MAX) | |
| @@ -136,6 +156,41 @@ impl Cache { | |||
| 136 | 156 | } | |
| 137 | 157 | } | |
| 138 | 158 | ||
| 159 | + | pub fn log<E>( | |
| 160 | + | &self, | |
| 161 | + | key: Key, | |
| 162 | + | compute: impl FnOnce() -> Result<LogPage, E>, | |
| 163 | + | ) -> Result<Arc<LogPage>, E> { | |
| 164 | + | match self.get_or(key, || compute().map(|v| Value::Log(Arc::new(v))))? { | |
| 165 | + | Value::Log(v) => Ok(v), | |
| 166 | + | _ => unreachable!("cache key/value kind mismatch"), | |
| 167 | + | } | |
| 168 | + | } | |
| 169 | + | ||
| 170 | + | pub fn diff<E>( | |
| 171 | + | &self, | |
| 172 | + | key: Key, | |
| 173 | + | compute: impl FnOnce() -> Result<CommitDiff, E>, | |
| 174 | + | ) -> Result<Arc<CommitDiff>, E> { | |
| 175 | + | match self.get_or(key, || compute().map(|v| Value::Diff(Arc::new(v))))? { | |
| 176 | + | Value::Diff(v) => Ok(v), | |
| 177 | + | _ => unreachable!("cache key/value kind mismatch"), | |
| 178 | + | } | |
| 179 | + | } | |
| 180 | + | ||
| 181 | + | pub fn mailmap<E>( | |
| 182 | + | &self, | |
| 183 | + | blob: ObjectId, | |
| 184 | + | compute: impl FnOnce() -> Result<Mailmap, E>, | |
| 185 | + | ) -> Result<Arc<Mailmap>, E> { | |
| 186 | + | match self.get_or(Key::Mailmap(blob), || { | |
| 187 | + | compute().map(|v| Value::Mailmap(Arc::new(v))) | |
| 188 | + | })? { | |
| 189 | + | Value::Mailmap(v) => Ok(v), | |
| 190 | + | _ => unreachable!("cache key/value kind mismatch"), | |
| 191 | + | } | |
| 192 | + | } | |
| 193 | + | ||
| 139 | 194 | pub fn entry_count(&self) -> u64 { | |
| 140 | 195 | self.inner.run_pending_tasks(); | |
| 141 | 196 | self.inner.entry_count() | |
| @@ -157,6 +157,8 @@ pub struct UiConfig { | |||
| 157 | 157 | pub code_font: String, | |
| 158 | 158 | pub max_blob_kb: u64, | |
| 159 | 159 | pub max_highlight_kb: u64, | |
| 160 | + | /// Commits per log page. | |
| 161 | + | pub log_page: usize, | |
| 160 | 162 | } | |
| 161 | 163 | ||
| 162 | 164 | impl Default for UiConfig { | |
| @@ -166,6 +168,7 @@ impl Default for UiConfig { | |||
| 166 | 168 | code_font: DEFAULT_CODE_FONT.into(), | |
| 167 | 169 | max_blob_kb: 1024, | |
| 168 | 170 | max_highlight_kb: 256, | |
| 171 | + | log_page: 50, | |
| 169 | 172 | } | |
| 170 | 173 | } | |
| 171 | 174 | } | |
| @@ -229,6 +232,12 @@ pub struct LimitsConfig { | |||
| 229 | 232 | pub git_parallel: usize, | |
| 230 | 233 | pub heavy_parallel: usize, | |
| 231 | 234 | pub request_timeout_secs: u64, | |
| 235 | + | /// Commits a filtered log (path or grep) examines per page. | |
| 236 | + | pub log_scan: usize, | |
| 237 | + | /// Above these, a commit's diff is not shown in full. | |
| 238 | + | pub diff_files: usize, | |
| 239 | + | pub diff_lines: usize, | |
| 240 | + | pub diff_file_lines: usize, | |
| 232 | 241 | } | |
| 233 | 242 | ||
| 234 | 243 | impl Default for LimitsConfig { | |
| @@ -237,6 +246,10 @@ impl Default for LimitsConfig { | |||
| 237 | 246 | git_parallel: 0, | |
| 238 | 247 | heavy_parallel: 2, | |
| 239 | 248 | request_timeout_secs: 30, | |
| 249 | + | log_scan: 10_000, | |
| 250 | + | diff_files: 300, | |
| 251 | + | diff_lines: 10_000, | |
| 252 | + | diff_file_lines: 2_000, | |
| 240 | 253 | } | |
| 241 | 254 | } | |
| 242 | 255 | } | |
| @@ -459,6 +472,19 @@ impl Config { | |||
| 459 | 472 | if self.limits.request_timeout_secs == 0 { | |
| 460 | 473 | errors.push("limits.request_timeout_secs must be at least 1".into()); | |
| 461 | 474 | } | |
| 475 | + | if !(1..=1000).contains(&self.ui.log_page) { | |
| 476 | + | errors.push("ui.log_page must be between 1 and 1000".into()); | |
| 477 | + | } | |
| 478 | + | for (key, value) in [ | |
| 479 | + | ("log_scan", self.limits.log_scan), | |
| 480 | + | ("diff_files", self.limits.diff_files), | |
| 481 | + | ("diff_lines", self.limits.diff_lines), | |
| 482 | + | ("diff_file_lines", self.limits.diff_file_lines), | |
| 483 | + | ] { | |
| 484 | + | if value == 0 { | |
| 485 | + | errors.push(format!("limits.{key} must be at least 1")); | |
| 486 | + | } | |
| 487 | + | } | |
| 462 | 488 | ||
| 463 | 489 | // log | |
| 464 | 490 | if let Err(e) = tracing_subscriber::EnvFilter::try_new(&self.log.level) { | |
| @@ -0,0 +1,408 @@ | |||
| 1 | + | //! What a commit changed: the file list (with renames) against its first | |
| 2 | + | //! parent, and line diffs grouped into hunks. | |
| 3 | + | ||
| 4 | + | use std::hash::Hash; | |
| 5 | + | ||
| 6 | + | use anyhow::Context; | |
| 7 | + | use gix::bstr::BString; | |
| 8 | + | use gix::diff::blob::{Algorithm, Diff, InternedInput}; | |
| 9 | + | use gix::object::tree::diff::ChangeDetached; | |
| 10 | + | ||
| 11 | + | use super::ObjectId; | |
| 12 | + | use super::tree::EntryKind; | |
| 13 | + | ||
| 14 | + | #[derive(Debug, Clone, Copy, PartialEq, Eq)] | |
| 15 | + | pub enum Status { | |
| 16 | + | Added, | |
| 17 | + | Deleted, | |
| 18 | + | Modified, | |
| 19 | + | Renamed, | |
| 20 | + | Copied, | |
| 21 | + | } | |
| 22 | + | ||
| 23 | + | /// One side of a change. | |
| 24 | + | #[derive(Debug, Clone, PartialEq, Eq)] | |
| 25 | + | pub struct Side { | |
| 26 | + | pub path: BString, | |
| 27 | + | pub oid: ObjectId, | |
| 28 | + | pub kind: EntryKind, | |
| 29 | + | } | |
| 30 | + | ||
| 31 | + | #[derive(Debug, Clone, PartialEq, Eq)] | |
| 32 | + | pub struct FileChange { | |
| 33 | + | pub status: Status, | |
| 34 | + | pub old: Option<Side>, | |
| 35 | + | pub new: Option<Side>, | |
| 36 | + | /// Rename/copy similarity in percent. | |
| 37 | + | pub similarity: Option<u8>, | |
| 38 | + | } | |
| 39 | + | ||
| 40 | + | impl FileChange { | |
| 41 | + | /// The path after the change (before it, for deletions). | |
| 42 | + | pub fn path(&self) -> &BString { | |
| 43 | + | match (&self.new, &self.old) { | |
| 44 | + | (Some(side), _) | (None, Some(side)) => &side.path, | |
| 45 | + | (None, None) => unreachable!("a change has at least one side"), | |
| 46 | + | } | |
| 47 | + | } | |
| 48 | + | } | |
| 49 | + | ||
| 50 | + | #[derive(Debug, Clone)] | |
| 51 | + | pub struct Changes { | |
| 52 | + | /// The first parent; `None` for a root commit (compared to nothing). | |
| 53 | + | pub parent: Option<ObjectId>, | |
| 54 | + | pub files: Vec<FileChange>, | |
| 55 | + | } | |
| 56 | + | ||
| 57 | + | /// Files changed by `commit` relative to its first parent, renames | |
| 58 | + | /// detected like `git diff -M` (50% similarity), sorted by path. | |
| 59 | + | pub fn changes(repo: &gix::Repository, commit: ObjectId) -> anyhow::Result<Changes> { | |
| 60 | + | let commit = repo.find_commit(commit).context("commit not found")?; | |
| 61 | + | let new_tree = commit.tree().context("commit has no tree")?; | |
| 62 | + | let parent = commit.parent_ids().next().map(|id| id.detach()); | |
| 63 | + | let old_tree = match parent { | |
| 64 | + | Some(id) => Some( | |
| 65 | + | repo.find_commit(id) | |
| 66 | + | .context("parent commit not found")? | |
| 67 | + | .tree() | |
| 68 | + | .context("parent has no tree")?, | |
| 69 | + | ), | |
| 70 | + | None => None, | |
| 71 | + | }; | |
| 72 | + | let options = gix::diff::Options::default().with_rewrites(Some(Default::default())); | |
| 73 | + | let changes = repo | |
| 74 | + | .diff_tree_to_tree(old_tree.as_ref(), Some(&new_tree), options) | |
| 75 | + | .context("cannot diff trees")?; | |
| 76 | + | ||
| 77 | + | let side = |path: &BString, mode: gix::object::tree::EntryMode, oid: ObjectId| { | |
| 78 | + | (!mode.is_tree()).then(|| Side { | |
| 79 | + | path: path.clone(), | |
| 80 | + | oid, | |
| 81 | + | kind: EntryKind::from_gix(mode.kind()), | |
| 82 | + | }) | |
| 83 | + | }; | |
| 84 | + | let mut files = Vec::new(); | |
| 85 | + | for change in changes { | |
| 86 | + | let file = match change { | |
| 87 | + | ChangeDetached::Addition { | |
| 88 | + | location, | |
| 89 | + | entry_mode, | |
| 90 | + | id, | |
| 91 | + | .. | |
| 92 | + | } => FileChange { | |
| 93 | + | status: Status::Added, | |
| 94 | + | old: None, | |
| 95 | + | new: side(&location, entry_mode, id), | |
| 96 | + | similarity: None, | |
| 97 | + | }, | |
| 98 | + | ChangeDetached::Deletion { | |
| 99 | + | location, | |
| 100 | + | entry_mode, | |
| 101 | + | id, | |
| 102 | + | .. | |
| 103 | + | } => FileChange { | |
| 104 | + | status: Status::Deleted, | |
| 105 | + | old: side(&location, entry_mode, id), | |
| 106 | + | new: None, | |
| 107 | + | similarity: None, | |
| 108 | + | }, | |
| 109 | + | ChangeDetached::Modification { | |
| 110 | + | location, | |
| 111 | + | previous_entry_mode, | |
| 112 | + | previous_id, | |
| 113 | + | entry_mode, | |
| 114 | + | id, | |
| 115 | + | } => { | |
| 116 | + | // A tree replaced by a file (or the reverse) is just an | |
| 117 | + | // addition (deletion) of the file. A file that changed type | |
| 118 | + | // (regular, symlink, submodule) is both, as git shows it. | |
| 119 | + | let old = side(&location, previous_entry_mode, previous_id); | |
| 120 | + | let new = side(&location, entry_mode, id); | |
| 121 | + | match (old, new) { | |
| 122 | + | (Some(old), Some(new)) if !old.kind.same_type(new.kind) => { | |
| 123 | + | files.push(FileChange { | |
| 124 | + | status: Status::Deleted, | |
| 125 | + | old: Some(old), | |
| 126 | + | new: None, | |
| 127 | + | similarity: None, | |
| 128 | + | }); | |
| 129 | + | FileChange { | |
| 130 | + | status: Status::Added, | |
| 131 | + | old: None, | |
| 132 | + | new: Some(new), | |
| 133 | + | similarity: None, | |
| 134 | + | } | |
| 135 | + | } | |
| 136 | + | (old, new) => FileChange { | |
| 137 | + | status: match (&old, &new) { | |
| 138 | + | (None, _) => Status::Added, | |
| 139 | + | (_, None) => Status::Deleted, | |
| 140 | + | _ => Status::Modified, | |
| 141 | + | }, | |
| 142 | + | old, | |
| 143 | + | new, | |
| 144 | + | similarity: None, | |
| 145 | + | }, | |
| 146 | + | } | |
| 147 | + | } | |
| 148 | + | ChangeDetached::Rewrite { | |
| 149 | + | source_location, | |
| 150 | + | source_entry_mode, | |
| 151 | + | source_id, | |
| 152 | + | diff, | |
| 153 | + | entry_mode, | |
| 154 | + | id, | |
| 155 | + | location, | |
| 156 | + | copy, | |
| 157 | + | .. | |
| 158 | + | } => FileChange { | |
| 159 | + | status: if copy { | |
| 160 | + | Status::Copied | |
| 161 | + | } else { | |
| 162 | + | Status::Renamed | |
| 163 | + | }, | |
| 164 | + | old: side(&source_location, source_entry_mode, source_id), | |
| 165 | + | new: side(&location, entry_mode, id), | |
| 166 | + | // Truncated like git's percentage. | |
| 167 | + | similarity: Some( | |
| 168 | + | diff.map_or(100.0, |d| d.similarity * 100.0) | |
| 169 | + | .floor() | |
| 170 | + | .clamp(0.0, 100.0) as u8, | |
| 171 | + | ), | |
| 172 | + | }, | |
| 173 | + | }; | |
| 174 | + | if file.old.is_some() || file.new.is_some() { | |
| 175 | + | files.push(file); | |
| 176 | + | } | |
| 177 | + | } | |
| 178 | + | files.sort_by(|a, b| a.path().cmp(b.path())); | |
| 179 | + | Ok(Changes { parent, files }) | |
| 180 | + | } | |
| 181 | + | ||
| 182 | + | #[derive(Debug, Clone, Copy, PartialEq, Eq)] | |
| 183 | + | pub enum LineKind { | |
| 184 | + | Context, | |
| 185 | + | Added, | |
| 186 | + | Removed, | |
| 187 | + | } | |
| 188 | + | ||
| 189 | + | /// A line in a hunk; `old`/`new` are 0-based line indexes into each side. | |
| 190 | + | #[derive(Debug, Clone, Copy, PartialEq, Eq)] | |
| 191 | + | pub struct Line { | |
| 192 | + | pub kind: LineKind, | |
| 193 | + | pub old: Option<u32>, | |
| 194 | + | pub new: Option<u32>, | |
| 195 | + | } | |
| 196 | + | ||
| 197 | + | #[derive(Debug, Clone, PartialEq, Eq)] | |
| 198 | + | pub struct Hunk { | |
| 199 | + | /// 0-based first line and length on each side. | |
| 200 | + | pub old_start: u32, | |
| 201 | + | pub old_len: u32, | |
| 202 | + | pub new_start: u32, | |
| 203 | + | pub new_len: u32, | |
| 204 | + | pub lines: Vec<Line>, | |
| 205 | + | /// The enclosing "function" line git shows after the ranges. | |
| 206 | + | pub func: Option<String>, | |
| 207 | + | } | |
| 208 | + | ||
| 209 | + | impl Hunk { | |
| 210 | + | /// `@@ -1,5 +1,8 @@ fn main() {` | |
| 211 | + | pub fn header(&self) -> String { | |
| 212 | + | fn range(start: u32, len: u32) -> String { | |
| 213 | + | match len { | |
| 214 | + | 0 => format!("{start},0"), | |
| 215 | + | 1 => format!("{}", start + 1), | |
| 216 | + | _ => format!("{},{len}", start + 1), | |
| 217 | + | } | |
| 218 | + | } | |
| 219 | + | let mut header = format!( | |
| 220 | + | "@@ -{} +{} @@", | |
| 221 | + | range(self.old_start, self.old_len), | |
| 222 | + | range(self.new_start, self.new_len) | |
| 223 | + | ); | |
| 224 | + | if let Some(func) = &self.func { | |
| 225 | + | header.push(' '); | |
| 226 | + | header.push_str(func); | |
| 227 | + | } | |
| 228 | + | header | |
| 229 | + | } | |
| 230 | + | } | |
| 231 | + | ||
| 232 | + | #[derive(Debug, Clone, Default, PartialEq, Eq)] | |
| 233 | + | pub struct LineDiff { | |
| 234 | + | pub hunks: Vec<Hunk>, | |
| 235 | + | pub added: u32, | |
| 236 | + | pub removed: u32, | |
| 237 | + | } | |
| 238 | + | ||
| 239 | + | /// Line diff of two texts split into lines (each with its `\n`, so a | |
| 240 | + | /// missing final newline counts as a change), with `context` unchanged | |
| 241 | + | /// lines around each change. `ignore_ws` compares lines with all | |
| 242 | + | /// whitespace removed, like `git diff -w`. | |
| 243 | + | pub fn lines(old: &[&str], new: &[&str], context: u32, ignore_ws: bool) -> LineDiff { | |
| 244 | + | let diff = if ignore_ws { | |
| 245 | + | let strip = | |
| 246 | + | |l: &&str| -> Vec<u8> { l.bytes().filter(|b| !b.is_ascii_whitespace()).collect() }; | |
| 247 | + | compute(old.iter().map(strip), new.iter().map(strip)) | |
| 248 | + | } else { | |
| 249 | + | compute( | |
| 250 | + | old.iter().map(|l| l.as_bytes()), | |
| 251 | + | new.iter().map(|l| l.as_bytes()), | |
| 252 | + | ) | |
| 253 | + | }; | |
| 254 | + | let raw: Vec<_> = diff.hunks().collect(); | |
| 255 | + | let (old_len, new_len) = (old.len() as u32, new.len() as u32); | |
| 256 | + | let mut out = LineDiff { | |
| 257 | + | hunks: Vec::new(), | |
| 258 | + | added: diff.count_additions(), | |
| 259 | + | removed: diff.count_removals(), | |
| 260 | + | }; | |
| 261 | + | let mut i = 0; | |
| 262 | + | while i < raw.len() { | |
| 263 | + | // Merge changes whose context would overlap or touch. | |
| 264 | + | let mut j = i; | |
| 265 | + | while j + 1 < raw.len() && raw[j + 1].before.start - raw[j].before.end <= 2 * context { | |
| 266 | + | j += 1; | |
| 267 | + | } | |
| 268 | + | let lead = raw[i].before.start.min(context); | |
| 269 | + | let (old_start, new_start) = (raw[i].before.start - lead, raw[i].after.start - lead); | |
| 270 | + | let trail = context.min(old_len - raw[j].before.end); | |
| 271 | + | let (old_end, new_end) = (raw[j].before.end + trail, raw[j].after.end + trail); | |
| 272 | + | debug_assert!(new_end <= new_len); | |
| 273 | + | ||
| 274 | + | let mut lines = Vec::new(); | |
| 275 | + | let (mut o, mut n) = (old_start, new_start); | |
| 276 | + | for h in &raw[i..=j] { | |
| 277 | + | while o < h.before.start { | |
| 278 | + | lines.push(Line { | |
| 279 | + | kind: LineKind::Context, | |
| 280 | + | old: Some(o), | |
| 281 | + | new: Some(n), | |
| 282 | + | }); | |
| 283 | + | o += 1; | |
| 284 | + | n += 1; | |
| 285 | + | } | |
| 286 | + | for r in h.before.clone() { | |
| 287 | + | lines.push(Line { | |
| 288 | + | kind: LineKind::Removed, | |
| 289 | + | old: Some(r), | |
| 290 | + | new: None, | |
| 291 | + | }); | |
| 292 | + | } | |
| 293 | + | for a in h.after.clone() { | |
| 294 | + | lines.push(Line { | |
| 295 | + | kind: LineKind::Added, | |
| 296 | + | old: None, | |
| 297 | + | new: Some(a), | |
| 298 | + | }); | |
| 299 | + | } | |
| 300 | + | o = h.before.end; | |
| 301 | + | n = h.after.end; | |
| 302 | + | } | |
| 303 | + | while o < old_end { | |
| 304 | + | lines.push(Line { | |
| 305 | + | kind: LineKind::Context, | |
| 306 | + | old: Some(o), | |
| 307 | + | new: Some(n), | |
| 308 | + | }); | |
| 309 | + | o += 1; | |
| 310 | + | n += 1; | |
| 311 | + | } | |
| 312 | + | out.hunks.push(Hunk { | |
| 313 | + | old_start, | |
| 314 | + | old_len: old_end - old_start, | |
| 315 | + | new_start, | |
| 316 | + | new_len: new_end - new_start, | |
| 317 | + | lines, | |
| 318 | + | func: func_line(&old[..old_start as usize]), | |
| 319 | + | }); | |
| 320 | + | i = j + 1; | |
| 321 | + | } | |
| 322 | + | out | |
| 323 | + | } | |
| 324 | + | ||
| 325 | + | /// git's default hunk-header context: the closest line above the hunk | |
| 326 | + | /// that starts like an identifier, trailing whitespace trimmed, at most | |
| 327 | + | /// 80 bytes. | |
| 328 | + | fn func_line(before: &[&str]) -> Option<String> { | |
| 329 | + | let line = before.iter().rev().find(|l| { | |
| 330 | + | l.bytes() | |
| 331 | + | .next() | |
| 332 | + | .is_some_and(|b| b.is_ascii_alphabetic() || b == b'_' || b == b'$') | |
| 333 | + | })?; | |
| 334 | + | let bytes = &line.as_bytes()[..line.len().min(80)]; | |
| 335 | + | let end = bytes | |
| 336 | + | .iter() | |
| 337 | + | .rposition(|b| !b.is_ascii_whitespace()) | |
| 338 | + | .map_or(0, |i| i + 1); | |
| 339 | + | Some(String::from_utf8_lossy(&bytes[..end]).into_owned()) | |
| 340 | + | } | |
| 341 | + | ||
| 342 | + | fn compute<T: AsRef<[u8]> + Hash + Eq + Default>( | |
| 343 | + | old: impl Iterator<Item = T>, | |
| 344 | + | new: impl Iterator<Item = T>, | |
| 345 | + | ) -> Diff { | |
| 346 | + | let mut input = InternedInput::default(); | |
| 347 | + | input.update_before(old); | |
| 348 | + | input.update_after(new); | |
| 349 | + | gix::diff::blob::diff_with_slider_heuristics(Algorithm::Myers, &input) | |
| 350 | + | } | |
| 351 | + | ||
| 352 | + | /// Git's mode for an entry kind (`100644`, …). | |
| 353 | + | pub fn mode(kind: EntryKind) -> &'static str { | |
| 354 | + | match kind { | |
| 355 | + | EntryKind::Blob => "100644", | |
| 356 | + | EntryKind::Executable => "100755", | |
| 357 | + | EntryKind::Symlink => "120000", | |
| 358 | + | EntryKind::Submodule => "160000", | |
| 359 | + | EntryKind::Tree => "040000", | |
| 360 | + | } | |
| 361 | + | } | |
| 362 | + | ||
| 363 | + | #[cfg(test)] | |
| 364 | + | mod tests { | |
| 365 | + | use super::*; | |
| 366 | + | ||
| 367 | + | fn split(text: &str) -> Vec<&str> { | |
| 368 | + | text.split_inclusive('\n').collect() | |
| 369 | + | } | |
| 370 | + | ||
| 371 | + | #[test] | |
| 372 | + | fn hunks_with_context_and_merging() { | |
| 373 | + | let old = "a\nb\nc\nd\ne\nf\ng\nh\ni\nj\n"; | |
| 374 | + | let new = "a\nB\nc\nd\ne\nf\ng\nh\ni\nJ\n"; | |
| 375 | + | let diff = lines(&split(old), &split(new), 3, false); | |
| 376 | + | assert_eq!((diff.added, diff.removed), (2, 2)); | |
| 377 | + | // Changes at lines 2 and 10 are 7 apart: with 3 lines of context | |
| 378 | + | // they stay separate hunks. | |
| 379 | + | assert_eq!(diff.hunks.len(), 2); | |
| 380 | + | assert_eq!(diff.hunks[0].header(), "@@ -1,5 +1,5 @@"); | |
| 381 | + | assert_eq!(diff.hunks[1].header(), "@@ -7,4 +7,4 @@ f"); | |
| 382 | + | // With more context they merge. | |
| 383 | + | let diff = lines(&split(old), &split(new), 4, false); | |
| 384 | + | assert_eq!(diff.hunks.len(), 1); | |
| 385 | + | assert_eq!(diff.hunks[0].header(), "@@ -1,10 +1,10 @@"); | |
| 386 | + | } | |
| 387 | + | ||
| 388 | + | #[test] | |
| 389 | + | fn additions_to_empty_and_whitespace() { | |
| 390 | + | let diff = lines(&[], &split("x\ny\n"), 3, false); | |
| 391 | + | assert_eq!(diff.hunks[0].header(), "@@ -0,0 +1,2 @@"); | |
| 392 | + | assert_eq!(diff.hunks[0].lines.len(), 2); | |
| 393 | + | ||
| 394 | + | let old = split("fn x() {\n a;\n}\n"); | |
| 395 | + | let new = split("fn x() {\n\ta;\n}\n"); | |
| 396 | + | assert_eq!(lines(&old, &new, 3, false).added, 1); | |
| 397 | + | let ws = lines(&old, &new, 3, true); | |
| 398 | + | assert!(ws.hunks.is_empty()); | |
| 399 | + | assert_eq!((ws.added, ws.removed), (0, 0)); | |
| 400 | + | } | |
| 401 | + | ||
| 402 | + | #[test] | |
| 403 | + | fn missing_final_newline_is_a_change() { | |
| 404 | + | let diff = lines(&split("a\nb\n"), &split("a\nb"), 0, false); | |
| 405 | + | assert_eq!((diff.added, diff.removed), (1, 1)); | |
| 406 | + | assert_eq!(diff.hunks[0].header(), "@@ -2 +2 @@ a"); | |
| 407 | + | } | |
| 408 | + | } | |
| @@ -0,0 +1,267 @@ | |||
| 1 | + | //! Commit history: newest-first walks, paged with a cursor, optionally | |
| 2 | + | //! filtered by path and message. | |
| 3 | + | //! | |
| 4 | + | //! Path filters follow git's default history simplification: at a merge | |
| 5 | + | //! that kept one parent's version of the path, only that parent is | |
| 6 | + | //! followed, so side branches whose changes didn't survive are left out | |
| 7 | + | //! (`git log -- path`, not `--full-history`). | |
| 8 | + | ||
| 9 | + | use std::collections::{BinaryHeap, HashMap, HashSet, VecDeque}; | |
| 10 | + | ||
| 11 | + | use anyhow::Context; | |
| 12 | + | use gix::bstr::ByteSlice; | |
| 13 | + | use gix::revision::walk::Sorting; | |
| 14 | + | use gix::traverse::commit::simple::CommitTimeOrder; | |
| 15 | + | ||
| 16 | + | use super::tree::{self, EntryKind}; | |
| 17 | + | use super::{CommitInfo, ObjectId, commit_info}; | |
| 18 | + | ||
| 19 | + | #[derive(Debug, Clone, Default, PartialEq, Eq, Hash)] | |
| 20 | + | pub struct LogQuery { | |
| 21 | + | /// Only commits that change this path (empty: all commits). | |
| 22 | + | pub path: Vec<u8>, | |
| 23 | + | /// Case-insensitive match on the message, author name and email. | |
| 24 | + | pub grep: Option<String>, | |
| 25 | + | /// Start after this commit (the last one on the previous page). | |
| 26 | + | pub after: Option<ObjectId>, | |
| 27 | + | pub page: usize, | |
| 28 | + | /// Commits a filtered walk may examine before giving up on this page. | |
| 29 | + | pub scan_limit: usize, | |
| 30 | + | } | |
| 31 | + | ||
| 32 | + | impl LogQuery { | |
| 33 | + | fn filtered(&self) -> bool { | |
| 34 | + | !self.path.is_empty() || self.grep.is_some() | |
| 35 | + | } | |
| 36 | + | } | |
| 37 | + | ||
| 38 | + | /// Where the "newer" link of a page goes. | |
| 39 | + | #[derive(Debug, Clone, Copy, PartialEq, Eq)] | |
| 40 | + | pub enum Newer { | |
| 41 | + | /// This is the first page. | |
| 42 | + | None, | |
| 43 | + | /// Back to the first page (filtered logs can't step back exactly). | |
| 44 | + | First, | |
| 45 | + | After(ObjectId), | |
| 46 | + | } | |
| 47 | + | ||
| 48 | + | #[derive(Debug, Clone)] | |
| 49 | + | pub struct LogPage { | |
| 50 | + | pub commits: Vec<CommitInfo>, | |
| 51 | + | /// Cursor for the next (older) page. | |
| 52 | + | pub older: Option<ObjectId>, | |
| 53 | + | pub newer: Newer, | |
| 54 | + | /// The filter gave up after `scan_limit` commits; `older` continues. | |
| 55 | + | pub limit_reached: bool, | |
| 56 | + | } | |
| 57 | + | ||
| 58 | + | impl LogPage { | |
| 59 | + | pub fn weight(&self) -> usize { | |
| 60 | + | 64 + self.commits.iter().map(CommitInfo::weight).sum::<usize>() | |
| 61 | + | } | |
| 62 | + | } | |
| 63 | + | ||
| 64 | + | #[derive(Debug, thiserror::Error)] | |
| 65 | + | pub enum LogError { | |
| 66 | + | #[error("the cursor commit is not in this history")] | |
| 67 | + | UnknownCursor, | |
| 68 | + | #[error(transparent)] | |
| 69 | + | Git(#[from] anyhow::Error), | |
| 70 | + | } | |
| 71 | + | ||
| 72 | + | /// One page of the history of `tip`, newest commit first. | |
| 73 | + | pub fn page(repo: &gix::Repository, tip: ObjectId, query: &LogQuery) -> Result<LogPage, LogError> { | |
| 74 | + | let filtered = query.filtered(); | |
| 75 | + | let grep = query.grep.as_ref().map(|q| q.to_lowercase()); | |
| 76 | + | let mut walk = if query.path.is_empty() { | |
| 77 | + | Walk::All( | |
| 78 | + | repo.rev_walk([tip]) | |
| 79 | + | .sorting(Sorting::ByCommitTime(CommitTimeOrder::NewestFirst)) | |
| 80 | + | .all() | |
| 81 | + | .context("cannot walk history")?, | |
| 82 | + | ) | |
| 83 | + | } else { | |
| 84 | + | Walk::Path(PathWalk::new(repo, tip, &query.path)?) | |
| 85 | + | }; | |
| 86 | + | ||
| 87 | + | // Unfiltered: the commits up to and including the cursor, enough to | |
| 88 | + | // find where the previous page started. | |
| 89 | + | let mut recent: VecDeque<ObjectId> = VecDeque::new(); | |
| 90 | + | let mut seeking = query.after; | |
| 91 | + | let mut out = LogPage { | |
| 92 | + | commits: Vec::new(), | |
| 93 | + | older: None, | |
| 94 | + | newer: Newer::None, | |
| 95 | + | limit_reached: false, | |
| 96 | + | }; | |
| 97 | + | let mut scanned = 0; | |
| 98 | + | let mut last_examined = None; | |
| 99 | + | while let Some(step) = walk.next() { | |
| 100 | + | let (id, touches) = step?; | |
| 101 | + | if let Some(cursor) = seeking { | |
| 102 | + | if !filtered { | |
| 103 | + | recent.push_back(id); | |
| 104 | + | if recent.len() > query.page + 1 { | |
| 105 | + | recent.pop_front(); | |
| 106 | + | } | |
| 107 | + | } | |
| 108 | + | if id == cursor { | |
| 109 | + | seeking = None; | |
| 110 | + | } | |
| 111 | + | continue; | |
| 112 | + | } | |
| 113 | + | if filtered { | |
| 114 | + | if scanned == query.scan_limit { | |
| 115 | + | out.limit_reached = true; | |
| 116 | + | out.older = last_examined; | |
| 117 | + | break; | |
| 118 | + | } | |
| 119 | + | scanned += 1; | |
| 120 | + | last_examined = Some(id); | |
| 121 | + | } | |
| 122 | + | if !touches { | |
| 123 | + | continue; | |
| 124 | + | } | |
| 125 | + | let commit = commit_info(repo, id)?; | |
| 126 | + | if let Some(grep) = &grep | |
| 127 | + | && !message_matches(repo, &commit, grep)? | |
| 128 | + | { | |
| 129 | + | continue; | |
| 130 | + | } | |
| 131 | + | if out.commits.len() == query.page { | |
| 132 | + | out.older = out.commits.last().map(|c| c.id); | |
| 133 | + | break; | |
| 134 | + | } | |
| 135 | + | out.commits.push(commit); | |
| 136 | + | } | |
| 137 | + | if seeking.is_some() { | |
| 138 | + | return Err(LogError::UnknownCursor); | |
| 139 | + | } | |
| 140 | + | if query.after.is_some() { | |
| 141 | + | out.newer = if filtered || recent.len() <= query.page { | |
| 142 | + | Newer::First | |
| 143 | + | } else { | |
| 144 | + | Newer::After(recent[0]) | |
| 145 | + | }; | |
| 146 | + | } | |
| 147 | + | Ok(out) | |
| 148 | + | } | |
| 149 | + | ||
| 150 | + | fn message_matches(repo: &gix::Repository, info: &CommitInfo, grep: &str) -> anyhow::Result<bool> { | |
| 151 | + | if info.author_name.to_lowercase().contains(grep) | |
| 152 | + | || info.author_email.to_lowercase().contains(grep) | |
| 153 | + | { | |
| 154 | + | return Ok(true); | |
| 155 | + | } | |
| 156 | + | let commit = repo.find_commit(info.id).context("commit not found")?; | |
| 157 | + | let message = commit.message_raw_sloppy().to_str_lossy().to_lowercase(); | |
| 158 | + | Ok(message.contains(grep)) | |
| 159 | + | } | |
| 160 | + | ||
| 161 | + | enum Walk<'repo> { | |
| 162 | + | All(gix::revision::Walk<'repo>), | |
| 163 | + | Path(PathWalk<'repo>), | |
| 164 | + | } | |
| 165 | + | ||
| 166 | + | impl Walk<'_> { | |
| 167 | + | /// The next commit, and whether it passes the path filter. | |
| 168 | + | fn next(&mut self) -> Option<anyhow::Result<(ObjectId, bool)>> { | |
| 169 | + | match self { | |
| 170 | + | Walk::All(walk) => Some( | |
| 171 | + | walk.next()? | |
| 172 | + | .map(|info| (info.id, true)) | |
| 173 | + | .context("cannot walk history"), | |
| 174 | + | ), | |
| 175 | + | Walk::Path(walk) => walk.next().transpose(), | |
| 176 | + | } | |
| 177 | + | } | |
| 178 | + | } | |
| 179 | + | ||
| 180 | + | /// Newest-first walk that simplifies history for `path`. | |
| 181 | + | struct PathWalk<'repo> { | |
| 182 | + | repo: &'repo gix::Repository, | |
| 183 | + | path: Vec<u8>, | |
| 184 | + | queue: BinaryHeap<(i64, ObjectId)>, | |
| 185 | + | seen: HashSet<ObjectId>, | |
| 186 | + | oids: PathOids, | |
| 187 | + | } | |
| 188 | + | ||
| 189 | + | impl<'repo> PathWalk<'repo> { | |
| 190 | + | fn new(repo: &'repo gix::Repository, tip: ObjectId, path: &[u8]) -> anyhow::Result<Self> { | |
| 191 | + | let mut walk = PathWalk { | |
| 192 | + | repo, | |
| 193 | + | path: path.to_vec(), | |
| 194 | + | queue: BinaryHeap::new(), | |
| 195 | + | seen: HashSet::new(), | |
| 196 | + | oids: PathOids::default(), | |
| 197 | + | }; | |
| 198 | + | walk.push(tip)?; | |
| 199 | + | Ok(walk) | |
| 200 | + | } | |
| 201 | + | ||
| 202 | + | fn push(&mut self, id: ObjectId) -> anyhow::Result<()> { | |
| 203 | + | if !self.seen.insert(id) { | |
| 204 | + | return Ok(()); | |
| 205 | + | } | |
| 206 | + | // Missing parents (shallow clones) end the walk there. | |
| 207 | + | let Ok(commit) = self.repo.find_commit(id) else { | |
| 208 | + | return Ok(()); | |
| 209 | + | }; | |
| 210 | + | let time = commit.time().context("malformed commit")?.seconds; | |
| 211 | + | self.queue.push((time, id)); | |
| 212 | + | Ok(()) | |
| 213 | + | } | |
| 214 | + | ||
| 215 | + | fn next(&mut self) -> anyhow::Result<Option<(ObjectId, bool)>> { | |
| 216 | + | let Some((_, id)) = self.queue.pop() else { | |
| 217 | + | return Ok(None); | |
| 218 | + | }; | |
| 219 | + | let commit = self.repo.find_commit(id).context("commit not found")?; | |
| 220 | + | let parents: Vec<ObjectId> = commit.parent_ids().map(|p| p.detach()).collect(); | |
| 221 | + | let here = self.oids.get(self.repo, id, &self.path)?; | |
| 222 | + | let mut same = None; | |
| 223 | + | for parent in &parents { | |
| 224 | + | if self.oids.get(self.repo, *parent, &self.path)? == here { | |
| 225 | + | same = Some(*parent); | |
| 226 | + | break; | |
| 227 | + | } | |
| 228 | + | } | |
| 229 | + | let (touches, follow) = match same { | |
| 230 | + | // Identical to a parent: not interesting, and only that | |
| 231 | + | // parent's history explains the path's state. | |
| 232 | + | Some(parent) => (false, vec![parent]), | |
| 233 | + | None if parents.is_empty() => (here.is_some(), Vec::new()), | |
| 234 | + | None => (true, parents), | |
| 235 | + | }; | |
| 236 | + | for parent in follow { | |
| 237 | + | self.push(parent)?; | |
| 238 | + | } | |
| 239 | + | Ok(Some((id, touches))) | |
| 240 | + | } | |
| 241 | + | } | |
| 242 | + | ||
| 243 | + | /// What one path holds (id and mode), memoized per commit: each commit is | |
| 244 | + | /// usually asked about again as the parent of the next one. | |
| 245 | + | #[derive(Default)] | |
| 246 | + | struct PathOids { | |
| 247 | + | by_commit: HashMap<ObjectId, Option<(ObjectId, EntryKind)>>, | |
| 248 | + | } | |
| 249 | + | ||
| 250 | + | impl PathOids { | |
| 251 | + | fn get( | |
| 252 | + | &mut self, | |
| 253 | + | repo: &gix::Repository, | |
| 254 | + | commit: ObjectId, | |
| 255 | + | path: &[u8], | |
| 256 | + | ) -> anyhow::Result<Option<(ObjectId, EntryKind)>> { | |
| 257 | + | if let Some(found) = self.by_commit.get(&commit) { | |
| 258 | + | return Ok(*found); | |
| 259 | + | } | |
| 260 | + | let found = tree::entry_at(repo, commit, path)?; | |
| 261 | + | if self.by_commit.len() > 4096 { | |
| 262 | + | self.by_commit.clear(); | |
| 263 | + | } | |
| 264 | + | self.by_commit.insert(commit, found); | |
| 265 | + | Ok(found) | |
| 266 | + | } | |
| 267 | + | } | |
| @@ -1,5 +1,7 @@ | |||
| 1 | 1 | //! All repository access goes through this module; nothing else touches gix. | |
| 2 | 2 | ||
| 3 | + | pub mod diff; | |
| 4 | + | pub mod log; | |
| 3 | 5 | pub mod refs; | |
| 4 | 6 | pub mod tree; | |
| 5 | 7 | ||
| @@ -224,3 +226,102 @@ pub fn commit_count(repo: &gix::Repository, tip: ObjectId) -> anyhow::Result<u64 | |||
| 224 | 226 | } | |
| 225 | 227 | Ok(count) | |
| 226 | 228 | } | |
| 229 | + | ||
| 230 | + | /// Everything the commit page shows about a commit. | |
| 231 | + | #[derive(Debug, Clone)] | |
| 232 | + | pub struct CommitDetail { | |
| 233 | + | pub info: CommitInfo, | |
| 234 | + | /// The message after the subject line, trimmed. | |
| 235 | + | pub body: String, | |
| 236 | + | /// The full message as stored, for patches. | |
| 237 | + | pub message: String, | |
| 238 | + | pub author: Person, | |
| 239 | + | pub committer: Person, | |
| 240 | + | pub parents: Vec<ObjectId>, | |
| 241 | + | pub tree: ObjectId, | |
| 242 | + | } | |
| 243 | + | ||
| 244 | + | #[derive(Debug, Clone, PartialEq, Eq)] | |
| 245 | + | pub struct Person { | |
| 246 | + | pub name: String, | |
| 247 | + | pub email: String, | |
| 248 | + | /// Unix seconds and the UTC offset in seconds, as recorded. | |
| 249 | + | pub time: i64, | |
| 250 | + | pub offset: i32, | |
| 251 | + | } | |
| 252 | + | ||
| 253 | + | pub fn commit_detail(repo: &gix::Repository, id: ObjectId) -> anyhow::Result<CommitDetail> { | |
| 254 | + | let commit = repo.find_commit(id).context("commit not found")?; | |
| 255 | + | let decoded = commit.decode().context("malformed commit")?; | |
| 256 | + | let person = |sig: gix::actor::SignatureRef<'_>| { | |
| 257 | + | let time = sig.time().unwrap_or_default(); | |
| 258 | + | Person { | |
| 259 | + | name: sig.name.to_str_lossy().trim().to_string(), | |
| 260 | + | email: sig.email.to_str_lossy().trim().to_string(), | |
| 261 | + | time: time.seconds, | |
| 262 | + | offset: time.offset, | |
| 263 | + | } | |
| 264 | + | }; | |
| 265 | + | let message = decoded.message(); | |
| 266 | + | Ok(CommitDetail { | |
| 267 | + | info: commit_info(repo, id)?, | |
| 268 | + | body: message | |
| 269 | + | .body() | |
| 270 | + | .map(|b| b.to_str_lossy().trim().to_string()) | |
| 271 | + | .unwrap_or_default(), | |
| 272 | + | message: decoded.message.to_str_lossy().into_owned(), | |
| 273 | + | author: person(decoded.author().context("malformed author")?), | |
| 274 | + | committer: person(decoded.committer().context("malformed committer")?), | |
| 275 | + | parents: decoded.parents().collect(), | |
| 276 | + | tree: decoded.tree(), | |
| 277 | + | }) | |
| 278 | + | } | |
| 279 | + | ||
| 280 | + | /// A `.mailmap`: canonical names and emails for authors. | |
| 281 | + | #[derive(Default)] | |
| 282 | + | pub struct Mailmap(gix::mailmap::Snapshot); | |
| 283 | + | ||
| 284 | + | impl Mailmap { | |
| 285 | + | pub fn parse(data: &[u8]) -> Self { | |
| 286 | + | Mailmap(gix::mailmap::Snapshot::from_bytes(data)) | |
| 287 | + | } | |
| 288 | + | ||
| 289 | + | /// Canonical `(name, email)` for an identity. | |
| 290 | + | pub fn resolve(&self, name: &str, email: &str) -> (String, String) { | |
| 291 | + | let signature = gix::actor::SignatureRef { | |
| 292 | + | name: name.as_bytes().as_bstr(), | |
| 293 | + | email: email.as_bytes().as_bstr(), | |
| 294 | + | time: "", | |
| 295 | + | }; | |
| 296 | + | match self.0.try_resolve_ref(signature) { | |
| 297 | + | Some(found) => ( | |
| 298 | + | found | |
| 299 | + | .name | |
| 300 | + | .map_or_else(|| name.to_string(), |n| n.to_str_lossy().into_owned()), | |
| 301 | + | found | |
| 302 | + | ||
| 303 | + | .map_or_else(|| email.to_string(), |e| e.to_str_lossy().into_owned()), | |
| 304 | + | ), | |
| 305 | + | None => (name.to_string(), email.to_string()), | |
| 306 | + | } | |
| 307 | + | } | |
| 308 | + | ||
| 309 | + | pub fn weight(&self) -> usize { | |
| 310 | + | self.0 | |
| 311 | + | .iter() | |
| 312 | + | .map(|e| { | |
| 313 | + | 64 + e.old_email().len() | |
| 314 | + | + e.old_name().map_or(0, |n| n.len()) | |
| 315 | + | + e.new_email().map_or(0, |n| n.len()) | |
| 316 | + | + e.new_name().map_or(0, |n| n.len()) | |
| 317 | + | }) | |
| 318 | + | .sum() | |
| 319 | + | } | |
| 320 | + | } | |
| 321 | + | ||
| 322 | + | /// The `.mailmap` blob at the root of `commit`, if there is one. | |
| 323 | + | pub fn mailmap_blob(repo: &gix::Repository, commit: ObjectId) -> Option<ObjectId> { | |
| 324 | + | let tree = repo.find_commit(commit).ok()?.tree().ok()?; | |
| 325 | + | let entry = tree.find_entry(".mailmap")?; | |
| 326 | + | entry.mode().is_blob().then(|| entry.object_id()) | |
| 327 | + | } | |
| @@ -20,7 +20,7 @@ pub enum EntryKind { | |||
| 20 | 20 | } | |
| 21 | 21 | ||
| 22 | 22 | impl EntryKind { | |
| 23 | − | fn from_gix(kind: GixKind) -> Self { | |
| 23 | + | pub(super) fn from_gix(kind: GixKind) -> Self { | |
| 24 | 24 | match kind { | |
| 25 | 25 | GixKind::Tree => EntryKind::Tree, | |
| 26 | 26 | GixKind::Blob => EntryKind::Blob, | |
| @@ -30,6 +30,12 @@ impl EntryKind { | |||
| 30 | 30 | } | |
| 31 | 31 | } | |
| 32 | 32 | ||
| 33 | + | /// Same kind of object, ignoring the executable bit. | |
| 34 | + | pub fn same_type(self, other: EntryKind) -> bool { | |
| 35 | + | let regular = |k| matches!(k, EntryKind::Blob | EntryKind::Executable); | |
| 36 | + | self == other || (regular(self) && regular(other)) | |
| 37 | + | } | |
| 38 | + | ||
| 33 | 39 | pub fn is_blob(self) -> bool { | |
| 34 | 40 | matches!( | |
| 35 | 41 | self, | |
| @@ -84,6 +90,22 @@ pub fn lookup( | |||
| 84 | 90 | })) | |
| 85 | 91 | } | |
| 86 | 92 | ||
| 93 | + | /// Id and kind of whatever `path` names in `commit`'s tree. `None` when | |
| 94 | + | /// the path doesn't exist, or the commit itself is missing (shallow clones). | |
| 95 | + | pub fn entry_at( | |
| 96 | + | repo: &gix::Repository, | |
| 97 | + | commit: ObjectId, | |
| 98 | + | path: &[u8], | |
| 99 | + | ) -> anyhow::Result<Option<(ObjectId, EntryKind)>> { | |
| 100 | + | let Ok(commit) = repo.find_commit(commit) else { | |
| 101 | + | return Ok(None); | |
| 102 | + | }; | |
| 103 | + | let tree = commit.tree().context("commit has no tree")?; | |
| 104 | + | Ok(tree | |
| 105 | + | .lookup_entry_by_path(as_path(path))? | |
| 106 | + | .map(|entry| (entry.object_id(), EntryKind::from_gix(entry.mode().kind())))) | |
| 107 | + | } | |
| 108 | + | ||
| 87 | 109 | /// Entries of a tree: directories first, then files, each by name. | |
| 88 | 110 | pub fn list(repo: &gix::Repository, tree: ObjectId) -> anyhow::Result<Vec<TreeEntry>> { | |
| 89 | 111 | let tree = repo.find_tree(tree).context("tree not found")?; | |
| @@ -44,6 +44,7 @@ pub struct BlobPage { | |||
| 44 | 44 | /// `412 lines`, `12.4 KB`, `Rust`. | |
| 45 | 45 | pub facts: Vec<String>, | |
| 46 | 46 | pub raw_url: String, | |
| 47 | + | pub log_url: String, | |
| 47 | 48 | pub download_url: String, | |
| 48 | 49 | pub toggle: Option<ViewToggle>, | |
| 49 | 50 | pub body: Body, | |
| @@ -132,24 +133,14 @@ pub fn page(ctx: &Ctx, segments: &[Vec<u8>]) -> Response { | |||
| 132 | 133 | html: html.unwrap_or_else(|never| match never {}), | |
| 133 | 134 | } | |
| 134 | 135 | } else { | |
| 135 | − | let first_line = text.lines().next().unwrap_or_default(); | |
| 136 | − | let syntax = highlight::detect(&path_str, first_line); | |
| 137 | − | language = syntax.map(|s| s.name.clone()); | |
| 138 | − | let lines = match syntax { | |
| 139 | − | Some(syntax) if size <= ctx.config.ui.max_highlight_kb * 1024 => { | |
| 140 | − | let key = Key::Highlight { | |
| 141 | − | blob: oid, | |
| 142 | − | syntax: syntax.name.clone(), | |
| 143 | − | }; | |
| 144 | − | ctx.state | |
| 145 | − | .cache | |
| 146 | − | .lines(key, || { | |
| 147 | − | Ok::<_, std::convert::Infallible>(highlight::lines(&text, syntax)) | |
| 148 | − | }) | |
| 149 | − | .unwrap_or_else(|never| match never {}) | |
| 150 | − | } | |
| 151 | − | _ => Arc::new(highlight::plain_lines(&text)), | |
| 152 | − | }; | |
| 136 | + | let (lines, lang) = highlight::cached_lines( | |
| 137 | + | &ctx.state.cache, | |
| 138 | + | oid, | |
| 139 | + | &path_str, | |
| 140 | + | &text, | |
| 141 | + | ctx.config.ui.max_highlight_kb * 1024, | |
| 142 | + | ); | |
| 143 | + | language = lang.map(str::to_string); | |
| 153 | 144 | Body::Code { lines } | |
| 154 | 145 | } | |
| 155 | 146 | } | |
| @@ -173,6 +164,7 @@ pub fn page(ctx: &Ctx, segments: &[Vec<u8>]) -> Response { | |||
| 173 | 164 | here: Some(name), | |
| 174 | 165 | facts, | |
| 175 | 166 | download_url: format!("{raw_url}?download"), | |
| 167 | + | log_url: ctx.url("log", &rev.name, &path), | |
| 176 | 168 | raw_url, | |
| 177 | 169 | toggle, | |
| 178 | 170 | body, | |
| @@ -0,0 +1,375 @@ | |||
| 1 | + | //! `/{repo}/commit/{rev}` — message, metadata, diffstat and the unified | |
| 2 | + | //! diff against the first parent (`?context=N`, `?ws=ignore`). | |
| 3 | + | //! `/{repo}/commit/{rev}.patch` — the same as a plain-text patch. | |
| 4 | + | ||
| 5 | + | use askama::Template; | |
| 6 | + | use axum::http::{HeaderValue, StatusCode, header}; | |
| 7 | + | use axum::response::{IntoResponse, Response}; | |
| 8 | + | use gix::bstr::ByteSlice; | |
| 9 | + | ||
| 10 | + | use super::raw::RAW_CSP; | |
| 11 | + | use super::repo::{Active, Crumb, Ctx, RepoNav, set_cache_headers, short}; | |
| 12 | + | use super::{Layout, render}; | |
| 13 | + | use crate::cache::Key; | |
| 14 | + | use crate::git::diff::Status; | |
| 15 | + | use crate::git::refs::{self, Rev}; | |
| 16 | + | use crate::git::{self, CommitDetail}; | |
| 17 | + | use crate::render::diff::{self, Body, CommitDiff, FileDiff}; | |
| 18 | + | use crate::render::time; | |
| 19 | + | ||
| 20 | + | const DEFAULT_CONTEXT: u32 = 3; | |
| 21 | + | const MAX_CONTEXT: u32 = 50; | |
| 22 | + | ||
| 23 | + | pub struct Meta { | |
| 24 | + | pub label: &'static str, | |
| 25 | + | pub value: String, | |
| 26 | + | pub title: Option<String>, | |
| 27 | + | pub links: Vec<(String, String)>, | |
| 28 | + | } | |
| 29 | + | ||
| 30 | + | pub struct FileRow<'a> { | |
| 31 | + | pub file: &'a FileDiff, | |
| 32 | + | pub anchor: String, | |
| 33 | + | pub path: String, | |
| 34 | + | /// Source path of a rename or copy. | |
| 35 | + | pub from: Option<String>, | |
| 36 | + | pub status: &'static str, | |
| 37 | + | /// Single-letter status (`A`, `M`, `D`, `R`, `C`). | |
| 38 | + | pub letter: &'static str, | |
| 39 | + | /// Five blocks: `add`, `del` or `none`. | |
| 40 | + | pub bar: [&'static str; 5], | |
| 41 | + | /// The file at this commit (or its parent, for deletions). | |
| 42 | + | pub url: Option<String>, | |
| 43 | + | } | |
| 44 | + | ||
| 45 | + | pub struct Toggle { | |
| 46 | + | pub label: &'static str, | |
| 47 | + | pub url: String, | |
| 48 | + | pub active: bool, | |
| 49 | + | } | |
| 50 | + | ||
| 51 | + | #[derive(Template)] | |
| 52 | + | #[template(path = "commit.html")] | |
| 53 | + | pub struct CommitPage<'a> { | |
| 54 | + | pub layout: Layout, | |
| 55 | + | pub nav: RepoNav, | |
| 56 | + | pub crumbs: Vec<Crumb>, | |
| 57 | + | pub here: Option<String>, | |
| 58 | + | pub subject: String, | |
| 59 | + | pub body: String, | |
| 60 | + | pub meta: Vec<Meta>, | |
| 61 | + | pub added: u64, | |
| 62 | + | pub removed: u64, | |
| 63 | + | pub files: Vec<FileRow<'a>>, | |
| 64 | + | pub diff: &'a CommitDiff, | |
| 65 | + | pub patch_url: String, | |
| 66 | + | pub tree_url: String, | |
| 67 | + | pub ws_toggle: Vec<Toggle>, | |
| 68 | + | pub context_less: Option<String>, | |
| 69 | + | pub context_more: Option<String>, | |
| 70 | + | pub context: u32, | |
| 71 | + | } | |
| 72 | + | ||
| 73 | + | pub fn page(ctx: &Ctx, segments: &[Vec<u8>]) -> Response { | |
| 74 | + | let Some((rev, patch)) = resolve(ctx, segments) else { | |
| 75 | + | return ctx.not_found("UNKNOWN REVISION"); | |
| 76 | + | }; | |
| 77 | + | let context = ctx | |
| 78 | + | .query | |
| 79 | + | .context | |
| 80 | + | .as_deref() | |
| 81 | + | .and_then(|c| c.parse::<u32>().ok()) | |
| 82 | + | .map_or(DEFAULT_CONTEXT, |c| c.min(MAX_CONTEXT)); | |
| 83 | + | let ignore_ws = ctx.query.ws.as_deref() == Some("ignore"); | |
| 84 | + | let etag = if patch { | |
| 85 | + | ctx.etag(("patch", rev.commit)) | |
| 86 | + | } else { | |
| 87 | + | ctx.etag(("commit", rev.commit, context, ignore_ws)) | |
| 88 | + | }; | |
| 89 | + | let immutable = rev.is_immutable(); | |
| 90 | + | if let Some(res) = ctx.not_modified(&etag, immutable) { | |
| 91 | + | return res; | |
| 92 | + | } | |
| 93 | + | let detail = match git::commit_detail(&ctx.git, rev.commit) { | |
| 94 | + | Ok(detail) => detail, | |
| 95 | + | Err(e) => return ctx.internal_error(e), | |
| 96 | + | }; | |
| 97 | + | let changes = match git::diff::changes(&ctx.git, rev.commit) { | |
| 98 | + | Ok(changes) => changes, | |
| 99 | + | Err(e) => return ctx.internal_error(e), | |
| 100 | + | }; | |
| 101 | + | if patch { | |
| 102 | + | return match diff::patch(&ctx.git, &detail, &changes) { | |
| 103 | + | Ok(text) => { | |
| 104 | + | let mut res = text.into_response(); | |
| 105 | + | let headers = res.headers_mut(); | |
| 106 | + | headers.insert( | |
| 107 | + | header::CONTENT_TYPE, | |
| 108 | + | HeaderValue::from_static("text/plain; charset=utf-8"), | |
| 109 | + | ); | |
| 110 | + | headers.insert( | |
| 111 | + | header::CONTENT_SECURITY_POLICY, | |
| 112 | + | HeaderValue::from_static(RAW_CSP), | |
| 113 | + | ); | |
| 114 | + | headers.insert( | |
| 115 | + | header::X_CONTENT_TYPE_OPTIONS, | |
| 116 | + | HeaderValue::from_static("nosniff"), | |
| 117 | + | ); | |
| 118 | + | set_cache_headers(&mut res, &etag, immutable); | |
| 119 | + | res | |
| 120 | + | } | |
| 121 | + | Err(e) => ctx.internal_error(e), | |
| 122 | + | }; | |
| 123 | + | } | |
| 124 | + | ||
| 125 | + | let options = diff::Options { | |
| 126 | + | context, | |
| 127 | + | ignore_ws, | |
| 128 | + | max_files: ctx.config.limits.diff_files, | |
| 129 | + | max_lines: ctx.config.limits.diff_lines, | |
| 130 | + | max_file_lines: ctx.config.limits.diff_file_lines, | |
| 131 | + | max_blob_bytes: ctx.config.ui.max_blob_kb * 1024, | |
| 132 | + | max_highlight_bytes: ctx.config.ui.max_highlight_kb * 1024, | |
| 133 | + | }; | |
| 134 | + | let key = Key::Diff { | |
| 135 | + | commit: rev.commit, | |
| 136 | + | options, | |
| 137 | + | }; | |
| 138 | + | let built = ctx.state.cache.diff(key, || { | |
| 139 | + | diff::build(&ctx.git, &ctx.state.cache, &changes, &options) | |
| 140 | + | }); | |
| 141 | + | let built = match built { | |
| 142 | + | Ok(built) => built, | |
| 143 | + | Err(e) => return ctx.internal_error(e), | |
| 144 | + | }; | |
| 145 | + | ||
| 146 | + | let mut page = commit_page(ctx, &rev, &detail, &built, changes.parent); | |
| 147 | + | page.context = context; | |
| 148 | + | let base = ctx.commit_url(&rev.commit); | |
| 149 | + | let url = |context: u32, ignore_ws: bool| { | |
| 150 | + | let mut params = Vec::new(); | |
| 151 | + | if context != DEFAULT_CONTEXT { | |
| 152 | + | params.push(format!("context={context}")); | |
| 153 | + | } | |
| 154 | + | if ignore_ws { | |
| 155 | + | params.push("ws=ignore".to_string()); | |
| 156 | + | } | |
| 157 | + | if params.is_empty() { | |
| 158 | + | base.clone() | |
| 159 | + | } else { | |
| 160 | + | format!("{base}?{}", params.join("&")) | |
| 161 | + | } | |
| 162 | + | }; | |
| 163 | + | page.ws_toggle = vec![ | |
| 164 | + | Toggle { | |
| 165 | + | label: "Whitespace", | |
| 166 | + | url: url(context, false), | |
| 167 | + | active: !ignore_ws, | |
| 168 | + | }, | |
| 169 | + | Toggle { | |
| 170 | + | label: "Ignore", | |
| 171 | + | url: url(context, true), | |
| 172 | + | active: ignore_ws, | |
| 173 | + | }, | |
| 174 | + | ]; | |
| 175 | + | page.context_less = (context > 0).then(|| url(context.saturating_sub(3), ignore_ws)); | |
| 176 | + | page.context_more = | |
| 177 | + | (context < MAX_CONTEXT).then(|| url((context + 3).min(MAX_CONTEXT), ignore_ws)); | |
| 178 | + | ||
| 179 | + | let mut res = render(StatusCode::OK, &page); | |
| 180 | + | set_cache_headers(&mut res, &etag, immutable); | |
| 181 | + | res | |
| 182 | + | } | |
| 183 | + | ||
| 184 | + | /// The revision and whether a patch was asked for. `{rev}.patch` is a | |
| 185 | + | /// patch unless a ref with that literal name exists. | |
| 186 | + | fn resolve(ctx: &Ctx, segments: &[Vec<u8>]) -> Option<(Rev, bool)> { | |
| 187 | + | let name = std::str::from_utf8(&segments.join(&b'/')).ok()?.to_string(); | |
| 188 | + | if name.is_empty() { | |
| 189 | + | return None; | |
| 190 | + | } | |
| 191 | + | if let Some(stem) = name.strip_suffix(".patch") | |
| 192 | + | && let Some(rev) = refs::resolve(&ctx.git, stem) | |
| 193 | + | && refs::resolve(&ctx.git, &name).is_none() | |
| 194 | + | { | |
| 195 | + | return Some((rev, true)); | |
| 196 | + | } | |
| 197 | + | refs::resolve(&ctx.git, &name).map(|rev| (rev, false)) | |
| 198 | + | } | |
| 199 | + | ||
| 200 | + | fn commit_page<'a>( | |
| 201 | + | ctx: &Ctx, | |
| 202 | + | rev: &Rev, | |
| 203 | + | detail: &CommitDetail, | |
| 204 | + | built: &'a CommitDiff, | |
| 205 | + | parent: Option<git::ObjectId>, | |
| 206 | + | ) -> CommitPage<'a> { | |
| 207 | + | let mailmap = ctx.mailmap(); | |
| 208 | + | let id = detail.info.id.to_string(); | |
| 209 | + | let (author, author_email) = mailmap.resolve(&detail.author.name, &detail.author.email); | |
| 210 | + | let (committer, committer_email) = | |
| 211 | + | mailmap.resolve(&detail.committer.name, &detail.committer.email); | |
| 212 | + | ||
| 213 | + | let mut meta = vec![Meta { | |
| 214 | + | label: "Author", | |
| 215 | + | value: author.clone(), | |
| 216 | + | title: Some(author_email.clone()), | |
| 217 | + | links: Vec::new(), | |
| 218 | + | }]; | |
| 219 | + | meta.push(Meta { | |
| 220 | + | label: "Date", | |
| 221 | + | value: time::local(detail.author.time, detail.author.offset), | |
| 222 | + | title: Some(time::iso(detail.author.time)), | |
| 223 | + | links: Vec::new(), | |
| 224 | + | }); | |
| 225 | + | if (&committer, &committer_email) != (&author, &author_email) | |
| 226 | + | || detail.committer.time != detail.author.time | |
| 227 | + | { | |
| 228 | + | meta.push(Meta { | |
| 229 | + | label: "Committer", | |
| 230 | + | value: format!( | |
| 231 | + | "{committer} · {}", | |
| 232 | + | time::local(detail.committer.time, detail.committer.offset) | |
| 233 | + | ), | |
| 234 | + | title: Some(committer_email), | |
| 235 | + | links: Vec::new(), | |
| 236 | + | }); | |
| 237 | + | } | |
| 238 | + | meta.push(Meta { | |
| 239 | + | label: if detail.parents.len() == 1 { | |
| 240 | + | "Parent" | |
| 241 | + | } else { | |
| 242 | + | "Parents" | |
| 243 | + | }, | |
| 244 | + | value: if detail.parents.is_empty() { | |
| 245 | + | "None".into() | |
| 246 | + | } else { | |
| 247 | + | String::new() | |
| 248 | + | }, | |
| 249 | + | title: None, | |
| 250 | + | links: detail | |
| 251 | + | .parents | |
| 252 | + | .iter() | |
| 253 | + | .map(|p| (short(&p.to_string()), ctx.commit_url(p))) | |
| 254 | + | .collect(), | |
| 255 | + | }); | |
| 256 | + | meta.push(Meta { | |
| 257 | + | label: "Commit", | |
| 258 | + | value: id.clone(), | |
| 259 | + | title: None, | |
| 260 | + | links: Vec::new(), | |
| 261 | + | }); | |
| 262 | + | ||
| 263 | + | let tree_url = ctx.url("tree", &id, b""); | |
| 264 | + | let parent_id = parent.map(|p| p.to_string()); | |
| 265 | + | let files = built | |
| 266 | + | .files | |
| 267 | + | .iter() | |
| 268 | + | .enumerate() | |
| 269 | + | .map(|(i, file)| { | |
| 270 | + | let change = &file.change; | |
| 271 | + | let path = change.path().to_str_lossy().into_owned(); | |
| 272 | + | let url = match change.status { | |
| 273 | + | Status::Deleted => parent_id | |
| 274 | + | .as_ref() | |
| 275 | + | .map(|p| ctx.url("blob", p, change.path())), | |
| 276 | + | _ => Some(ctx.url("blob", &id, change.path())), | |
| 277 | + | }; | |
| 278 | + | let from = match change.status { | |
| 279 | + | Status::Renamed | Status::Copied => change | |
| 280 | + | .old | |
| 281 | + | .as_ref() | |
| 282 | + | .map(|o| o.path.to_str_lossy().into_owned()), | |
| 283 | + | _ => None, | |
| 284 | + | }; | |
| 285 | + | let (status, letter) = match change.status { | |
| 286 | + | Status::Added => ("Added", "A"), | |
| 287 | + | Status::Deleted => ("Deleted", "D"), | |
| 288 | + | Status::Modified => ("Modified", "M"), | |
| 289 | + | Status::Renamed => ("Renamed", "R"), | |
| 290 | + | Status::Copied => ("Copied", "C"), | |
| 291 | + | }; | |
| 292 | + | // Submodules and binaries have no line counts but still have | |
| 293 | + | // a URL; only blobs get one. | |
| 294 | + | let url = url.filter(|_| !matches!(file.body, Body::Submodule { .. })); | |
| 295 | + | FileRow { | |
| 296 | + | file, | |
| 297 | + | anchor: format!("f{}", i + 1), | |
| 298 | + | path, | |
| 299 | + | from, | |
| 300 | + | status, | |
| 301 | + | letter, | |
| 302 | + | bar: bar(file.stat), | |
| 303 | + | url, | |
| 304 | + | } | |
| 305 | + | }) | |
| 306 | + | .collect(); | |
| 307 | + | ||
| 308 | + | CommitPage { | |
| 309 | + | layout: Layout::new( | |
| 310 | + | ctx.state, | |
| 311 | + | ctx.config, | |
| 312 | + | Some(&format!("{} · {}", detail.info.subject, ctx.repo.name)), | |
| 313 | + | ), | |
| 314 | + | nav: ctx.nav(Active::Log, Some(rev), |name| ctx.url("log", name, b"")), | |
| 315 | + | crumbs: vec![Crumb { | |
| 316 | + | name: ctx.repo.name.clone(), | |
| 317 | + | url: ctx.repo_url(), | |
| 318 | + | }], | |
| 319 | + | here: Some(short(&id)), | |
| 320 | + | subject: detail.info.subject.clone(), | |
| 321 | + | body: detail.body.clone(), | |
| 322 | + | meta, | |
| 323 | + | added: built.added, | |
| 324 | + | removed: built.removed, | |
| 325 | + | files, | |
| 326 | + | diff: built, | |
| 327 | + | patch_url: format!("{}.patch", ctx.commit_url(&detail.info.id)), | |
| 328 | + | tree_url, | |
| 329 | + | ws_toggle: Vec::new(), | |
| 330 | + | context_less: None, | |
| 331 | + | context_more: None, | |
| 332 | + | context: DEFAULT_CONTEXT, | |
| 333 | + | } | |
| 334 | + | } | |
| 335 | + | ||
| 336 | + | /// Five blocks split between additions and removals; fewer than five | |
| 337 | + | /// changed lines leave the rest empty. | |
| 338 | + | fn bar(stat: Option<(u32, u32)>) -> [&'static str; 5] { | |
| 339 | + | let mut out = ["none"; 5]; | |
| 340 | + | let Some((added, removed)) = stat else { | |
| 341 | + | return out; | |
| 342 | + | }; | |
| 343 | + | let total = added + removed; | |
| 344 | + | if total == 0 { | |
| 345 | + | return out; | |
| 346 | + | } | |
| 347 | + | let filled = total.min(5); | |
| 348 | + | let mut adds = ((filled * added) as f64 / total as f64).round() as u32; | |
| 349 | + | // Keep at least one block for each side that changed. | |
| 350 | + | if added > 0 && adds == 0 { | |
| 351 | + | adds = 1; | |
| 352 | + | } | |
| 353 | + | if removed > 0 && adds == filled { | |
| 354 | + | adds = filled - 1; | |
| 355 | + | } | |
| 356 | + | for (i, slot) in out.iter_mut().enumerate().take(filled as usize) { | |
| 357 | + | *slot = if (i as u32) < adds { "add" } else { "del" }; | |
| 358 | + | } | |
| 359 | + | out | |
| 360 | + | } | |
| 361 | + | ||
| 362 | + | #[cfg(test)] | |
| 363 | + | mod tests { | |
| 364 | + | use super::bar; | |
| 365 | + | ||
| 366 | + | #[test] | |
| 367 | + | fn diffstat_bars() { | |
| 368 | + | assert_eq!(bar(None), ["none"; 5]); | |
| 369 | + | assert_eq!(bar(Some((0, 0))), ["none"; 5]); | |
| 370 | + | assert_eq!(bar(Some((2, 0))), ["add", "add", "none", "none", "none"]); | |
| 371 | + | assert_eq!(bar(Some((100, 1))), ["add", "add", "add", "add", "del"]); | |
| 372 | + | assert_eq!(bar(Some((1, 100))), ["add", "del", "del", "del", "del"]); | |
| 373 | + | assert_eq!(bar(Some((30, 20))), ["add", "add", "add", "del", "del"]); | |
| 374 | + | } | |
| 375 | + | } | |
| @@ -0,0 +1,166 @@ | |||
| 1 | + | //! `/{repo}/log/{rev}[/{path…}]` — history, newest first, grouped by day. | |
| 2 | + | //! `?after=<oid>` pages, `?q=` filters by message and author. | |
| 3 | + | ||
| 4 | + | use askama::Template; | |
| 5 | + | use axum::http::StatusCode; | |
| 6 | + | use axum::response::Response; | |
| 7 | + | use gix::bstr::ByteSlice; | |
| 8 | + | ||
| 9 | + | use super::repo::{Active, Crumb, Ctx, RepoNav, crumbs, set_cache_headers, short}; | |
| 10 | + | use super::{Layout, render}; | |
| 11 | + | use crate::cache::Key; | |
| 12 | + | use crate::git::ObjectId; | |
| 13 | + | use crate::git::log::{self, LogError, LogQuery, Newer}; | |
| 14 | + | use crate::render::{encode_path, time}; | |
| 15 | + | ||
| 16 | + | pub struct LogRow { | |
| 17 | + | pub subject: String, | |
| 18 | + | pub url: String, | |
| 19 | + | pub author: String, | |
| 20 | + | pub age: String, | |
| 21 | + | pub iso: String, | |
| 22 | + | pub id: String, | |
| 23 | + | } | |
| 24 | + | ||
| 25 | + | pub struct Day { | |
| 26 | + | pub label: String, | |
| 27 | + | pub rows: Vec<LogRow>, | |
| 28 | + | } | |
| 29 | + | ||
| 30 | + | #[derive(Template)] | |
| 31 | + | #[template(path = "log.html")] | |
| 32 | + | pub struct LogPage { | |
| 33 | + | pub layout: Layout, | |
| 34 | + | pub nav: RepoNav, | |
| 35 | + | pub crumbs: Vec<Crumb>, | |
| 36 | + | pub here: Option<String>, | |
| 37 | + | pub days: Vec<Day>, | |
| 38 | + | /// The form posts here (the log URL without query). | |
| 39 | + | pub action: String, | |
| 40 | + | pub q: String, | |
| 41 | + | pub newer_url: Option<String>, | |
| 42 | + | /// `Newer`, or `Newest` when the link jumps back to the first page. | |
| 43 | + | pub newer_label: &'static str, | |
| 44 | + | pub older_url: Option<String>, | |
| 45 | + | pub limit_reached: bool, | |
| 46 | + | pub scan_limit: usize, | |
| 47 | + | } | |
| 48 | + | ||
| 49 | + | pub fn page(ctx: &Ctx, segments: &[Vec<u8>]) -> Response { | |
| 50 | + | let (rev, path) = match ctx.resolve(segments) { | |
| 51 | + | Ok(found) => found, | |
| 52 | + | Err(res) => return *res, | |
| 53 | + | }; | |
| 54 | + | let after = match ctx.query.after.as_deref().filter(|a| !a.is_empty()) { | |
| 55 | + | None => None, | |
| 56 | + | Some(hex) => match ObjectId::from_hex(hex.as_bytes()) { | |
| 57 | + | Ok(id) => Some(id), | |
| 58 | + | Err(_) => return ctx.not_found("INVALID CURSOR"), | |
| 59 | + | }, | |
| 60 | + | }; | |
| 61 | + | let grep = ctx | |
| 62 | + | .query | |
| 63 | + | .q | |
| 64 | + | .as_deref() | |
| 65 | + | .map(str::trim) | |
| 66 | + | .filter(|q| !q.is_empty()) | |
| 67 | + | .map(str::to_string); | |
| 68 | + | let query = LogQuery { | |
| 69 | + | path: path.clone(), | |
| 70 | + | grep, | |
| 71 | + | after, | |
| 72 | + | page: ctx.config.ui.log_page, | |
| 73 | + | scan_limit: ctx.config.limits.log_scan, | |
| 74 | + | }; | |
| 75 | + | let etag = ctx.etag(("log", rev.commit, &query)); | |
| 76 | + | let immutable = rev.is_immutable(); | |
| 77 | + | if let Some(res) = ctx.not_modified(&etag, immutable) { | |
| 78 | + | return res; | |
| 79 | + | } | |
| 80 | + | let key = Key::Log { | |
| 81 | + | tip: rev.commit, | |
| 82 | + | query: query.clone(), | |
| 83 | + | }; | |
| 84 | + | let result = ctx | |
| 85 | + | .state | |
| 86 | + | .cache | |
| 87 | + | .log(key, || log::page(&ctx.git, rev.commit, &query)); | |
| 88 | + | let found = match result { | |
| 89 | + | Ok(found) => found, | |
| 90 | + | Err(LogError::UnknownCursor) => return ctx.not_found("COMMIT NOT IN THIS HISTORY"), | |
| 91 | + | Err(LogError::Git(e)) => return ctx.internal_error(e), | |
| 92 | + | }; | |
| 93 | + | ||
| 94 | + | let action = ctx.url("log", &rev.name, &path); | |
| 95 | + | let link = |after: Option<ObjectId>| { | |
| 96 | + | let mut params = Vec::new(); | |
| 97 | + | if let Some(q) = &query.grep { | |
| 98 | + | params.push(format!("q={}", encode_path(q))); | |
| 99 | + | } | |
| 100 | + | if let Some(id) = after { | |
| 101 | + | params.push(format!("after={id}")); | |
| 102 | + | } | |
| 103 | + | if params.is_empty() { | |
| 104 | + | action.clone() | |
| 105 | + | } else { | |
| 106 | + | format!("{action}?{}", params.join("&")) | |
| 107 | + | } | |
| 108 | + | }; | |
| 109 | + | let (newer_url, newer_label) = match found.newer { | |
| 110 | + | Newer::None => (None, "Newer"), | |
| 111 | + | Newer::First => (Some(link(None)), "Newest"), | |
| 112 | + | Newer::After(id) => (Some(link(Some(id))), "Newer"), | |
| 113 | + | }; | |
| 114 | + | ||
| 115 | + | let mailmap = ctx.mailmap(); | |
| 116 | + | let now = time::now(); | |
| 117 | + | let mut days: Vec<Day> = Vec::new(); | |
| 118 | + | for commit in &found.commits { | |
| 119 | + | let label = time::day(commit.committer_time); | |
| 120 | + | let id = commit.id.to_string(); | |
| 121 | + | let row = LogRow { | |
| 122 | + | subject: if commit.subject.is_empty() { | |
| 123 | + | "(no message)".into() | |
| 124 | + | } else { | |
| 125 | + | commit.subject.clone() | |
| 126 | + | }, | |
| 127 | + | url: ctx.commit_url(&commit.id), | |
| 128 | + | author: mailmap.resolve(&commit.author_name, &commit.author_email).0, | |
| 129 | + | age: time::relative(commit.committer_time, now), | |
| 130 | + | iso: time::iso(commit.committer_time), | |
| 131 | + | id: short(&id), | |
| 132 | + | }; | |
| 133 | + | match days.last_mut() { | |
| 134 | + | Some(day) if day.label == label => day.rows.push(row), | |
| 135 | + | _ => days.push(Day { | |
| 136 | + | label, | |
| 137 | + | rows: vec![row], | |
| 138 | + | }), | |
| 139 | + | } | |
| 140 | + | } | |
| 141 | + | ||
| 142 | + | let (crumbs, last) = crumbs(ctx, &rev.name, &path); | |
| 143 | + | let title = if path.is_empty() { | |
| 144 | + | format!("Log · {}", ctx.repo.name) | |
| 145 | + | } else { | |
| 146 | + | format!("History of {} · {}", path.to_str_lossy(), ctx.repo.name) | |
| 147 | + | }; | |
| 148 | + | let older_url = found.older.map(|id| link(Some(id))); | |
| 149 | + | let page = LogPage { | |
| 150 | + | layout: Layout::new(ctx.state, ctx.config, Some(&title)), | |
| 151 | + | nav: ctx.nav(Active::Log, Some(&rev), |name| ctx.url("log", name, &path)), | |
| 152 | + | crumbs, | |
| 153 | + | here: last, | |
| 154 | + | days, | |
| 155 | + | action, | |
| 156 | + | q: query.grep.clone().unwrap_or_default(), | |
| 157 | + | newer_url, | |
| 158 | + | newer_label, | |
| 159 | + | older_url, | |
| 160 | + | limit_reached: found.limit_reached, | |
| 161 | + | scan_limit: query.scan_limit, | |
| 162 | + | }; | |
| 163 | + | let mut res = render(StatusCode::OK, &page); | |
| 164 | + | set_cache_headers(&mut res, &etag, immutable); | |
| 165 | + | res | |
| 166 | + | } | |
| @@ -1,8 +1,10 @@ | |||
| 1 | 1 | //! Request handlers. Each page is an askama template that embeds a [`Layout`]. | |
| 2 | 2 | ||
| 3 | 3 | pub mod blob; | |
| 4 | + | pub mod commit; | |
| 4 | 5 | pub mod error; | |
| 5 | 6 | pub mod index; | |
| 7 | + | pub mod log; | |
| 6 | 8 | pub mod raw; | |
| 7 | 9 | pub mod refs; | |
| 8 | 10 | pub mod repo; | |
| @@ -11,11 +11,11 @@ use axum::http::{HeaderValue, StatusCode, header}; | |||
| 11 | 11 | use axum::response::{IntoResponse, Redirect, Response}; | |
| 12 | 12 | use serde::Deserialize; | |
| 13 | 13 | ||
| 14 | − | use super::{blob, error, raw, refs, summary, tree}; | |
| 14 | + | use super::{blob, commit, error, log, raw, refs, summary, tree}; | |
| 15 | 15 | use crate::cache::Key; | |
| 16 | 16 | use crate::config::Config; | |
| 17 | 17 | use crate::git::refs::{RefKind, RefList, Rev}; | |
| 18 | − | use crate::git::{self, Handle}; | |
| 18 | + | use crate::git::{self, Handle, Mailmap, ObjectId}; | |
| 19 | 19 | use crate::index::Repo; | |
| 20 | 20 | use crate::render::{encode_bytes, encode_path}; | |
| 21 | 21 | use crate::state::AppState; | |
| @@ -29,6 +29,14 @@ pub struct PageQuery { | |||
| 29 | 29 | pub view: Option<String>, | |
| 30 | 30 | /// Raw: serve as an attachment. | |
| 31 | 31 | pub download: Option<String>, | |
| 32 | + | /// Log: cursor (the last commit of the previous page). | |
| 33 | + | pub after: Option<String>, | |
| 34 | + | /// Log: message/author filter. | |
| 35 | + | pub q: Option<String>, | |
| 36 | + | /// Commit: lines of context. | |
| 37 | + | pub context: Option<String>, | |
| 38 | + | /// Commit: `ignore` compares lines ignoring whitespace. | |
| 39 | + | pub ws: Option<String>, | |
| 32 | 40 | } | |
| 33 | 41 | ||
| 34 | 42 | pub struct RepoRequest { | |
| @@ -66,6 +74,8 @@ pub fn handle(state: &AppState, req: RepoRequest) -> Response { | |||
| 66 | 74 | b"tree" => tree::page(&ctx, rest), | |
| 67 | 75 | b"blob" => blob::page(&ctx, rest), | |
| 68 | 76 | b"raw" => raw::serve(&ctx, rest), | |
| 77 | + | b"log" => log::page(&ctx, rest), | |
| 78 | + | b"commit" => commit::page(&ctx, rest), | |
| 69 | 79 | b"refs" if rest.is_empty() => refs::page(&ctx), | |
| 70 | 80 | _ => ctx.not_found("PAGE NOT FOUND"), | |
| 71 | 81 | } | |
| @@ -91,6 +101,28 @@ impl Ctx<'_> { | |||
| 91 | 101 | url | |
| 92 | 102 | } | |
| 93 | 103 | ||
| 104 | + | pub fn commit_url(&self, id: &ObjectId) -> String { | |
| 105 | + | format!("{}/commit/{id}", self.repo_url()) | |
| 106 | + | } | |
| 107 | + | ||
| 108 | + | /// The `.mailmap` on the default branch (empty when there is none). | |
| 109 | + | pub fn mailmap(&self) -> Arc<Mailmap> { | |
| 110 | + | let blob = self | |
| 111 | + | .repo | |
| 112 | + | .meta | |
| 113 | + | .head | |
| 114 | + | .and_then(|head| git::mailmap_blob(&self.git, head)); | |
| 115 | + | let Some(blob) = blob else { | |
| 116 | + | return Arc::default(); | |
| 117 | + | }; | |
| 118 | + | self.state | |
| 119 | + | .cache | |
| 120 | + | .mailmap(blob, || { | |
| 121 | + | git::tree::blob(&self.git, blob).map(|data| Mailmap::parse(&data)) | |
| 122 | + | }) | |
| 123 | + | .unwrap_or_default() | |
| 124 | + | } | |
| 125 | + | ||
| 94 | 126 | pub fn not_found(&self, message: &str) -> Response { | |
| 95 | 127 | error::page(self.state, StatusCode::NOT_FOUND, message) | |
| 96 | 128 | } | |
| @@ -171,9 +203,15 @@ impl Ctx<'_> { | |||
| 171 | 203 | current: Option<&Rev>, | |
| 172 | 204 | picker: impl Fn(&str) -> String, | |
| 173 | 205 | ) -> RepoNav { | |
| 174 | − | let tree_url = match current { | |
| 175 | − | Some(rev) => self.url("tree", &rev.name, b""), | |
| 176 | − | None => format!("{}/tree", self.repo_url()), | |
| 206 | + | let (tree_url, log_url) = match current { | |
| 207 | + | Some(rev) => ( | |
| 208 | + | self.url("tree", &rev.name, b""), | |
| 209 | + | self.url("log", &rev.name, b""), | |
| 210 | + | ), | |
| 211 | + | None => ( | |
| 212 | + | format!("{}/tree", self.repo_url()), | |
| 213 | + | format!("{}/log", self.repo_url()), | |
| 214 | + | ), | |
| 177 | 215 | }; | |
| 178 | 216 | let picker = current.and_then(|rev| { | |
| 179 | 217 | let refs = self.refs().ok()?; | |
| @@ -206,6 +244,7 @@ impl Ctx<'_> { | |||
| 206 | 244 | name: self.repo.name.clone(), | |
| 207 | 245 | url: self.repo_url(), | |
| 208 | 246 | tree_url, | |
| 247 | + | log_url, | |
| 209 | 248 | refs_url: format!("{}/refs", self.repo_url()), | |
| 210 | 249 | active: active.as_str(), | |
| 211 | 250 | picker, | |
| @@ -240,6 +279,7 @@ pub fn short(oid: &str) -> String { | |||
| 240 | 279 | pub enum Active { | |
| 241 | 280 | Summary, | |
| 242 | 281 | Tree, | |
| 282 | + | Log, | |
| 243 | 283 | Refs, | |
| 244 | 284 | } | |
| 245 | 285 | ||
| @@ -248,6 +288,7 @@ impl Active { | |||
| 248 | 288 | match self { | |
| 249 | 289 | Active::Summary => "summary", | |
| 250 | 290 | Active::Tree => "tree", | |
| 291 | + | Active::Log => "log", | |
| 251 | 292 | Active::Refs => "refs", | |
| 252 | 293 | } | |
| 253 | 294 | } | |
| @@ -257,8 +298,9 @@ pub struct RepoNav { | |||
| 257 | 298 | pub name: String, | |
| 258 | 299 | pub url: String, | |
| 259 | 300 | pub tree_url: String, | |
| 301 | + | pub log_url: String, | |
| 260 | 302 | pub refs_url: String, | |
| 261 | − | /// `summary`, `tree` or `refs`. | |
| 303 | + | /// `summary`, `tree`, `log` or `refs`. | |
| 262 | 304 | pub active: &'static str, | |
| 263 | 305 | pub picker: Option<Picker>, | |
| 264 | 306 | } | |
| @@ -17,6 +17,7 @@ pub struct Stat { | |||
| 17 | 17 | pub label: &'static str, | |
| 18 | 18 | pub value: String, | |
| 19 | 19 | pub title: Option<String>, | |
| 20 | + | pub url: Option<String>, | |
| 20 | 21 | } | |
| 21 | 22 | ||
| 22 | 23 | pub struct CloneUrl { | |
| @@ -117,16 +118,19 @@ fn stats(ctx: &Ctx, commits: Option<u64>) -> Vec<Stat> { | |||
| 117 | 118 | label: "Commits", | |
| 118 | 119 | value: commits.unwrap_or(0).to_string(), | |
| 119 | 120 | title: None, | |
| 121 | + | url: commits.map(|_| format!("{}/log", ctx.repo_url())), | |
| 120 | 122 | }, | |
| 121 | 123 | Stat { | |
| 122 | 124 | label: "Branches", | |
| 123 | 125 | value: meta.branches.to_string(), | |
| 124 | 126 | title: None, | |
| 127 | + | url: None, | |
| 125 | 128 | }, | |
| 126 | 129 | Stat { | |
| 127 | 130 | label: "Tags", | |
| 128 | 131 | value: meta.tags.to_string(), | |
| 129 | 132 | title: None, | |
| 133 | + | url: None, | |
| 130 | 134 | }, | |
| 131 | 135 | ]; | |
| 132 | 136 | if let Some(t) = meta.last_activity { | |
| @@ -134,6 +138,7 @@ fn stats(ctx: &Ctx, commits: Option<u64>) -> Vec<Stat> { | |||
| 134 | 138 | label: "Updated", | |
| 135 | 139 | value: time::relative(t, time::now()), | |
| 136 | 140 | title: Some(time::iso(t)), | |
| 141 | + | url: None, | |
| 137 | 142 | }); | |
| 138 | 143 | } | |
| 139 | 144 | if let Some(owner) = &meta.owner { | |
| @@ -141,6 +146,7 @@ fn stats(ctx: &Ctx, commits: Option<u64>) -> Vec<Stat> { | |||
| 141 | 146 | label: "Owner", | |
| 142 | 147 | value: owner.clone(), | |
| 143 | 148 | title: None, | |
| 149 | + | url: None, | |
| 144 | 150 | }); | |
| 145 | 151 | } | |
| 146 | 152 | stats | |
| @@ -150,10 +156,13 @@ fn latest(ctx: &Ctx, info: &CommitInfo) -> Latest { | |||
| 150 | 156 | let id = info.id.to_string(); | |
| 151 | 157 | Latest { | |
| 152 | 158 | subject: info.subject.clone(), | |
| 153 | − | author: info.author_name.clone(), | |
| 159 | + | author: ctx | |
| 160 | + | .mailmap() | |
| 161 | + | .resolve(&info.author_name, &info.author_email) | |
| 162 | + | .0, | |
| 154 | 163 | age: time::relative(info.committer_time, time::now()), | |
| 155 | 164 | iso: time::iso(info.committer_time), | |
| 156 | − | url: ctx.url("tree", &id, b""), | |
| 165 | + | url: ctx.commit_url(&info.id), | |
| 157 | 166 | id: short(&id), | |
| 158 | 167 | } | |
| 159 | 168 | } | |
| @@ -41,6 +41,7 @@ pub struct TreePage { | |||
| 41 | 41 | pub rows: Vec<TreeRow>, | |
| 42 | 42 | pub entries: usize, | |
| 43 | 43 | pub tree_id: String, | |
| 44 | + | pub log_url: String, | |
| 44 | 45 | pub readme: Option<Readme>, | |
| 45 | 46 | } | |
| 46 | 47 | ||
| @@ -103,6 +104,7 @@ pub fn page(ctx: &Ctx, segments: &[Vec<u8>]) -> Response { | |||
| 103 | 104 | rows, | |
| 104 | 105 | entries: entries.len(), | |
| 105 | 106 | tree_id: short(&tree_id.to_string()), | |
| 107 | + | log_url: ctx.url("log", &rev.name, &path), | |
| 106 | 108 | readme: dir_readme(ctx, &rev, &path, &entries), | |
| 107 | 109 | }; | |
| 108 | 110 | let mut res = render(StatusCode::OK, &page); | |
| @@ -0,0 +1,484 @@ | |||
| 1 | + | //! Commit diffs: the cached, render-ready model behind the commit page, | |
| 2 | + | //! and plain-text patches. | |
| 3 | + | ||
| 4 | + | use std::fmt::Write; | |
| 5 | + | use std::sync::Arc; | |
| 6 | + | ||
| 7 | + | use crate::cache::Cache; | |
| 8 | + | use crate::git::diff::{self, Changes, FileChange, LineKind, Side, Status}; | |
| 9 | + | use crate::git::tree::{self, EntryKind}; | |
| 10 | + | use crate::git::{CommitDetail, Handle, ObjectId}; | |
| 11 | + | use crate::render::highlight; | |
| 12 | + | ||
| 13 | + | /// Knobs for [`build`]; everything that changes the output is part of the | |
| 14 | + | /// cache key. | |
| 15 | + | #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] | |
| 16 | + | pub struct Options { | |
| 17 | + | pub context: u32, | |
| 18 | + | pub ignore_ws: bool, | |
| 19 | + | /// Above this many files only the file list is shown. | |
| 20 | + | pub max_files: usize, | |
| 21 | + | /// Changed lines rendered for the whole commit, and per file. | |
| 22 | + | pub max_lines: usize, | |
| 23 | + | pub max_file_lines: usize, | |
| 24 | + | /// Blobs larger than this are not diffed. | |
| 25 | + | pub max_blob_bytes: u64, | |
| 26 | + | pub max_highlight_bytes: u64, | |
| 27 | + | } | |
| 28 | + | ||
| 29 | + | pub struct CommitDiff { | |
| 30 | + | pub files: Vec<FileDiff>, | |
| 31 | + | /// Totals over the files that were diffed. | |
| 32 | + | pub added: u64, | |
| 33 | + | pub removed: u64, | |
| 34 | + | /// More than `max_files` files: no per-file diffs at all. | |
| 35 | + | pub too_many_files: bool, | |
| 36 | + | } | |
| 37 | + | ||
| 38 | + | impl CommitDiff { | |
| 39 | + | pub fn weight(&self) -> usize { | |
| 40 | + | 64 + self.files.iter().map(FileDiff::weight).sum::<usize>() | |
| 41 | + | } | |
| 42 | + | } | |
| 43 | + | ||
| 44 | + | pub struct FileDiff { | |
| 45 | + | pub change: FileChange, | |
| 46 | + | /// Lines added and removed; `None` when the file wasn't diffed. | |
| 47 | + | pub stat: Option<(u32, u32)>, | |
| 48 | + | /// `(old, new)` modes when only the mode or type changed alongside. | |
| 49 | + | pub mode_change: Option<(&'static str, &'static str)>, | |
| 50 | + | pub body: Body, | |
| 51 | + | } | |
| 52 | + | ||
| 53 | + | impl FileDiff { | |
| 54 | + | fn weight(&self) -> usize { | |
| 55 | + | let body = match &self.body { | |
| 56 | + | Body::Hunks(hunks) => hunks | |
| 57 | + | .iter() | |
| 58 | + | .map(|h| h.header.len() + h.lines.iter().map(|l| l.html.len() + 32).sum::<usize>()) | |
| 59 | + | .sum(), | |
| 60 | + | _ => 0, | |
| 61 | + | }; | |
| 62 | + | 128 + self.change.path().len() * 2 + body | |
| 63 | + | } | |
| 64 | + | } | |
| 65 | + | ||
| 66 | + | pub enum Body { | |
| 67 | + | Hunks(Vec<Hunk>), | |
| 68 | + | /// Content unchanged (pure rename, mode change, whitespace-only with | |
| 69 | + | /// `ignore_ws`). | |
| 70 | + | Unchanged, | |
| 71 | + | Binary, | |
| 72 | + | Submodule { | |
| 73 | + | old: Option<ObjectId>, | |
| 74 | + | new: Option<ObjectId>, | |
| 75 | + | }, | |
| 76 | + | /// Over a size limit: the blob, the per-file or the per-commit lines. | |
| 77 | + | TooLarge, | |
| 78 | + | /// Not diffed because the commit has too many files. | |
| 79 | + | NotShown, | |
| 80 | + | } | |
| 81 | + | ||
| 82 | + | pub struct Hunk { | |
| 83 | + | pub header: String, | |
| 84 | + | pub lines: Vec<Line>, | |
| 85 | + | } | |
| 86 | + | ||
| 87 | + | pub struct Line { | |
| 88 | + | pub kind: LineKind, | |
| 89 | + | /// 1-based line numbers. | |
| 90 | + | pub old: Option<u32>, | |
| 91 | + | pub new: Option<u32>, | |
| 92 | + | pub html: String, | |
| 93 | + | } | |
| 94 | + | ||
| 95 | + | impl Line { | |
| 96 | + | pub fn sign(&self) -> &'static str { | |
| 97 | + | match self.kind { | |
| 98 | + | LineKind::Context => " ", | |
| 99 | + | LineKind::Added => "+", | |
| 100 | + | LineKind::Removed => "\u{2212}", | |
| 101 | + | } | |
| 102 | + | } | |
| 103 | + | ||
| 104 | + | pub fn class(&self) -> &'static str { | |
| 105 | + | match self.kind { | |
| 106 | + | LineKind::Context => "ctx", | |
| 107 | + | LineKind::Added => "add", | |
| 108 | + | LineKind::Removed => "del", | |
| 109 | + | } | |
| 110 | + | } | |
| 111 | + | } | |
| 112 | + | ||
| 113 | + | enum Content { | |
| 114 | + | Text(String), | |
| 115 | + | Binary, | |
| 116 | + | TooLarge, | |
| 117 | + | Missing, | |
| 118 | + | } | |
| 119 | + | ||
| 120 | + | impl Content { | |
| 121 | + | fn text(&self) -> &str { | |
| 122 | + | match self { | |
| 123 | + | Content::Text(t) => t, | |
| 124 | + | _ => "", | |
| 125 | + | } | |
| 126 | + | } | |
| 127 | + | } | |
| 128 | + | ||
| 129 | + | fn load(repo: &Handle, side: Option<&Side>, max_bytes: Option<u64>) -> anyhow::Result<Content> { | |
| 130 | + | let Some(side) = side else { | |
| 131 | + | return Ok(Content::Missing); | |
| 132 | + | }; | |
| 133 | + | if let Some(max) = max_bytes | |
| 134 | + | && tree::size(repo, side.oid)? > max | |
| 135 | + | { | |
| 136 | + | return Ok(Content::TooLarge); | |
| 137 | + | } | |
| 138 | + | let data = tree::blob(repo, side.oid)?; | |
| 139 | + | if data[..data.len().min(8000)].contains(&0) { | |
| 140 | + | return Ok(Content::Binary); | |
| 141 | + | } | |
| 142 | + | Ok(Content::Text(String::from_utf8_lossy(&data).into_owned())) | |
| 143 | + | } | |
| 144 | + | ||
| 145 | + | fn split(text: &str) -> Vec<&str> { | |
| 146 | + | text.split_inclusive('\n').collect() | |
| 147 | + | } | |
| 148 | + | ||
| 149 | + | fn mode_change(change: &FileChange) -> Option<(&'static str, &'static str)> { | |
| 150 | + | let (old, new) = (change.old.as_ref()?, change.new.as_ref()?); | |
| 151 | + | (old.kind != new.kind).then(|| (diff::mode(old.kind), diff::mode(new.kind))) | |
| 152 | + | } | |
| 153 | + | ||
| 154 | + | fn is_submodule(side: Option<&Side>) -> bool { | |
| 155 | + | submodule(side).is_some() | |
| 156 | + | } | |
| 157 | + | ||
| 158 | + | fn submodule(side: Option<&Side>) -> Option<&Side> { | |
| 159 | + | side.filter(|s| s.kind == EntryKind::Submodule) | |
| 160 | + | } | |
| 161 | + | ||
| 162 | + | /// Diff every changed file, highlighting both sides as whole files. | |
| 163 | + | pub fn build( | |
| 164 | + | repo: &Handle, | |
| 165 | + | cache: &Cache, | |
| 166 | + | changes: &Changes, | |
| 167 | + | options: &Options, | |
| 168 | + | ) -> anyhow::Result<CommitDiff> { | |
| 169 | + | let too_many_files = changes.files.len() > options.max_files; | |
| 170 | + | let mut out = CommitDiff { | |
| 171 | + | files: Vec::with_capacity(changes.files.len()), | |
| 172 | + | added: 0, | |
| 173 | + | removed: 0, | |
| 174 | + | too_many_files, | |
| 175 | + | }; | |
| 176 | + | let mut budget = options.max_lines; | |
| 177 | + | for change in &changes.files { | |
| 178 | + | let mut file = FileDiff { | |
| 179 | + | change: change.clone(), | |
| 180 | + | stat: None, | |
| 181 | + | mode_change: mode_change(change), | |
| 182 | + | body: Body::NotShown, | |
| 183 | + | }; | |
| 184 | + | if too_many_files { | |
| 185 | + | out.files.push(file); | |
| 186 | + | continue; | |
| 187 | + | } | |
| 188 | + | let (old_side, new_side) = (change.old.as_ref(), change.new.as_ref()); | |
| 189 | + | if is_submodule(old_side) || is_submodule(new_side) { | |
| 190 | + | file.body = Body::Submodule { | |
| 191 | + | old: submodule(old_side).map(|s| s.oid), | |
| 192 | + | new: submodule(new_side).map(|s| s.oid), | |
| 193 | + | }; | |
| 194 | + | out.files.push(file); | |
| 195 | + | continue; | |
| 196 | + | } | |
| 197 | + | if old_side.map(|s| s.oid) == new_side.map(|s| s.oid) { | |
| 198 | + | file.stat = Some((0, 0)); | |
| 199 | + | file.body = Body::Unchanged; | |
| 200 | + | out.files.push(file); | |
| 201 | + | continue; | |
| 202 | + | } | |
| 203 | + | let max = Some(options.max_blob_bytes); | |
| 204 | + | let (old, new) = (load(repo, old_side, max)?, load(repo, new_side, max)?); | |
| 205 | + | file.body = match (&old, &new) { | |
| 206 | + | (Content::TooLarge, _) | (_, Content::TooLarge) => Body::TooLarge, | |
| 207 | + | (Content::Binary, _) | (_, Content::Binary) => Body::Binary, | |
| 208 | + | _ => { | |
| 209 | + | let (old_text, new_text) = (old.text(), new.text()); | |
| 210 | + | let (old_lines, new_lines) = (split(old_text), split(new_text)); | |
| 211 | + | let lines = diff::lines(&old_lines, &new_lines, options.context, options.ignore_ws); | |
| 212 | + | file.stat = Some((lines.added, lines.removed)); | |
| 213 | + | out.added += u64::from(lines.added); | |
| 214 | + | out.removed += u64::from(lines.removed); | |
| 215 | + | let changed = (lines.added + lines.removed) as usize; | |
| 216 | + | if lines.hunks.is_empty() { | |
| 217 | + | Body::Unchanged | |
| 218 | + | } else if changed > options.max_file_lines || changed > budget { | |
| 219 | + | Body::TooLarge | |
| 220 | + | } else { | |
| 221 | + | budget -= changed; | |
| 222 | + | let html = |side: Option<&Side>, text: &str| match side { | |
| 223 | + | Some(side) => { | |
| 224 | + | highlight::cached_lines( | |
| 225 | + | cache, | |
| 226 | + | side.oid, | |
| 227 | + | &String::from_utf8_lossy(&side.path), | |
| 228 | + | text, | |
| 229 | + | options.max_highlight_bytes, | |
| 230 | + | ) | |
| 231 | + | .0 | |
| 232 | + | } | |
| 233 | + | None => Arc::new(Vec::new()), | |
| 234 | + | }; | |
| 235 | + | let old_html = html(old_side, old_text); | |
| 236 | + | let new_html = html(new_side, new_text); | |
| 237 | + | let get = |lines: &[String], i: Option<u32>| { | |
| 238 | + | i.and_then(|i| lines.get(i as usize)) | |
| 239 | + | .cloned() | |
| 240 | + | .unwrap_or_default() | |
| 241 | + | }; | |
| 242 | + | Body::Hunks( | |
| 243 | + | lines | |
| 244 | + | .hunks | |
| 245 | + | .iter() | |
| 246 | + | .map(|h| Hunk { | |
| 247 | + | header: h.header(), | |
| 248 | + | lines: h | |
| 249 | + | .lines | |
| 250 | + | .iter() | |
| 251 | + | .map(|l| Line { | |
| 252 | + | kind: l.kind, | |
| 253 | + | old: l.old.map(|n| n + 1), | |
| 254 | + | new: l.new.map(|n| n + 1), | |
| 255 | + | html: match l.kind { | |
| 256 | + | LineKind::Removed => get(&old_html, l.old), | |
| 257 | + | _ => get(&new_html, l.new), | |
| 258 | + | }, | |
| 259 | + | }) | |
| 260 | + | .collect(), | |
| 261 | + | }) | |
| 262 | + | .collect(), | |
| 263 | + | ) | |
| 264 | + | } | |
| 265 | + | } | |
| 266 | + | }; | |
| 267 | + | out.files.push(file); | |
| 268 | + | } | |
| 269 | + | Ok(out) | |
| 270 | + | } | |
| 271 | + | ||
| 272 | + | /// A `git format-patch`-style patch of `commit` against its first parent. | |
| 273 | + | pub fn patch(repo: &Handle, commit: &CommitDetail, changes: &Changes) -> anyhow::Result<String> { | |
| 274 | + | let mut stat = String::new(); | |
| 275 | + | let mut body = String::new(); | |
| 276 | + | let (mut files, mut added, mut removed) = (0, 0, 0); | |
| 277 | + | let width = changes | |
| 278 | + | .files | |
| 279 | + | .iter() | |
| 280 | + | .map(|f| display_path(f).chars().count()) | |
| 281 | + | .max() | |
| 282 | + | .unwrap_or(0); | |
| 283 | + | let mut stats = Vec::new(); | |
| 284 | + | for change in &changes.files { | |
| 285 | + | files += 1; | |
| 286 | + | let (a, r, bin) = file_patch(repo, change, &mut body)?; | |
| 287 | + | added += a; | |
| 288 | + | removed += r; | |
| 289 | + | stats.push((display_path(change), a, r, bin)); | |
| 290 | + | } | |
| 291 | + | let max = stats | |
| 292 | + | .iter() | |
| 293 | + | .map(|(_, a, r, _)| a + r) | |
| 294 | + | .max() | |
| 295 | + | .unwrap_or(0) | |
| 296 | + | .max(1); | |
| 297 | + | for (path, a, r, bin) in &stats { | |
| 298 | + | let _ = write!(stat, " {path:<width$} | "); | |
| 299 | + | match bin { | |
| 300 | + | Some(sizes) => { | |
| 301 | + | let _ = writeln!(stat, "{sizes}"); | |
| 302 | + | } | |
| 303 | + | None => { | |
| 304 | + | // Scale the bar like git (to at most 50 columns). | |
| 305 | + | let scale = |n: u32| -> usize { | |
| 306 | + | if max <= 50 { | |
| 307 | + | n as usize | |
| 308 | + | } else { | |
| 309 | + | ((n as u64 * 50).div_ceil(max as u64)) as usize | |
| 310 | + | } | |
| 311 | + | }; | |
| 312 | + | let _ = writeln!( | |
| 313 | + | stat, | |
| 314 | + | "{} {}{}", | |
| 315 | + | a + r, | |
| 316 | + | "+".repeat(scale(*a)), | |
| 317 | + | "-".repeat(scale(*r)) | |
| 318 | + | ); | |
| 319 | + | } | |
| 320 | + | } | |
| 321 | + | } | |
| 322 | + | let plural = |n: u32, one: &str, many: &str| format!("{n} {}", if n == 1 { one } else { many }); | |
| 323 | + | let mut summary = format!(" {} changed", plural(files, "file", "files")); | |
| 324 | + | if added > 0 || removed == 0 { | |
| 325 | + | let _ = write!(summary, ", {}(+)", plural(added, "insertion", "insertions")); | |
| 326 | + | } | |
| 327 | + | if removed > 0 { | |
| 328 | + | let _ = write!(summary, ", {}(-)", plural(removed, "deletion", "deletions")); | |
| 329 | + | } | |
| 330 | + | ||
| 331 | + | let (subject, rest) = commit | |
| 332 | + | .message | |
| 333 | + | .split_once('\n') | |
| 334 | + | .unwrap_or((commit.message.as_str(), "")); | |
| 335 | + | let mut out = String::new(); | |
| 336 | + | let _ = writeln!(out, "From {} Mon Sep 17 00:00:00 2001", commit.info.id); | |
| 337 | + | let _ = writeln!( | |
| 338 | + | out, | |
| 339 | + | "From: {} <{}>", | |
| 340 | + | commit.author.name, commit.author.email | |
| 341 | + | ); | |
| 342 | + | let _ = writeln!( | |
| 343 | + | out, | |
| 344 | + | "Date: {}", | |
| 345 | + | crate::render::time::rfc2822(commit.author.time, commit.author.offset) | |
| 346 | + | ); | |
| 347 | + | let _ = writeln!(out, "Subject: [PATCH] {}", subject.trim()); | |
| 348 | + | out.push('\n'); | |
| 349 | + | let rest = rest.trim(); | |
| 350 | + | if !rest.is_empty() { | |
| 351 | + | out.push_str(rest); | |
| 352 | + | out.push('\n'); | |
| 353 | + | } | |
| 354 | + | out.push_str("---\n"); | |
| 355 | + | out.push_str(&stat); | |
| 356 | + | out.push_str(&summary); | |
| 357 | + | out.push_str("\n\n"); | |
| 358 | + | out.push_str(&body); | |
| 359 | + | let _ = write!(out, "-- \nferrenium {}\n\n", env!("CARGO_PKG_VERSION")); | |
| 360 | + | Ok(out) | |
| 361 | + | } | |
| 362 | + | ||
| 363 | + | fn display_path(change: &FileChange) -> String { | |
| 364 | + | let new = String::from_utf8_lossy(change.path()).into_owned(); | |
| 365 | + | match (&change.status, &change.old) { | |
| 366 | + | (Status::Renamed | Status::Copied, Some(old)) => { | |
| 367 | + | format!("{} => {new}", String::from_utf8_lossy(&old.path)) | |
| 368 | + | } | |
| 369 | + | _ => new, | |
| 370 | + | } | |
| 371 | + | } | |
| 372 | + | ||
| 373 | + | fn short(oid: &ObjectId) -> String { | |
| 374 | + | oid.to_hex_with_len(7).to_string() | |
| 375 | + | } | |
| 376 | + | ||
| 377 | + | /// Append one file's `diff --git` section; returns lines added, removed, | |
| 378 | + | /// and the stat text for binary files. | |
| 379 | + | fn file_patch( | |
| 380 | + | repo: &Handle, | |
| 381 | + | change: &FileChange, | |
| 382 | + | out: &mut String, | |
| 383 | + | ) -> anyhow::Result<(u32, u32, Option<String>)> { | |
| 384 | + | let path = |s: Option<&Side>| s.map(|s| String::from_utf8_lossy(&s.path).into_owned()); | |
| 385 | + | let (old, new) = (change.old.as_ref(), change.new.as_ref()); | |
| 386 | + | let a = path(old).unwrap_or_else(|| path(new).unwrap_or_default()); | |
| 387 | + | let b = path(new).unwrap_or_else(|| a.clone()); | |
| 388 | + | let _ = writeln!(out, "diff --git a/{a} b/{b}"); | |
| 389 | + | match (old, new) { | |
| 390 | + | (None, Some(n)) => { | |
| 391 | + | let _ = writeln!(out, "new file mode {}", diff::mode(n.kind)); | |
| 392 | + | } | |
| 393 | + | (Some(o), None) => { | |
| 394 | + | let _ = writeln!(out, "deleted file mode {}", diff::mode(o.kind)); | |
| 395 | + | } | |
| 396 | + | (Some(o), Some(n)) => { | |
| 397 | + | if o.kind != n.kind { | |
| 398 | + | let _ = writeln!(out, "old mode {}", diff::mode(o.kind)); | |
| 399 | + | let _ = writeln!(out, "new mode {}", diff::mode(n.kind)); | |
| 400 | + | } | |
| 401 | + | if let (Some(similarity), true) = ( | |
| 402 | + | change.similarity, | |
| 403 | + | matches!(change.status, Status::Renamed | Status::Copied), | |
| 404 | + | ) { | |
| 405 | + | let verb = if change.status == Status::Copied { | |
| 406 | + | "copy" | |
| 407 | + | } else { | |
| 408 | + | "rename" | |
| 409 | + | }; | |
| 410 | + | let _ = writeln!(out, "similarity index {similarity}%"); | |
| 411 | + | let _ = writeln!(out, "{verb} from {a}"); | |
| 412 | + | let _ = writeln!(out, "{verb} to {b}"); | |
| 413 | + | } | |
| 414 | + | } | |
| 415 | + | (None, None) => {} | |
| 416 | + | } | |
| 417 | + | let old_id = old.map_or_else(|| "0000000".to_string(), |s| short(&s.oid)); | |
| 418 | + | let new_id = new.map_or_else(|| "0000000".to_string(), |s| short(&s.oid)); | |
| 419 | + | if old.map(|s| s.oid) == new.map(|s| s.oid) { | |
| 420 | + | return Ok((0, 0, None)); | |
| 421 | + | } | |
| 422 | + | match (old, new) { | |
| 423 | + | (Some(o), Some(n)) if o.kind == n.kind => { | |
| 424 | + | let _ = writeln!(out, "index {old_id}..{new_id} {}", diff::mode(n.kind)); | |
| 425 | + | } | |
| 426 | + | _ => { | |
| 427 | + | let _ = writeln!(out, "index {old_id}..{new_id}"); | |
| 428 | + | } | |
| 429 | + | } | |
| 430 | + | let minus = old.map_or_else(|| "/dev/null".to_string(), |_| format!("a/{a}")); | |
| 431 | + | let plus = new.map_or_else(|| "/dev/null".to_string(), |_| format!("b/{b}")); | |
| 432 | + | ||
| 433 | + | if is_submodule(old) || is_submodule(new) { | |
| 434 | + | let _ = writeln!(out, "--- {minus}\n+++ {plus}"); | |
| 435 | + | let (o, n) = (submodule(old), submodule(new)); | |
| 436 | + | let _ = writeln!( | |
| 437 | + | out, | |
| 438 | + | "@@ -{} +{} @@", | |
| 439 | + | if o.is_some() { "1" } else { "0,0" }, | |
| 440 | + | if n.is_some() { "1" } else { "0,0" } | |
| 441 | + | ); | |
| 442 | + | if let Some(o) = o { | |
| 443 | + | let _ = writeln!(out, "-Subproject commit {}", o.oid); | |
| 444 | + | } | |
| 445 | + | if let Some(n) = n { | |
| 446 | + | let _ = writeln!(out, "+Subproject commit {}", n.oid); | |
| 447 | + | } | |
| 448 | + | return Ok((u32::from(n.is_some()), u32::from(o.is_some()), None)); | |
| 449 | + | } | |
| 450 | + | ||
| 451 | + | let (old_c, new_c) = (load(repo, old, None)?, load(repo, new, None)?); | |
| 452 | + | if matches!(old_c, Content::Binary) || matches!(new_c, Content::Binary) { | |
| 453 | + | let _ = writeln!(out, "Binary files {minus} and {plus} differ"); | |
| 454 | + | let size = |s: Option<&Side>| -> anyhow::Result<u64> { | |
| 455 | + | s.map_or(Ok(0), |s| tree::size(repo, s.oid)) | |
| 456 | + | }; | |
| 457 | + | let sizes = format!("Bin {} -> {} bytes", size(old)?, size(new)?); | |
| 458 | + | return Ok((0, 0, Some(sizes))); | |
| 459 | + | } | |
| 460 | + | let (old_l, new_l) = (split(old_c.text()), split(new_c.text())); | |
| 461 | + | let lines = diff::lines(&old_l, &new_l, 3, false); | |
| 462 | + | // Like git, an empty file added or removed has no `---`/`+++` lines. | |
| 463 | + | if lines.hunks.is_empty() { | |
| 464 | + | return Ok((0, 0, None)); | |
| 465 | + | } | |
| 466 | + | let _ = writeln!(out, "--- {minus}\n+++ {plus}"); | |
| 467 | + | for hunk in &lines.hunks { | |
| 468 | + | out.push_str(&hunk.header()); | |
| 469 | + | out.push('\n'); | |
| 470 | + | for line in &hunk.lines { | |
| 471 | + | let (sign, text) = match line.kind { | |
| 472 | + | LineKind::Context => (' ', old_l[line.old.unwrap_or(0) as usize]), | |
| 473 | + | LineKind::Removed => ('-', old_l[line.old.unwrap_or(0) as usize]), | |
| 474 | + | LineKind::Added => ('+', new_l[line.new.unwrap_or(0) as usize]), | |
| 475 | + | }; | |
| 476 | + | out.push(sign); | |
| 477 | + | out.push_str(text); | |
| 478 | + | if !text.ends_with('\n') { | |
| 479 | + | out.push_str("\n\\ No newline at end of file\n"); | |
| 480 | + | } | |
| 481 | + | } | |
| 482 | + | } | |
| 483 | + | Ok((lines.added, lines.removed, None)) | |
| 484 | + | } | |
| @@ -8,12 +8,14 @@ | |||
| 8 | 8 | //! `hl-k` keywords/storage, `hl-d` names/numbers/constants, `hl-s` | |
| 9 | 9 | //! strings/punctuation/operators, `hl-c` comments, `hl-x` invalid. | |
| 10 | 10 | ||
| 11 | − | use std::sync::OnceLock; | |
| 11 | + | use std::sync::{Arc, OnceLock}; | |
| 12 | 12 | ||
| 13 | 13 | use syntect::parsing::{ParseState, Scope, ScopeStack, SyntaxReference, SyntaxSet}; | |
| 14 | 14 | use syntect::util::LinesWithEndings; | |
| 15 | 15 | ||
| 16 | 16 | use super::escape_html; | |
| 17 | + | use crate::cache::{Cache, Key}; | |
| 18 | + | use crate::git::ObjectId; | |
| 17 | 19 | ||
| 18 | 20 | static SYNTAXES: OnceLock<SyntaxSet> = OnceLock::new(); | |
| 19 | 21 | ||
| @@ -187,6 +189,44 @@ pub fn lines(text: &str, syntax: &SyntaxReference) -> Vec<String> { | |||
| 187 | 189 | out | |
| 188 | 190 | } | |
| 189 | 191 | ||
| 192 | + | /// Lines longer than this (minified code) make syntect crawl; such files | |
| 193 | + | /// are shown plain. | |
| 194 | + | const MAX_LINE_BYTES: usize = 5000; | |
| 195 | + | ||
| 196 | + | /// HTML lines of blob `oid` (the file at `path`, contents `text`), | |
| 197 | + | /// highlighted when a syntax is detected, the blob is at most `max_bytes` | |
| 198 | + | /// and no line is minified-long, plain otherwise. Highlighted lines are cached by blob and | |
| 199 | + | /// shared by every view of it. Also returns the detected language. | |
| 200 | + | pub fn cached_lines( | |
| 201 | + | cache: &Cache, | |
| 202 | + | oid: ObjectId, | |
| 203 | + | path: &str, | |
| 204 | + | text: &str, | |
| 205 | + | max_bytes: u64, | |
| 206 | + | ) -> (Arc<Vec<String>>, Option<&'static str>) { | |
| 207 | + | let first_line = text.lines().next().unwrap_or_default(); | |
| 208 | + | let syntax = detect(path, first_line); | |
| 209 | + | let language = syntax.map(|s| s.name.as_str()); | |
| 210 | + | let lines = match syntax { | |
| 211 | + | Some(syntax) | |
| 212 | + | if text.len() as u64 <= max_bytes | |
| 213 | + | && !text.lines().any(|l| l.len() > MAX_LINE_BYTES) => | |
| 214 | + | { | |
| 215 | + | let key = Key::Highlight { | |
| 216 | + | blob: oid, | |
| 217 | + | syntax: syntax.name.clone(), | |
| 218 | + | }; | |
| 219 | + | cache | |
| 220 | + | .lines(key, || { | |
| 221 | + | Ok::<_, std::convert::Infallible>(lines(text, syntax)) | |
| 222 | + | }) | |
| 223 | + | .unwrap_or_else(|never| match never {}) | |
| 224 | + | } | |
| 225 | + | _ => Arc::new(plain_lines(text)), | |
| 226 | + | }; | |
| 227 | + | (lines, language) | |
| 228 | + | } | |
| 229 | + | ||
| 190 | 230 | /// Escaped lines without highlighting. | |
| 191 | 231 | pub fn plain_lines(text: &str) -> Vec<String> { | |
| 192 | 232 | LinesWithEndings::from(text).map(plain_line).collect() | |
| @@ -1,5 +1,6 @@ | |||
| 1 | 1 | //! Presentation helpers shared by handlers and templates. | |
| 2 | 2 | ||
| 3 | + | pub mod diff; | |
| 3 | 4 | pub mod highlight; | |
| 4 | 5 | pub mod markdown; | |
| 5 | 6 | pub mod time; | |
| @@ -38,6 +38,33 @@ pub fn iso(then: i64) -> String { | |||
| 38 | 38 | .unwrap_or_default() | |
| 39 | 39 | } | |
| 40 | 40 | ||
| 41 | + | fn zoned(then: i64, offset: i32) -> Option<jiff::Zoned> { | |
| 42 | + | let offset = jiff::tz::Offset::from_seconds(offset).unwrap_or(jiff::tz::Offset::UTC); | |
| 43 | + | let ts = jiff::Timestamp::from_second(then).ok()?; | |
| 44 | + | Some(ts.to_zoned(jiff::tz::TimeZone::fixed(offset))) | |
| 45 | + | } | |
| 46 | + | ||
| 47 | + | /// `Thu, 5 Nov 2026 14:03:00 +0100` in the recorded offset (patch headers). | |
| 48 | + | pub fn rfc2822(then: i64, offset: i32) -> String { | |
| 49 | + | zoned(then, offset) | |
| 50 | + | .map(|z| z.strftime("%a, %-d %b %Y %H:%M:%S %z").to_string()) | |
| 51 | + | .unwrap_or_default() | |
| 52 | + | } | |
| 53 | + | ||
| 54 | + | /// `2026-11-05 14:03 +0100` in the recorded offset. | |
| 55 | + | pub fn local(then: i64, offset: i32) -> String { | |
| 56 | + | zoned(then, offset) | |
| 57 | + | .map(|z| z.strftime("%Y-%m-%d %H:%M %z").to_string()) | |
| 58 | + | .unwrap_or_default() | |
| 59 | + | } | |
| 60 | + | ||
| 61 | + | /// `Nov 5, 2026` (UTC), for grouping commits by day. | |
| 62 | + | pub fn day(then: i64) -> String { | |
| 63 | + | jiff::Timestamp::from_second(then) | |
| 64 | + | .map(|t| t.strftime("%b %-d, %Y").to_string()) | |
| 65 | + | .unwrap_or_default() | |
| 66 | + | } | |
| 67 | + | ||
| 41 | 68 | #[cfg(test)] | |
| 42 | 69 | mod tests { | |
| 43 | 70 | use super::*; | |
| @@ -59,5 +86,8 @@ mod tests { | |||
| 59 | 86 | #[test] | |
| 60 | 87 | fn iso_format() { | |
| 61 | 88 | assert_eq!(iso(0), "1970-01-01T00:00:00Z"); | |
| 89 | + | assert_eq!(rfc2822(0, 3600), "Thu, 1 Jan 1970 01:00:00 +0100"); | |
| 90 | + | assert_eq!(local(0, -7200), "1969-12-31 22:00 -0200"); | |
| 91 | + | assert_eq!(day(86400 * 5), "Jan 6, 1970"); | |
| 62 | 92 | } | |
| 63 | 93 | } | |
| @@ -12,6 +12,7 @@ | |||
| 12 | 12 | </nav> | |
| 13 | 13 | {%- endif %} | |
| 14 | 14 | <a class="btn-ghost" href="{{ raw_url }}">Raw</a> | |
| 15 | + | <a class="btn-ghost" href="{{ log_url }}">History</a> | |
| 15 | 16 | <a class="btn-ghost" href="{{ download_url }}">Download</a> | |
| 16 | 17 | </div> | |
| 17 | 18 | </div> | |
| @@ -0,0 +1,111 @@ | |||
| 1 | + | {% extends "base.html" %} | |
| 2 | + | {% block content %} | |
| 3 | + | {% include "repo_nav.html" %} | |
| 4 | + | {% include "crumbs.html" %} | |
| 5 | + | <section class="commit-head"> | |
| 6 | + | <h2 class="commit-subject">{{ subject }}</h2> | |
| 7 | + | {%- if !body.is_empty() %} | |
| 8 | + | <pre class="commit-body">{{ body }}</pre> | |
| 9 | + | {%- endif %} | |
| 10 | + | </section> | |
| 11 | + | <div class="commit-grid"> | |
| 12 | + | <dl class="stats commit-meta"> | |
| 13 | + | {%- for m in meta %} | |
| 14 | + | <div class="stat"> | |
| 15 | + | <dt class="label">{{ m.label }}</dt> | |
| 16 | + | <dd> | |
| 17 | + | {%- if let Some(title) = m.title %}<span title="{{ title }}">{{ m.value }}</span>{% else %}{{ m.value }}{% endif %} | |
| 18 | + | {%- for (text, url) in m.links %}{% if !loop.first %} {% endif %}<a class="sha" href="{{ url }}">{{ text }}</a>{% endfor -%} | |
| 19 | + | </dd> | |
| 20 | + | </div> | |
| 21 | + | {%- endfor %} | |
| 22 | + | </dl> | |
| 23 | + | <div class="diffstat-hero"> | |
| 24 | + | {%- if diff.too_many_files %} | |
| 25 | + | <p class="diffstat-num">{{ files.len() }}</p> | |
| 26 | + | <p class="label dim">Files changed</p> | |
| 27 | + | {%- else %} | |
| 28 | + | <p class="diffstat-num"><span class="add">+{{ added }}</span> <span class="del">−{{ removed }}</span></p> | |
| 29 | + | <p class="label dim">{{ files.len() }} {% if files.len() == 1 %}file{% else %}files{% endif %} changed</p> | |
| 30 | + | {%- endif %} | |
| 31 | + | </div> | |
| 32 | + | </div> | |
| 33 | + | <div class="commit-actions"> | |
| 34 | + | <div class="blob-actions"> | |
| 35 | + | <a class="btn-ghost" href="{{ tree_url }}">Browse files</a> | |
| 36 | + | <a class="btn-ghost" href="{{ patch_url }}">Patch</a> | |
| 37 | + | </div> | |
| 38 | + | <div class="blob-actions"> | |
| 39 | + | <nav class="segmented segmented-sm" aria-label="Whitespace"> | |
| 40 | + | {%- for t in ws_toggle %} | |
| 41 | + | <a href="{{ t.url }}"{% if t.active %} aria-current="true"{% endif %}>{{ t.label }}</a> | |
| 42 | + | {%- endfor %} | |
| 43 | + | </nav> | |
| 44 | + | <span class="label dim context-ctl"> | |
| 45 | + | {%- if let Some(url) = context_less %}<a href="{{ url }}" aria-label="Less context">−</a>{% else %}<span>−</span>{% endif -%} | |
| 46 | + | <span>Context {{ context }}</span> | |
| 47 | + | {%- if let Some(url) = context_more %}<a href="{{ url }}" aria-label="More context">+</a>{% else %}<span>+</span>{% endif -%} | |
| 48 | + | </span> | |
| 49 | + | </div> | |
| 50 | + | </div> | |
| 51 | + | {%- if files.is_empty() %} | |
| 52 | + | <p class="status blob-note">[No changes]</p> | |
| 53 | + | {%- else %} | |
| 54 | + | <ol class="file-list"> | |
| 55 | + | {%- for row in files %} | |
| 56 | + | <li class="file-row"> | |
| 57 | + | <span class="file-status" title="{{ row.status }}">{{ row.letter }}</span> | |
| 58 | + | <a class="file-path" href="#{{ row.anchor }}">{% if let Some(from) = row.from %}<span class="dim">{{ from }} → </span>{% endif %}{{ row.path }}</a> | |
| 59 | + | {%- if let Some((a, r)) = row.file.stat %} | |
| 60 | + | <span class="file-counts"><span class="add">+{{ a }}</span> <span class="del">−{{ r }}</span></span> | |
| 61 | + | {%- else %} | |
| 62 | + | <span class="file-counts"></span> | |
| 63 | + | {%- endif %} | |
| 64 | + | <span class="blocks" aria-hidden="true">{% for b in row.bar %}<i class="{{ b }}"></i>{% endfor %}</span> | |
| 65 | + | </li> | |
| 66 | + | {%- endfor %} | |
| 67 | + | </ol> | |
| 68 | + | {%- endif %} | |
| 69 | + | {%- if diff.too_many_files %} | |
| 70 | + | <p class="status blob-note">[Diff too large: {{ files.len() }} files] <a href="{{ patch_url }}">View patch</a></p> | |
| 71 | + | {%- else %} | |
| 72 | + | {%- for row in files %} | |
| 73 | + | <section class="diff-file" id="{{ row.anchor }}"> | |
| 74 | + | <header class="diff-head"> | |
| 75 | + | <span class="file-status" title="{{ row.status }}">{{ row.letter }}</span> | |
| 76 | + | <span class="diff-path">{% if let Some(from) = row.from %}<span class="dim">{{ from }} → </span>{% endif %}{{ row.path }}</span> | |
| 77 | + | {%- if let Some(url) = row.url %} | |
| 78 | + | <a class="label diff-view" href="{{ url }}">View</a> | |
| 79 | + | {%- endif %} | |
| 80 | + | </header> | |
| 81 | + | {%- if let Some((old_mode, new_mode)) = row.file.mode_change %} | |
| 82 | + | <p class="label dim diff-note">Mode {{ old_mode }} → {{ new_mode }}</p> | |
| 83 | + | {%- endif %} | |
| 84 | + | {%- match row.file.body %} | |
| 85 | + | {%- when Body::Hunks with (hunks) %} | |
| 86 | + | <div class="code-wrap"> | |
| 87 | + | <table class="code diff"> | |
| 88 | + | <tbody> | |
| 89 | + | {%- for hunk in hunks %} | |
| 90 | + | <tr class="hunk"><td class="ln" colspan="2"></td><td class="lc" colspan="2">{{ hunk.header }}</td></tr> | |
| 91 | + | {%- for line in hunk.lines %} | |
| 92 | + | <tr class="{{ line.class() }}"><td class="ln">{% if let Some(n) = line.old %}{{ n }}{% endif %}</td><td class="ln">{% if let Some(n) = line.new %}{{ n }}{% endif %}</td><td class="sign">{{ line.sign() }}</td><td class="lc">{{ line.html|safe }}</td></tr> | |
| 93 | + | {%- endfor %} | |
| 94 | + | {%- endfor %} | |
| 95 | + | </tbody> | |
| 96 | + | </table> | |
| 97 | + | </div> | |
| 98 | + | {%- when Body::Unchanged %} | |
| 99 | + | <p class="status diff-note">[No content changes]</p> | |
| 100 | + | {%- when Body::Binary %} | |
| 101 | + | <p class="status diff-note">[Binary file]</p> | |
| 102 | + | {%- when Body::Submodule with { old, new } %} | |
| 103 | + | <p class="diff-note"><span class="label">Submodule</span> <span class="mono">{% if let Some(o) = old %}{{ o.to_hex_with_len(7) }}{% else %}—{% endif %} → {% if let Some(n) = new %}{{ n.to_hex_with_len(7) }}{% else %}—{% endif %}</span></p> | |
| 104 | + | {%- when Body::TooLarge %} | |
| 105 | + | <p class="status diff-note">[Diff too large]{% if let Some(url) = row.url %} <a href="{{ url }}">View file</a>{% endif %}</p> | |
| 106 | + | {%- when Body::NotShown %} | |
| 107 | + | {%- endmatch %} | |
| 108 | + | </section> | |
| 109 | + | {%- endfor %} | |
| 110 | + | {%- endif %} | |
| 111 | + | {% endblock %} | |
| @@ -0,0 +1,47 @@ | |||
| 1 | + | {% extends "base.html" %} | |
| 2 | + | {% block content %} | |
| 3 | + | {% include "repo_nav.html" %} | |
| 4 | + | {% include "crumbs.html" %} | |
| 5 | + | <form class="log-tools" method="get" action="{{ action }}" role="search"> | |
| 6 | + | <label class="field"> | |
| 7 | + | <span class="label">Grep</span> | |
| 8 | + | <input type="search" name="q" value="{{ q }}" autocomplete="off" spellcheck="false" placeholder="Message, author or email"> | |
| 9 | + | </label> | |
| 10 | + | </form> | |
| 11 | + | {%- if days.is_empty() %} | |
| 12 | + | <p class="status log-note">{% if limit_reached %}[No matches in {{ scan_limit }} commits]{% else %}[No commits]{% endif %}</p> | |
| 13 | + | {%- endif %} | |
| 14 | + | {%- for day in days %} | |
| 15 | + | <section class="log-day"> | |
| 16 | + | <h2 class="label section-label">{{ day.label }}</h2> | |
| 17 | + | <ol class="log"> | |
| 18 | + | {%- for row in day.rows %} | |
| 19 | + | <li class="log-row"> | |
| 20 | + | <div class="log-main"> | |
| 21 | + | <a class="log-subject" href="{{ row.url }}">{{ row.subject }}</a> | |
| 22 | + | <p class="log-meta">{{ row.author }} · <time datetime="{{ row.iso }}" title="{{ row.iso }}">{{ row.age }}</time></p> | |
| 23 | + | </div> | |
| 24 | + | <a class="sha log-sha" href="{{ row.url }}">{{ row.id }}</a> | |
| 25 | + | </li> | |
| 26 | + | {%- endfor %} | |
| 27 | + | </ol> | |
| 28 | + | </section> | |
| 29 | + | {%- endfor %} | |
| 30 | + | {%- if limit_reached && !days.is_empty() %} | |
| 31 | + | <p class="status log-note">[Search limit reached: {{ scan_limit }} commits scanned]</p> | |
| 32 | + | {%- endif %} | |
| 33 | + | {%- if newer_url.is_some() || older_url.is_some() %} | |
| 34 | + | <nav class="pager" aria-label="Pages"> | |
| 35 | + | {%- if let Some(url) = newer_url %} | |
| 36 | + | <a class="label" href="{{ url }}" rel="prev">< {{ newer_label }}</a> | |
| 37 | + | {%- else %} | |
| 38 | + | <span class="label dim">< Newer</span> | |
| 39 | + | {%- endif %} | |
| 40 | + | {%- if let Some(url) = older_url %} | |
| 41 | + | <a class="label" href="{{ url }}" rel="next">Older ></a> | |
| 42 | + | {%- else %} | |
| 43 | + | <span class="label dim">Older ></span> | |
| 44 | + | {%- endif %} | |
| 45 | + | </nav> | |
| 46 | + | {%- endif %} | |
| 47 | + | {% endblock %} | |
| @@ -2,6 +2,7 @@ | |||
| 2 | 2 | <nav class="bracket-nav" aria-label="Repository"> | |
| 3 | 3 | <a class="label{% if nav.active == "summary" %} active{% endif %}" href="{{ nav.url }}"{% if nav.active == "summary" %} aria-current="page"{% endif %}>Summary</a> | |
| 4 | 4 | <a class="label{% if nav.active == "tree" %} active{% endif %}" href="{{ nav.tree_url }}"{% if nav.active == "tree" %} aria-current="page"{% endif %}>Tree</a> | |
| 5 | + | <a class="label{% if nav.active == "log" %} active{% endif %}" href="{{ nav.log_url }}"{% if nav.active == "log" %} aria-current="page"{% endif %}>Log</a> | |
| 5 | 6 | <a class="label{% if nav.active == "refs" %} active{% endif %}" href="{{ nav.refs_url }}"{% if nav.active == "refs" %} aria-current="page"{% endif %}>Refs</a> | |
| 6 | 7 | </nav> | |
| 7 | 8 | {%- if let Some(picker) = nav.picker %} | |
| @@ -27,7 +27,7 @@ | |||
| 27 | 27 | {%- for stat in stats %} | |
| 28 | 28 | <div class="stat"> | |
| 29 | 29 | <dt class="label">{{ stat.label }}</dt> | |
| 30 | − | <dd>{% if let Some(title) = stat.title %}<time datetime="{{ title }}" title="{{ title }}">{{ stat.value }}</time>{% else %}{{ stat.value }}{% endif %}</dd> | |
| 30 | + | <dd>{% if let Some(url) = stat.url %}<a href="{{ url }}">{{ stat.value }}</a>{% else if let Some(title) = stat.title %}<time datetime="{{ title }}" title="{{ title }}">{{ stat.value }}</time>{% else %}{{ stat.value }}{% endif %}</dd> | |
| 31 | 31 | </div> | |
| 32 | 32 | {%- endfor %} | |
| 33 | 33 | </dl> | |
| @@ -4,7 +4,7 @@ | |||
| 4 | 4 | {% include "repo_nav.html" %} | |
| 5 | 5 | {% include "crumbs.html" %} | |
| 6 | 6 | {% call m::tree_list(rows, layout.static_base) %}{% endcall %} | |
| 7 | − | <p class="label dim tree-foot">{{ entries }} {% if entries == 1 %}entry{% else %}entries{% endif %} · Tree {{ tree_id }}</p> | |
| 7 | + | <p class="label dim tree-foot">{{ entries }} {% if entries == 1 %}entry{% else %}entries{% endif %} · Tree {{ tree_id }} · <a href="{{ log_url }}">History</a></p> | |
| 8 | 8 | {%- if let Some(readme) = readme %} | |
| 9 | 9 | {% call m::readme(readme) %}{% endcall %} | |
| 10 | 10 | {%- endif %} | |
| @@ -111,12 +111,15 @@ async fn summary_shows_tree_readme_and_rail() { | |||
| 111 | 111 | assert!(html.contains(r#"src="/rich/raw/main/img/logo.png""#)); | |
| 112 | 112 | assert!(!html.contains("alert(1)")); | |
| 113 | 113 | // Rail. | |
| 114 | − | assert!(html.contains("<dd>2</dd>"), "commit count: {html}"); | |
| 114 | + | assert!( | |
| 115 | + | html.contains(r#"<dd><a href="/rich/log">2</a></dd>"#), | |
| 116 | + | "commit count: {html}" | |
| 117 | + | ); | |
| 115 | 118 | assert!(html.contains("https://git.example.com/rich")); | |
| 116 | 119 | assert!(html.contains("git@example.com:rich")); | |
| 117 | 120 | assert!(html.contains(">HTTPS<") && html.contains(">SSH<")); | |
| 118 | 121 | assert!(html.contains("add everything")); | |
| 119 | − | assert!(html.contains(&format!(r#"href="/rich/tree/{}""#, s.rich.head))); | |
| 122 | + | assert!(html.contains(&format!(r#"href="/rich/commit/{}""#, s.rich.head))); | |
| 120 | 123 | // Nav + picker. | |
| 121 | 124 | assert!(html.contains(r#"class="label active" href="/rich" aria-current="page">Summary"#)); | |
| 122 | 125 | assert!(html.contains(r#"href="/rich/tree/feature/x""#)); | |
| @@ -172,7 +175,7 @@ async fn dispatcher_matches_repos_and_revisions() { | |||
| 172 | 175 | ("/rich/tree/nope", "UNKNOWN REVISION"), | |
| 173 | 176 | ("/rich/tree/main/nope", "PATH NOT FOUND IN main"), | |
| 174 | 177 | ("/rich/blob/main/vendor/lib", "PATH IS A SUBMODULE"), | |
| 175 | − | ("/rich/log", "PAGE NOT FOUND"), | |
| 178 | + | ("/rich/blame-nope", "PAGE NOT FOUND"), | |
| 176 | 179 | ("/rich/tree/main/../x", "PAGE NOT FOUND"), | |
| 177 | 180 | ("/-/nope", "PAGE NOT FOUND"), | |
| 178 | 181 | ] { | |
| @@ -0,0 +1,509 @@ | |||
| 1 | + | //! History pages (log, commit, patch) checked against the git CLI. | |
| 2 | + | ||
| 3 | + | mod common; | |
| 4 | + | ||
| 5 | + | use std::path::{Path, PathBuf}; | |
| 6 | + | ||
| 7 | + | use axum::body::{Body, to_bytes}; | |
| 8 | + | use axum::http::{HeaderMap, Request, StatusCode, header}; | |
| 9 | + | use common::{Work, bare, git}; | |
| 10 | + | use ferrenium::config::Config; | |
| 11 | + | use ferrenium::server; | |
| 12 | + | use ferrenium::state::{AppState, Overrides}; | |
| 13 | + | use tower::ServiceExt; | |
| 14 | + | ||
| 15 | + | /// Commit ids in [`history_repo`], oldest first. | |
| 16 | + | struct History { | |
| 17 | + | bare: PathBuf, | |
| 18 | + | root: String, | |
| 19 | + | tweak: String, | |
| 20 | + | rename: String, | |
| 21 | + | misc: String, | |
| 22 | + | merge: String, | |
| 23 | + | whitespace: String, | |
| 24 | + | head: String, | |
| 25 | + | } | |
| 26 | + | ||
| 27 | + | fn lines(n: usize) -> String { | |
| 28 | + | (1..=n).map(|i| format!("line {i}\n")).collect() | |
| 29 | + | } | |
| 30 | + | ||
| 31 | + | /// Root commit; a tweak; a rename with an edit; binary + mode change + | |
| 32 | + | /// deletion; a merge of a side branch; a whitespace-only change; a | |
| 33 | + | /// `.mailmap`. Authors vary so mailmap and grep have something to do. | |
| 34 | + | fn history_repo(root: &Path) -> History { | |
| 35 | + | let bare = bare(root, "hist.git", &[]); | |
| 36 | + | let work = Work::clone_of(&bare); | |
| 37 | + | let w = work.path(); | |
| 38 | + | let write = |name: &str, data: &[u8]| { | |
| 39 | + | let path = w.join(name); | |
| 40 | + | std::fs::create_dir_all(path.parent().unwrap()).unwrap(); | |
| 41 | + | std::fs::write(path, data).unwrap(); | |
| 42 | + | }; | |
| 43 | + | let commit = |args: &[&str]| { | |
| 44 | + | git(w, &["add", "-A"]); | |
| 45 | + | let mut all = vec!["commit", "-q"]; | |
| 46 | + | all.extend_from_slice(args); | |
| 47 | + | git(w, &all); | |
| 48 | + | git(w, &["rev-parse", "HEAD"]) | |
| 49 | + | }; | |
| 50 | + | ||
| 51 | + | write("a.txt", lines(20).as_bytes()); | |
| 52 | + | write("src/lib.rs", b"pub fn one() -> u32 {\n 1\n}\n"); | |
| 53 | + | write("gone.txt", b"bye\n"); | |
| 54 | + | write("run.sh", b"#!/bin/sh\necho hi\n"); | |
| 55 | + | let root_commit = commit(&["-m", "Initial import"]); | |
| 56 | + | ||
| 57 | + | write( | |
| 58 | + | "a.txt", | |
| 59 | + | lines(20).replace("line 5\n", "line five\n").as_bytes(), | |
| 60 | + | ); | |
| 61 | + | let tweak = commit(&[ | |
| 62 | + | "-m", | |
| 63 | + | "Tweak a.txt\n\nThe body mentions a Needle.", | |
| 64 | + | "--author", | |
| 65 | + | "Bob <bob@old.example>", | |
| 66 | + | ]); | |
| 67 | + | ||
| 68 | + | git(w, &["mv", "a.txt", "b.txt"]); | |
| 69 | + | write( | |
| 70 | + | "b.txt", | |
| 71 | + | lines(20) | |
| 72 | + | .replace("line 5\n", "line five\n") | |
| 73 | + | .replace("line 20\n", "line twenty\n") | |
| 74 | + | .as_bytes(), | |
| 75 | + | ); | |
| 76 | + | let rename = commit(&["-m", "Rename a to b"]); | |
| 77 | + | ||
| 78 | + | write("blob.bin", b"\0\x01\x02\x03binary"); | |
| 79 | + | std::fs::remove_file(w.join("gone.txt")).unwrap(); | |
| 80 | + | use std::os::unix::fs::PermissionsExt; | |
| 81 | + | std::fs::set_permissions(w.join("run.sh"), std::fs::Permissions::from_mode(0o755)).unwrap(); | |
| 82 | + | let misc = commit(&["-m", "Binary, mode and deletion"]); | |
| 83 | + | ||
| 84 | + | git(w, &["checkout", "-q", "-b", "side"]); | |
| 85 | + | write("side.txt", b"from the side\n"); | |
| 86 | + | commit(&["-m", "Side work"]); | |
| 87 | + | git(w, &["checkout", "-q", "main"]); | |
| 88 | + | write( | |
| 89 | + | "src/lib.rs", | |
| 90 | + | b"pub fn one() -> u32 {\n 1\n}\n\npub fn two() -> u32 {\n 2\n}\n", | |
| 91 | + | ); | |
| 92 | + | commit(&["-m", "Add two"]); | |
| 93 | + | git(w, &["merge", "-q", "--no-ff", "-m", "Merge side", "side"]); | |
| 94 | + | let merge = git(w, &["rev-parse", "HEAD"]); | |
| 95 | + | ||
| 96 | + | write( | |
| 97 | + | "src/lib.rs", | |
| 98 | + | b"pub fn one() -> u32 {\n\t1\n}\n\npub fn two() -> u32 {\n 2\n}\n", | |
| 99 | + | ); | |
| 100 | + | let whitespace = commit(&["-m", "Tabs"]); | |
| 101 | + | ||
| 102 | + | write( | |
| 103 | + | ".mailmap", | |
| 104 | + | b"Ada Lovelace <ada@example.com>\nRobert <bob@new.example> <bob@old.example>\n", | |
| 105 | + | ); | |
| 106 | + | let head = commit(&["-m", "Add mailmap"]); | |
| 107 | + | git(w, &["push", "-q", "origin", "main", "side"]); | |
| 108 | + | History { | |
| 109 | + | bare, | |
| 110 | + | root: root_commit, | |
| 111 | + | tweak, | |
| 112 | + | rename, | |
| 113 | + | misc, | |
| 114 | + | merge, | |
| 115 | + | whitespace, | |
| 116 | + | head, | |
| 117 | + | } | |
| 118 | + | } | |
| 119 | + | ||
| 120 | + | struct Site { | |
| 121 | + | _root: tempfile::TempDir, | |
| 122 | + | app: axum::Router, | |
| 123 | + | h: History, | |
| 124 | + | } | |
| 125 | + | ||
| 126 | + | fn site(extra: &str) -> Site { | |
| 127 | + | let root = tempfile::tempdir().unwrap(); | |
| 128 | + | let h = history_repo(root.path()); | |
| 129 | + | let toml = format!( | |
| 130 | + | "[repos]\nscan_path = {:?}\n[watch]\nenabled = false\n{extra}", | |
| 131 | + | root.path().to_str().unwrap() | |
| 132 | + | ); | |
| 133 | + | let mut config = Config::parse(&toml).expect("parse"); | |
| 134 | + | config.finish().expect("valid"); | |
| 135 | + | let state = AppState::new(config, Overrides::default()); | |
| 136 | + | let config = state.config.load_full(); | |
| 137 | + | state.index.full_scan(&config); | |
| 138 | + | Site { | |
| 139 | + | app: server::app(state), | |
| 140 | + | h, | |
| 141 | + | _root: root, | |
| 142 | + | } | |
| 143 | + | } | |
| 144 | + | ||
| 145 | + | struct Res { | |
| 146 | + | status: StatusCode, | |
| 147 | + | headers: HeaderMap, | |
| 148 | + | body: String, | |
| 149 | + | } | |
| 150 | + | ||
| 151 | + | async fn fetch(app: &axum::Router, uri: &str, etag: Option<&str>) -> Res { | |
| 152 | + | let mut req = Request::get(uri); | |
| 153 | + | if let Some(etag) = etag { | |
| 154 | + | req = req.header(header::IF_NONE_MATCH, etag); | |
| 155 | + | } | |
| 156 | + | let res = app | |
| 157 | + | .clone() | |
| 158 | + | .oneshot(req.body(Body::empty()).unwrap()) | |
| 159 | + | .await | |
| 160 | + | .unwrap(); | |
| 161 | + | let status = res.status(); | |
| 162 | + | let headers = res.headers().clone(); | |
| 163 | + | let body = to_bytes(res.into_body(), usize::MAX).await.unwrap(); | |
| 164 | + | Res { | |
| 165 | + | status, | |
| 166 | + | headers, | |
| 167 | + | body: String::from_utf8_lossy(&body).into_owned(), | |
| 168 | + | } | |
| 169 | + | } | |
| 170 | + | ||
| 171 | + | async fn ok(app: &axum::Router, uri: &str) -> String { | |
| 172 | + | let res = fetch(app, uri, None).await; | |
| 173 | + | assert_eq!(res.status, StatusCode::OK, "{uri}: {}", res.body); | |
| 174 | + | assert!(!res.body.contains("style=\""), "style attribute in {uri}"); | |
| 175 | + | res.body | |
| 176 | + | } | |
| 177 | + | ||
| 178 | + | /// Commit ids linked from log rows, in page order. | |
| 179 | + | fn log_ids(html: &str) -> Vec<String> { | |
| 180 | + | html.split(r#"class="sha log-sha" href="/hist/commit/"#) | |
| 181 | + | .skip(1) | |
| 182 | + | .map(|rest| rest[..40].to_string()) | |
| 183 | + | .collect() | |
| 184 | + | } | |
| 185 | + | ||
| 186 | + | fn next_link(html: &str, rel: &str) -> Option<String> { | |
| 187 | + | let end = html.find(&format!(r#"" rel="{rel}""#))?; | |
| 188 | + | let start = html[..end].rfind("href=\"")? + 6; | |
| 189 | + | Some(html[start..end].replace("&", "&").replace("&", "&")) | |
| 190 | + | } | |
| 191 | + | ||
| 192 | + | /// Follow `Older` links from `uri` and collect every commit id. | |
| 193 | + | async fn all_pages(app: &axum::Router, uri: &str) -> (Vec<String>, usize) { | |
| 194 | + | let (mut ids, mut pages, mut url) = (Vec::new(), 0, uri.to_string()); | |
| 195 | + | loop { | |
| 196 | + | let html = ok(app, &url).await; | |
| 197 | + | pages += 1; | |
| 198 | + | ids.extend(log_ids(&html)); | |
| 199 | + | match next_link(&html, "next") { | |
| 200 | + | Some(next) => url = next, | |
| 201 | + | None => return (ids, pages), | |
| 202 | + | } | |
| 203 | + | } | |
| 204 | + | } | |
| 205 | + | ||
| 206 | + | fn git_log(bare: &Path, args: &[&str]) -> Vec<String> { | |
| 207 | + | let mut all = vec!["log", "--format=%H"]; | |
| 208 | + | all.extend_from_slice(args); | |
| 209 | + | git(bare, &all).lines().map(str::to_string).collect() | |
| 210 | + | } | |
| 211 | + | ||
| 212 | + | #[tokio::test] | |
| 213 | + | async fn log_pages_match_git() { | |
| 214 | + | let s = site("[ui]\nlog_page = 3\n"); | |
| 215 | + | let (ids, pages) = all_pages(&s.app, "/hist/log").await; | |
| 216 | + | assert_eq!(ids, git_log(&s.h.bare, &["main"])); | |
| 217 | + | assert_eq!(pages, ids.len().div_ceil(3)); | |
| 218 | + | ||
| 219 | + | // Second page: "newer" goes back to the first. | |
| 220 | + | let first = ok(&s.app, "/hist/log/main").await; | |
| 221 | + | let older = next_link(&first, "next").unwrap(); | |
| 222 | + | let second = ok(&s.app, &older).await; | |
| 223 | + | assert_eq!(next_link(&second, "prev").unwrap(), "/hist/log/main"); | |
| 224 | + | let third = ok(&s.app, &next_link(&second, "next").unwrap()).await; | |
| 225 | + | assert_eq!(next_link(&third, "prev").unwrap(), older); | |
| 226 | + | ||
| 227 | + | // Day groups and nav. | |
| 228 | + | assert!(first.contains(r#"class="label section-label""#)); | |
| 229 | + | assert!( | |
| 230 | + | first.contains(r#"class="label active" href="/hist/log/main" aria-current="page">Log<"#), | |
| 231 | + | "{first}" | |
| 232 | + | ); | |
| 233 | + | // Mailmap applies to authors. | |
| 234 | + | assert!(first.contains("Ada Lovelace"), "{first}"); | |
| 235 | + | assert!(!first.contains(">Ada ·")); | |
| 236 | + | } | |
| 237 | + | ||
| 238 | + | #[tokio::test] | |
| 239 | + | async fn path_filtered_log_matches_git() { | |
| 240 | + | let s = site("[ui]\nlog_page = 2\n"); | |
| 241 | + | for path in [ | |
| 242 | + | "src/lib.rs", | |
| 243 | + | "src", | |
| 244 | + | "b.txt", | |
| 245 | + | "a.txt", | |
| 246 | + | "gone.txt", | |
| 247 | + | "side.txt", | |
| 248 | + | "run.sh", | |
| 249 | + | ] { | |
| 250 | + | let (ids, _) = all_pages(&s.app, &format!("/hist/log/main/{path}")).await; | |
| 251 | + | assert_eq!(ids, git_log(&s.h.bare, &["main", "--", path]), "{path}"); | |
| 252 | + | } | |
| 253 | + | let html = ok(&s.app, "/hist/log/main/src/lib.rs").await; | |
| 254 | + | assert!( | |
| 255 | + | html.contains(r#"<span class="crumb-here">lib.rs</span>"#), | |
| 256 | + | "{html}" | |
| 257 | + | ); | |
| 258 | + | let empty = ok(&s.app, "/hist/log/main/nope").await; | |
| 259 | + | assert!(empty.contains("[No commits]")); | |
| 260 | + | } | |
| 261 | + | ||
| 262 | + | #[tokio::test] | |
| 263 | + | async fn grep_filters_message_and_author() { | |
| 264 | + | let s = site(""); | |
| 265 | + | let html = ok(&s.app, "/hist/log?q=needle").await; | |
| 266 | + | assert_eq!(log_ids(&html), vec![s.h.tweak.clone()]); | |
| 267 | + | assert!(html.contains(r#"value="needle""#)); | |
| 268 | + | let html = ok(&s.app, "/hist/log/main?q=BOB%40OLD").await; | |
| 269 | + | assert_eq!(log_ids(&html), vec![s.h.tweak.clone()]); | |
| 270 | + | let html = ok(&s.app, "/hist/log?q=zzz-nothing").await; | |
| 271 | + | assert!(html.contains("[No commits]")); | |
| 272 | + | ||
| 273 | + | // The scan limit stops a search and offers to continue. | |
| 274 | + | let s = site("[limits]\nlog_scan = 2\n"); | |
| 275 | + | let html = ok(&s.app, "/hist/log?q=initial").await; | |
| 276 | + | assert!(log_ids(&html).is_empty()); | |
| 277 | + | assert!(html.contains("[No matches in 2 commits]"), "{html}"); | |
| 278 | + | let (ids, _) = all_pages(&s.app, "/hist/log?q=initial").await; | |
| 279 | + | assert_eq!(ids, vec![s.h.root.clone()]); | |
| 280 | + | } | |
| 281 | + | ||
| 282 | + | #[tokio::test] | |
| 283 | + | async fn bad_cursors_are_not_found() { | |
| 284 | + | let s = site(""); | |
| 285 | + | let res = fetch(&s.app, "/hist/log?after=xyz", None).await; | |
| 286 | + | assert_eq!(res.status, StatusCode::NOT_FOUND); | |
| 287 | + | let res = fetch(&s.app, &format!("/hist/log?after={}", "0".repeat(40)), None).await; | |
| 288 | + | assert_eq!(res.status, StatusCode::NOT_FOUND); | |
| 289 | + | assert!(res.body.contains("COMMIT NOT IN THIS HISTORY")); | |
| 290 | + | } | |
| 291 | + | ||
| 292 | + | /// `+added −removed` per file from `git diff --numstat`, as the commit page | |
| 293 | + | /// shows them. | |
| 294 | + | fn numstat(bare: &Path, commit: &str) -> Vec<(String, String)> { | |
| 295 | + | let out = git( | |
| 296 | + | bare, | |
| 297 | + | &["diff", "--numstat", "-M", &format!("{commit}^"), commit], | |
| 298 | + | ); | |
| 299 | + | out.lines() | |
| 300 | + | .map(|l| { | |
| 301 | + | let mut parts = l.split('\t'); | |
| 302 | + | let (a, r) = (parts.next().unwrap(), parts.next().unwrap()); | |
| 303 | + | (a.to_string(), r.to_string()) | |
| 304 | + | }) | |
| 305 | + | .collect() | |
| 306 | + | } | |
| 307 | + | ||
| 308 | + | #[tokio::test] | |
| 309 | + | async fn commit_page_shows_metadata_and_diffstat() { | |
| 310 | + | let s = site(""); | |
| 311 | + | let html = ok(&s.app, &format!("/hist/commit/{}", s.h.tweak)).await; | |
| 312 | + | assert!( | |
| 313 | + | html.contains(r#"<h2 class="commit-subject">Tweak a.txt</h2>"#), | |
| 314 | + | "{html}" | |
| 315 | + | ); | |
| 316 | + | assert!(html.contains("The body mentions a Needle.")); | |
| 317 | + | // Mailmap: Bob's old address maps to Robert. | |
| 318 | + | assert!( | |
| 319 | + | html.contains(r#"title="bob@new.example">Robert<"#), | |
| 320 | + | "{html}" | |
| 321 | + | ); | |
| 322 | + | assert!(html.contains(&format!(r#"href="/hist/commit/{}""#, s.h.root))); | |
| 323 | + | assert!(html.contains(r#"<span class="add">+1</span> <span class="del">−1</span>"#)); | |
| 324 | + | assert!( | |
| 325 | + | html.contains( | |
| 326 | + | r#"<tr class="del"><td class="ln">5</td><td class="ln"></td><td class="sign">"# | |
| 327 | + | ) | |
| 328 | + | ); | |
| 329 | + | assert!(html.contains(r#"<tr class="add"><td class="ln"></td><td class="ln">5</td><td class="sign">+</td><td class="lc">line five</td>"#), "{html}"); | |
| 330 | + | assert!(html.contains("@@ -2,7 +2,7 @@ line 1")); | |
| 331 | + | ||
| 332 | + | // Rename with an edit: stats match git, the old name is shown. | |
| 333 | + | let html = ok(&s.app, &format!("/hist/commit/{}", s.h.rename)).await; | |
| 334 | + | let stats = numstat(&s.h.bare, &s.h.rename); | |
| 335 | + | assert_eq!(stats, vec![("1".into(), "1".into())]); | |
| 336 | + | assert!(html.contains(r#"title="Renamed">R<"#)); | |
| 337 | + | assert!(html.contains("a.txt → </span>b.txt"), "{html}"); | |
| 338 | + | ||
| 339 | + | // Binary, mode change and deletion. | |
| 340 | + | let html = ok(&s.app, &format!("/hist/commit/{}", s.h.misc)).await; | |
| 341 | + | assert!(html.contains("[Binary file]")); | |
| 342 | + | assert!(html.contains("Mode 100644 → 100755"), "{html}"); | |
| 343 | + | assert!(html.contains(r#"title="Deleted">D<"#)); | |
| 344 | + | // A deleted file links to its last version. | |
| 345 | + | let parent = git(&s.h.bare, &["rev-parse", &format!("{}^", s.h.misc)]); | |
| 346 | + | assert!(html.contains(&format!(r#"href="/hist/blob/{parent}/gone.txt""#))); | |
| 347 | + | ||
| 348 | + | // Root commit: everything added. | |
| 349 | + | let html = ok(&s.app, &format!("/hist/commit/{}", s.h.root)).await; | |
| 350 | + | assert!(html.contains("<dd>None</dd>"), "{html}"); | |
| 351 | + | assert!(html.contains("4 files changed")); | |
| 352 | + | ||
| 353 | + | // Merge: diffed against the first parent, both parents listed. | |
| 354 | + | let html = ok(&s.app, &format!("/hist/commit/{}", s.h.merge)).await; | |
| 355 | + | assert!(html.contains(">Parents<")); | |
| 356 | + | assert!(html.contains("side.txt")); | |
| 357 | + | assert!(!html.contains("pub fn two"), "first-parent diff: {html}"); | |
| 358 | + | } | |
| 359 | + | ||
| 360 | + | #[tokio::test] | |
| 361 | + | async fn whitespace_and_context_options() { | |
| 362 | + | let s = site(""); | |
| 363 | + | let url = format!("/hist/commit/{}", s.h.whitespace); | |
| 364 | + | let html = ok(&s.app, &url).await; | |
| 365 | + | assert!(html.contains(r#"<span class="add">+1</span> <span class="del">−1</span>"#)); | |
| 366 | + | let html = ok(&s.app, &format!("{url}?ws=ignore")).await; | |
| 367 | + | assert!(html.contains("[No content changes]"), "{html}"); | |
| 368 | + | assert!(html.contains(&format!( | |
| 369 | + | r#"href="{url}?ws=ignore" aria-current="true">Ignore<"# | |
| 370 | + | ))); | |
| 371 | + | ||
| 372 | + | let html = ok(&s.app, &format!("{url}?context=0")).await; | |
| 373 | + | assert!(html.contains("@@ -2 +2 @@ pub fn one"), "{html}"); | |
| 374 | + | assert!(html.contains("Context 0")); | |
| 375 | + | let html = ok(&s.app, &format!("{url}?context=999")).await; | |
| 376 | + | assert!(html.contains("Context 50")); | |
| 377 | + | } | |
| 378 | + | ||
| 379 | + | #[tokio::test] | |
| 380 | + | async fn patch_matches_git() { | |
| 381 | + | let s = site(""); | |
| 382 | + | for commit in [ | |
| 383 | + | &s.h.root, | |
| 384 | + | &s.h.tweak, | |
| 385 | + | &s.h.rename, | |
| 386 | + | &s.h.misc, | |
| 387 | + | &s.h.merge, | |
| 388 | + | &s.h.head, | |
| 389 | + | ] { | |
| 390 | + | let res = fetch(&s.app, &format!("/hist/commit/{commit}.patch"), None).await; | |
| 391 | + | assert_eq!(res.status, StatusCode::OK); | |
| 392 | + | assert_eq!( | |
| 393 | + | res.headers[header::CONTENT_TYPE], | |
| 394 | + | "text/plain; charset=utf-8" | |
| 395 | + | ); | |
| 396 | + | assert!( | |
| 397 | + | res.headers[header::CONTENT_SECURITY_POLICY] | |
| 398 | + | .to_str() | |
| 399 | + | .unwrap() | |
| 400 | + | .contains("sandbox") | |
| 401 | + | ); | |
| 402 | + | assert!( | |
| 403 | + | res.body | |
| 404 | + | .starts_with(&format!("From {commit} Mon Sep 17 00:00:00 2001\n")) | |
| 405 | + | ); | |
| 406 | + | let ours: String = res | |
| 407 | + | .body | |
| 408 | + | .split_inclusive('\n') | |
| 409 | + | .skip_while(|l| !l.starts_with("diff --git")) | |
| 410 | + | .take_while(|l| *l != "-- \n") | |
| 411 | + | .collect(); | |
| 412 | + | let base = if commit == &s.h.root { | |
| 413 | + | "4b825dc642cb6eb9a060e54bf8d69288fbee4904".to_string() | |
| 414 | + | } else { | |
| 415 | + | format!("{commit}^") | |
| 416 | + | }; | |
| 417 | + | let theirs = std::process::Command::new("git") | |
| 418 | + | .args([ | |
| 419 | + | "-c", | |
| 420 | + | "core.abbrev=7", | |
| 421 | + | "diff", | |
| 422 | + | "--no-color", | |
| 423 | + | "-M", | |
| 424 | + | "--abbrev=7", | |
| 425 | + | &base, | |
| 426 | + | commit, | |
| 427 | + | ]) | |
| 428 | + | .current_dir(&s.h.bare) | |
| 429 | + | .env("GIT_CONFIG_NOSYSTEM", "1") | |
| 430 | + | .env("GIT_CONFIG_GLOBAL", "/dev/null") | |
| 431 | + | .output() | |
| 432 | + | .unwrap() | |
| 433 | + | .stdout; | |
| 434 | + | assert_eq!(ours, String::from_utf8(theirs).unwrap(), "{commit}"); | |
| 435 | + | } | |
| 436 | + | let res = fetch(&s.app, &format!("/hist/commit/{}.patch", s.h.tweak), None).await; | |
| 437 | + | assert!( | |
| 438 | + | res.body.contains("From: Bob <bob@old.example>\n"), | |
| 439 | + | "patches keep the recorded author" | |
| 440 | + | ); | |
| 441 | + | assert!( | |
| 442 | + | res.body | |
| 443 | + | .contains("Subject: [PATCH] Tweak a.txt\n\nThe body mentions a Needle.\n---\n") | |
| 444 | + | ); | |
| 445 | + | assert!( | |
| 446 | + | res.body | |
| 447 | + | .contains(" 1 file changed, 1 insertion(+), 1 deletion(-)\n") | |
| 448 | + | ); | |
| 449 | + | } | |
| 450 | + | ||
| 451 | + | #[tokio::test] | |
| 452 | + | async fn diff_limits() { | |
| 453 | + | let s = site("[limits]\ndiff_file_lines = 1\n"); | |
| 454 | + | let html = ok(&s.app, &format!("/hist/commit/{}", s.h.tweak)).await; | |
| 455 | + | assert!(html.contains("[Diff too large]"), "{html}"); | |
| 456 | + | assert!( | |
| 457 | + | html.contains(r#"<span class="add">+1</span>"#), | |
| 458 | + | "stats still shown" | |
| 459 | + | ); | |
| 460 | + | ||
| 461 | + | let s = site("[limits]\ndiff_files = 2\n"); | |
| 462 | + | let html = ok(&s.app, &format!("/hist/commit/{}", s.h.root)).await; | |
| 463 | + | assert!(html.contains("[Diff too large: 4 files]"), "{html}"); | |
| 464 | + | assert!(html.contains("src/lib.rs")); | |
| 465 | + | ||
| 466 | + | // The per-commit line budget. | |
| 467 | + | let s = site("[limits]\ndiff_lines = 1\n"); | |
| 468 | + | let html = ok(&s.app, &format!("/hist/commit/{}", s.h.tweak)).await; | |
| 469 | + | assert!(html.contains("[Diff too large]"), "{html}"); | |
| 470 | + | } | |
| 471 | + | ||
| 472 | + | #[tokio::test] | |
| 473 | + | async fn caching_and_resolution() { | |
| 474 | + | let s = site(""); | |
| 475 | + | // Full ids are immutable; refs revalidate. | |
| 476 | + | let res = fetch(&s.app, &format!("/hist/commit/{}", s.h.head), None).await; | |
| 477 | + | assert_eq!(res.headers[header::CACHE_CONTROL], "public, max-age=86400"); | |
| 478 | + | let etag = res.headers[header::ETAG].to_str().unwrap().to_string(); | |
| 479 | + | let res = fetch(&s.app, &format!("/hist/commit/{}", s.h.head), Some(&etag)).await; | |
| 480 | + | assert_eq!(res.status, StatusCode::NOT_MODIFIED); | |
| 481 | + | ||
| 482 | + | let res = fetch(&s.app, "/hist/log/main", None).await; | |
| 483 | + | assert_eq!(res.headers[header::CACHE_CONTROL], "no-cache"); | |
| 484 | + | let etag = res.headers[header::ETAG].to_str().unwrap().to_string(); | |
| 485 | + | let res = fetch(&s.app, "/hist/log/main", Some(&etag)).await; | |
| 486 | + | assert_eq!(res.status, StatusCode::NOT_MODIFIED); | |
| 487 | + | // A different query is a different page. | |
| 488 | + | let res = fetch(&s.app, "/hist/log/main?q=x", Some(&etag)).await; | |
| 489 | + | assert_eq!(res.status, StatusCode::OK); | |
| 490 | + | ||
| 491 | + | // Commits by branch name and short id. | |
| 492 | + | let html = ok(&s.app, "/hist/commit/main").await; | |
| 493 | + | assert!(html.contains("Add mailmap")); | |
| 494 | + | let html = ok(&s.app, &format!("/hist/commit/{}", &s.h.tweak[..8])).await; | |
| 495 | + | assert!(html.contains("Tweak a.txt")); | |
| 496 | + | let res = fetch(&s.app, "/hist/commit/nope", None).await; | |
| 497 | + | assert_eq!(res.status, StatusCode::NOT_FOUND); | |
| 498 | + | let res = fetch(&s.app, "/hist/commit", None).await; | |
| 499 | + | assert_eq!(res.status, StatusCode::NOT_FOUND); | |
| 500 | + | ||
| 501 | + | // Links into history from other pages. | |
| 502 | + | let html = ok(&s.app, "/hist").await; | |
| 503 | + | assert!(html.contains(r#"<a href="/hist/log">"#), "{html}"); | |
| 504 | + | assert!(html.contains(&format!(r#"href="/hist/commit/{}""#, s.h.head))); | |
| 505 | + | let html = ok(&s.app, "/hist/blob/main/src/lib.rs").await; | |
| 506 | + | assert!(html.contains(r#"href="/hist/log/main/src/lib.rs">History<"#)); | |
| 507 | + | let html = ok(&s.app, "/hist/tree/main/src").await; | |
| 508 | + | assert!(html.contains(r#"href="/hist/log/main/src">History<"#)); | |
| 509 | + | } | |