| | @@ -40,8 +40,8 @@ use tokio::io::AsyncWriteExt; |
| 40 | 40 | use crate::{ |
| 41 | 41 | application::port::{Blob, GitQuery, GitQueryError}, |
| 42 | 42 | domain::{ |
| 43 | | − CommitSummary, EntryKind, GitRef, ObjectId, OrgName, RefKind, RefName, RepoName, RepoPath, |
| 44 | | − TagSummary, TreeEntry, |
| 43 | + BranchRow, CommitSummary, EntryKind, GitRef, ObjectId, OrgName, RefKind, RefName, RepoName, |
| 44 | + RepoPath, TagRow, TagSummary, TreeEntry, |
| 45 | 45 | }, |
| 46 | 46 | infrastructure::git::git_command, |
| 47 | 47 | }; |
| | @@ -349,6 +349,48 @@ impl GitQuery for DiskGitQuery { |
| 349 | 349 | |
| 350 | 350 | Ok(parse_latest_tag(&output.stdout)) |
| 351 | 351 | } |
| 352 | + |
| 353 | + async fn branches( |
| 354 | + &self, |
| 355 | + handle: &OrgName, |
| 356 | + name: &RepoName, |
| 357 | + ) -> Result<Vec<BranchRow>, GitQueryError> { |
| 358 | + let repo = self.repo_path(handle, name); |
| 359 | + |
| 360 | + // One process for the whole branches page. The sort is git's because it is |
| 361 | + // free there and would otherwise be a second pass in Rust over the same rows, |
| 362 | + // and `%(HEAD)` is what saves the page a `symbolic-ref` for the default branch. |
| 363 | + // Every argument is a literal — nothing from a URL reaches this call. |
| 364 | + let output = run( |
| 365 | + &repo, |
| 366 | + [ |
| 367 | + OsStr::new("for-each-ref"), |
| 368 | + OsStr::new("--sort=-committerdate"), |
| 369 | + OsStr::new(BRANCH_FORMAT), |
| 370 | + OsStr::new("refs/heads/"), |
| 371 | + ], |
| 372 | + ) |
| 373 | + .await?; |
| 374 | + |
| 375 | + parse_branches(&output.stdout) |
| 376 | + } |
| 377 | + |
| 378 | + async fn tags(&self, handle: &OrgName, name: &RepoName) -> Result<Vec<TagRow>, GitQueryError> { |
| 379 | + let repo = self.repo_path(handle, name); |
| 380 | + |
| 381 | + let output = run( |
| 382 | + &repo, |
| 383 | + [ |
| 384 | + OsStr::new("for-each-ref"), |
| 385 | + OsStr::new("--sort=-creatordate"), |
| 386 | + OsStr::new(TAG_ROW_FORMAT), |
| 387 | + OsStr::new("refs/tags/"), |
| 388 | + ], |
| 389 | + ) |
| 390 | + .await?; |
| 391 | + |
| 392 | + parse_tags(&output.stdout) |
| 393 | + } |
| 352 | 394 | } |
| 353 | 395 | |
| 354 | 396 | /// What `cat-file --batch-check` said about one object. |
| | @@ -672,6 +714,162 @@ fn parse_latest_tag(stdout: &[u8]) -> Option<TagSummary> { |
| 672 | 714 | }) |
| 673 | 715 | } |
| 674 | 716 | |
| 717 | +/// What the branches page asks for, per branch. |
| 718 | +/// |
| 719 | +/// `%(HEAD)` is git's own marker for the branch `HEAD` points at — `*` for it and a |
| 720 | +/// space for everything else. It is asked for here rather than resolved separately |
| 721 | +/// because a second process to learn one bit is the cost 0006 is about. |
| 722 | +/// |
| 723 | +/// `%(objectname)` is the tip commit itself: a branch, unlike a tag, never points at |
| 724 | +/// anything else. |
| 725 | +const BRANCH_FORMAT: &str = "--format=%(refname)%00%(HEAD)%00%(objectname)%00%(committerdate:unix)%00%(contents:subject)%00"; |
| 726 | + |
| 727 | +/// What the tags page asks for, per tag. |
| 728 | +/// |
| 729 | +/// `%(objecttype)` is `tag` for an annotated tag and `commit` for a lightweight one, |
| 730 | +/// which is the only reliable way to tell them apart. `%(*objectname)` is the peeled |
| 731 | +/// object and is empty for a lightweight tag, so the commit is "the peeled one if there |
| 732 | +/// is one". `%(contents:subject)` is the *tag's* message for an annotated tag and the |
| 733 | +/// *commit's* for a lightweight one — so it is read only when the type says `tag`, |
| 734 | +/// otherwise a lightweight tag would appear to carry a message it does not have. |
| 735 | +const TAG_ROW_FORMAT: &str = "--format=%(refname)%00%(objecttype)%00%(objectname)%00%(*objectname)%00%(creatordate:unix)%00%(contents:subject)%00"; |
| 736 | + |
| 737 | +/// Splits `for-each-ref` output into its NUL-terminated fields. |
| 738 | +/// |
| 739 | +/// Every field ends with a NUL and git adds a newline after each record that the format |
| 740 | +/// cannot suppress, so the split yields exactly one field per `%00` plus a trailing |
| 741 | +/// remainder holding that last newline — dropped here. |
| 742 | +/// |
| 743 | +/// **Empty fields are kept.** A lightweight tag has no peeled object, and filtering |
| 744 | +/// empties the way [`parse_latest_tag`] can afford to would shift every later field of |
| 745 | +/// that record onto the wrong name. |
| 746 | +fn ref_fields(stdout: &[u8]) -> Vec<&[u8]> { |
| 747 | + let mut fields: Vec<&[u8]> = stdout.split(|byte| *byte == 0).collect(); |
| 748 | + fields.pop(); |
| 749 | + fields |
| 750 | +} |
| 751 | + |
| 752 | +/// The first line of git's subject, or `None` when there is nothing to show. |
| 753 | +/// |
| 754 | +/// `%(contents:subject)` is already one line, but that is git's invariant rather than |
| 755 | +/// something this parser should assume — the same reason [`parse_log`] trims `%s`. |
| 756 | +fn subject(field: &[u8]) -> Option<String> { |
| 757 | + let line = String::from_utf8_lossy(field) |
| 758 | + .lines() |
| 759 | + .next() |
| 760 | + .unwrap_or_default() |
| 761 | + .trim() |
| 762 | + .to_owned(); |
| 763 | + |
| 764 | + (!line.is_empty()).then_some(line) |
| 765 | +} |
| 766 | + |
| 767 | +/// Parses [`BRANCH_FORMAT`] into rows, in the order git sorted them. |
| 768 | +/// |
| 769 | +/// A record whose name or commit id Steid cannot use is skipped rather than failing the |
| 770 | +/// page, exactly as [`parse_refs`] skips one: a branch that cannot be linked to is a |
| 771 | +/// reason to leave a row out, not to refuse the whole list. A record with the wrong |
| 772 | +/// number of fields is different — that is git saying something this code does not |
| 773 | +/// understand, and it is an error. |
| 774 | +fn parse_branches(stdout: &[u8]) -> Result<Vec<BranchRow>, GitQueryError> { |
| 775 | + let fields = ref_fields(stdout); |
| 776 | + let mut rows = Vec::with_capacity(fields.len() / 5); |
| 777 | + |
| 778 | + for record in fields.chunks(5) { |
| 779 | + let [name, head, commit, committed_at, summary] = record[..] else { |
| 780 | + return Err(GitQueryError::new( |
| 781 | + "git listed a branch with missing fields", |
| 782 | + )); |
| 783 | + }; |
| 784 | + |
| 785 | + // git's trailing newline arrives in front of the next record's first field. |
| 786 | + // A ref name can contain neither a newline nor a space, so trimming cannot eat |
| 787 | + // part of one. |
| 788 | + let Some(name) = short_ref(name.trim_ascii(), "refs/heads/") else { |
| 789 | + continue; |
| 790 | + }; |
| 791 | + |
| 792 | + let Ok(commit) = ObjectId::new(String::from_utf8_lossy(commit).trim()) else { |
| 793 | + continue; |
| 794 | + }; |
| 795 | + |
| 796 | + let committed_at = String::from_utf8_lossy(committed_at); |
| 797 | + let Ok(committed_at) = committed_at.trim().parse::<i64>() else { |
| 798 | + continue; |
| 799 | + }; |
| 800 | + |
| 801 | + rows.push(BranchRow { |
| 802 | + name, |
| 803 | + // `*` for the branch HEAD names, a space for the rest. |
| 804 | + is_default: head.trim_ascii() == b"*", |
| 805 | + commit, |
| 806 | + summary: subject(summary).unwrap_or_default(), |
| 807 | + committed_at: unix_time(committed_at), |
| 808 | + }); |
| 809 | + } |
| 810 | + |
| 811 | + Ok(rows) |
| 812 | +} |
| 813 | + |
| 814 | +/// Parses [`TAG_ROW_FORMAT`] into rows, in the order git sorted them. |
| 815 | +/// |
| 816 | +/// Skips and errors on the same terms as [`parse_branches`]. |
| 817 | +fn parse_tags(stdout: &[u8]) -> Result<Vec<TagRow>, GitQueryError> { |
| 818 | + let fields = ref_fields(stdout); |
| 819 | + let mut rows = Vec::with_capacity(fields.len() / 6); |
| 820 | + |
| 821 | + for record in fields.chunks(6) { |
| 822 | + let [name, kind, object, peeled, created_at, message] = record[..] else { |
| 823 | + return Err(GitQueryError::new("git listed a tag with missing fields")); |
| 824 | + }; |
| 825 | + |
| 826 | + let Some(name) = short_ref(name.trim_ascii(), "refs/tags/") else { |
| 827 | + continue; |
| 828 | + }; |
| 829 | + |
| 830 | + // An annotated tag's `objectname` is the tag object, so the thing worth linking |
| 831 | + // to is the peeled one. A lightweight tag has no peel and already names its |
| 832 | + // commit. |
| 833 | + let annotated = kind.trim_ascii() == b"tag"; |
| 834 | + let id = if peeled.trim_ascii().is_empty() { |
| 835 | + object |
| 836 | + } else { |
| 837 | + peeled |
| 838 | + }; |
| 839 | + |
| 840 | + let Ok(commit) = ObjectId::new(String::from_utf8_lossy(id).trim()) else { |
| 841 | + continue; |
| 842 | + }; |
| 843 | + |
| 844 | + let created_at = String::from_utf8_lossy(created_at); |
| 845 | + let Ok(created_at) = created_at.trim().parse::<i64>() else { |
| 846 | + continue; |
| 847 | + }; |
| 848 | + |
| 849 | + rows.push(TagRow { |
| 850 | + name, |
| 851 | + commit, |
| 852 | + // Only an annotated tag has a message of its own; for a lightweight one |
| 853 | + // this field is the commit's subject, which belongs to the commit. |
| 854 | + message: annotated.then(|| subject(message)).flatten(), |
| 855 | + annotated, |
| 856 | + created_at: unix_time(created_at), |
| 857 | + }); |
| 858 | + } |
| 859 | + |
| 860 | + Ok(rows) |
| 861 | +} |
| 862 | + |
| 863 | +/// A full ref name reduced to the short form Steid puts in a URL, or `None` when it is |
| 864 | +/// outside the namespace asked for or is not a name Steid will hand back to git. |
| 865 | +/// |
| 866 | +/// Validated rather than trusted for the reason [`parse_refs`] gives: this name is |
| 867 | +/// about to become a link. |
| 868 | +fn short_ref(full: &[u8], namespace: &str) -> Option<RefName> { |
| 869 | + let full = std::str::from_utf8(full).ok()?; |
| 870 | + RefName::new(full.strip_prefix(namespace)?).ok() |
| 871 | +} |
| 872 | + |
| 675 | 873 | /// Runs a git command inside a repository and fails on a non-zero exit. |
| 676 | 874 | /// |
| 677 | 875 | /// Only ever used for commands whose subject has already been confirmed to exist, so a |
| | @@ -1785,6 +1983,244 @@ mod tests { |
| 1785 | 1983 | ); |
| 1786 | 1984 | } |
| 1787 | 1985 | |
| 1986 | + // --- branches and tags --------------------------------------------------------- |
| 1987 | + |
| 1988 | + /// The populated repository with two more branches, each left at an older commit so |
| 1989 | + /// the three tips carry three different dates — otherwise "newest first" is not |
| 1990 | + /// something a test can see. |
| 1991 | + fn with_branches() -> (TempDir, DiskGitQuery) { |
| 1992 | + let (dir, query) = populated(); |
| 1993 | + let repo = query.repo_path(&handle(), &repo_name()); |
| 1994 | + let work = dir.path().join("work"); |
| 1995 | + let target = repo.to_str().expect("utf-8 fixture path").to_owned(); |
| 1996 | + |
| 1997 | + git(&work, THIRD_COMMIT, &["branch", "stale", "main~2"]); |
| 1998 | + // A slash in the name, because that is the case the `/-/` separator exists for. |
| 1999 | + git(&work, THIRD_COMMIT, &["branch", "feature/login", "main~1"]); |
| 2000 | + git( |
| 2001 | + &work, |
| 2002 | + THIRD_COMMIT, |
| 2003 | + &["push", "--quiet", &target, "stale", "feature/login"], |
| 2004 | + ); |
| 2005 | + |
| 2006 | + (dir, query) |
| 2007 | + } |
| 2008 | + |
| 2009 | + /// The populated repository with one lightweight tag and two annotated ones, made |
| 2010 | + /// on three different dates so ordering and the annotated/lightweight split can be |
| 2011 | + /// asserted together. |
| 2012 | + fn with_mixed_tags() -> (TempDir, DiskGitQuery) { |
| 2013 | + let (dir, query) = populated(); |
| 2014 | + let repo = query.repo_path(&handle(), &repo_name()); |
| 2015 | + let work = dir.path().join("work"); |
| 2016 | + let target = repo.to_str().expect("utf-8 fixture path").to_owned(); |
| 2017 | + |
| 2018 | + // Lightweight: no object of its own, so its date is the commit's. |
| 2019 | + git(&work, FIRST_COMMIT, &["tag", "v0.5", "main~2"]); |
| 2020 | + git( |
| 2021 | + &work, |
| 2022 | + SECOND_COMMIT, |
| 2023 | + &["tag", "-a", "v1.0", "-m", "first release"], |
| 2024 | + ); |
| 2025 | + git( |
| 2026 | + &work, |
| 2027 | + THIRD_COMMIT, |
| 2028 | + &["tag", "-a", "v2.0", "-m", "second release\n\nnotes below"], |
| 2029 | + ); |
| 2030 | + git(&work, THIRD_COMMIT, &["push", "--quiet", &target, "--tags"]); |
| 2031 | + |
| 2032 | + (dir, query) |
| 2033 | + } |
| 2034 | + |
| 2035 | + #[tokio::test] |
| 2036 | + async fn branches_are_newest_first_with_the_default_marked() { |
| 2037 | + let (_dir, query) = with_branches(); |
| 2038 | + |
| 2039 | + let rows = query |
| 2040 | + .branches(&handle(), &repo_name()) |
| 2041 | + .await |
| 2042 | + .expect("should read"); |
| 2043 | + |
| 2044 | + assert_eq!( |
| 2045 | + rows.iter().map(|row| row.name.as_str()).collect::<Vec<_>>(), |
| 2046 | + vec!["main", "feature/login", "stale"] |
| 2047 | + ); |
| 2048 | + // `%(HEAD)` marks exactly one branch, and it is the one a bare repository's |
| 2049 | + // HEAD names — which is what the page pins to the top. |
| 2050 | + assert_eq!( |
| 2051 | + rows.iter() |
| 2052 | + .filter(|row| row.is_default) |
| 2053 | + .map(|row| row.name.as_str()) |
| 2054 | + .collect::<Vec<_>>(), |
| 2055 | + vec!["main"] |
| 2056 | + ); |
| 2057 | + } |
| 2058 | + |
| 2059 | + #[tokio::test] |
| 2060 | + async fn a_branch_row_carries_its_tip_commit() { |
| 2061 | + let (_dir, query) = with_branches(); |
| 2062 | + |
| 2063 | + let rows = query |
| 2064 | + .branches(&handle(), &repo_name()) |
| 2065 | + .await |
| 2066 | + .expect("should read"); |
| 2067 | + |
| 2068 | + let main = rows.first().expect("main is first"); |
| 2069 | + |
| 2070 | + // The subject only, from a message whose body would leak into it if the format |
| 2071 | + // were read line-wise. |
| 2072 | + assert_eq!(main.summary, "third: 'quotes', \"doubles\" | pipes"); |
| 2073 | + assert_eq!(main.committed_at, unix_time(THIRD_COMMIT)); |
| 2074 | + assert_eq!(main.commit.as_str().len(), 40); |
| 2075 | + |
| 2076 | + let stale = rows.last().expect("stale is last"); |
| 2077 | + assert_eq!(stale.summary, "first"); |
| 2078 | + assert_eq!(stale.committed_at, unix_time(FIRST_COMMIT)); |
| 2079 | + } |
| 2080 | + |
| 2081 | + #[tokio::test] |
| 2082 | + async fn an_empty_repository_has_no_branches() { |
| 2083 | + // The same answer `list_refs` gives, and for the same reason: nothing pushed |
| 2084 | + // yet is not a failure. It is also how the page knows to show the push snippet. |
| 2085 | + let (_dir, query) = empty(); |
| 2086 | + |
| 2087 | + assert_eq!( |
| 2088 | + query |
| 2089 | + .branches(&handle(), &repo_name()) |
| 2090 | + .await |
| 2091 | + .expect("should read"), |
| 2092 | + Vec::new() |
| 2093 | + ); |
| 2094 | + } |
| 2095 | + |
| 2096 | + #[tokio::test] |
| 2097 | + async fn tags_are_newest_first_and_only_annotated_ones_carry_a_message() { |
| 2098 | + let (_dir, query) = with_mixed_tags(); |
| 2099 | + |
| 2100 | + let rows = query |
| 2101 | + .tags(&handle(), &repo_name()) |
| 2102 | + .await |
| 2103 | + .expect("should read"); |
| 2104 | + |
| 2105 | + assert_eq!( |
| 2106 | + rows.iter().map(|row| row.name.as_str()).collect::<Vec<_>>(), |
| 2107 | + vec!["v2.0", "v1.0", "v0.5"] |
| 2108 | + ); |
| 2109 | + |
| 2110 | + let newest = &rows[0]; |
| 2111 | + assert!(newest.annotated); |
| 2112 | + // The subject of the tag's own message, not its body. |
| 2113 | + assert_eq!(newest.message.as_deref(), Some("second release")); |
| 2114 | + assert_eq!(newest.created_at, unix_time(THIRD_COMMIT)); |
| 2115 | + |
| 2116 | + let lightweight = &rows[2]; |
| 2117 | + assert!(!lightweight.annotated); |
| 2118 | + // A lightweight tag has no message of its own; the commit's subject is the |
| 2119 | + // commit's, and reporting it would invent one. |
| 2120 | + assert_eq!(lightweight.message, None); |
| 2121 | + assert_eq!(lightweight.created_at, unix_time(FIRST_COMMIT)); |
| 2122 | + } |
| 2123 | + |
| 2124 | + #[tokio::test] |
| 2125 | + async fn an_annotated_tag_reports_the_commit_it_peels_to() { |
| 2126 | + // Its `objectname` is the tag object, which is not what a visitor browses. |
| 2127 | + let (_dir, query) = with_mixed_tags(); |
| 2128 | + |
| 2129 | + let tip = query |
| 2130 | + .branches(&handle(), &repo_name()) |
| 2131 | + .await |
| 2132 | + .expect("should read") |
| 2133 | + .into_iter() |
| 2134 | + .find(|row| row.name.as_str() == "main") |
| 2135 | + .expect("main"); |
| 2136 | + |
| 2137 | + let annotated = query |
| 2138 | + .tags(&handle(), &repo_name()) |
| 2139 | + .await |
| 2140 | + .expect("should read") |
| 2141 | + .into_iter() |
| 2142 | + .find(|row| row.name.as_str() == "v1.0") |
| 2143 | + .expect("v1.0"); |
| 2144 | + |
| 2145 | + assert_eq!(annotated.commit, tip.commit); |
| 2146 | + } |
| 2147 | + |
| 2148 | + #[tokio::test] |
| 2149 | + async fn a_repository_with_no_tags_lists_none() { |
| 2150 | + let (_dir, query) = populated(); |
| 2151 | + assert_eq!( |
| 2152 | + query |
| 2153 | + .tags(&handle(), &repo_name()) |
| 2154 | + .await |
| 2155 | + .expect("should read"), |
| 2156 | + Vec::new() |
| 2157 | + ); |
| 2158 | + |
| 2159 | + let (_dir, empty_query) = empty(); |
| 2160 | + assert_eq!( |
| 2161 | + empty_query |
| 2162 | + .tags(&handle(), &repo_name()) |
| 2163 | + .await |
| 2164 | + .expect("should read"), |
| 2165 | + Vec::new() |
| 2166 | + ); |
| 2167 | + } |
| 2168 | + |
| 2169 | + #[tokio::test] |
| 2170 | + async fn listing_rows_of_a_repository_that_is_not_on_disk_is_an_error() { |
| 2171 | + let (_dir, query) = empty(); |
| 2172 | + let missing = RepoName::new("never-created").expect("valid repository name"); |
| 2173 | + |
| 2174 | + assert!(query.branches(&handle(), &missing).await.is_err()); |
| 2175 | + assert!(query.tags(&handle(), &missing).await.is_err()); |
| 2176 | + } |
| 2177 | + |
| 2178 | + #[test] |
| 2179 | + fn branch_records_survive_the_newline_git_puts_between_them() { |
| 2180 | + let rows = parse_branches( |
| 2181 | + b"refs/heads/main\x00*\x00aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\x001700000000\x00first\x00\nrefs/heads/side\x00 \x00bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\x001700000100\x00second\x00\n", |
| 2182 | + ) |
| 2183 | + .expect("should parse"); |
| 2184 | + |
| 2185 | + assert_eq!(rows.len(), 2); |
| 2186 | + assert!(rows[0].is_default); |
| 2187 | + assert_eq!(rows[0].summary, "first"); |
| 2188 | + // The newline in front of `refs/heads/side` is git's record separator, not part |
| 2189 | + // of the name. |
| 2190 | + assert_eq!(rows[1].name.as_str(), "side"); |
| 2191 | + assert!(!rows[1].is_default); |
| 2192 | + assert_eq!(rows[1].committed_at, unix_time(1_700_000_100)); |
| 2193 | + } |
| 2194 | + |
| 2195 | + #[test] |
| 2196 | + fn a_lightweight_tags_empty_peel_does_not_shift_the_fields_after_it() { |
| 2197 | + // The reason `ref_fields` keeps empty fields: filtering them would read this |
| 2198 | + // record's date as its commit id. |
| 2199 | + let rows = parse_tags( |
| 2200 | + b"refs/tags/v1.0\x00commit\x00aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\x00\x001700000000\x00a commit subject\x00\n", |
| 2201 | + ) |
| 2202 | + .expect("should parse"); |
| 2203 | + |
| 2204 | + assert_eq!(rows.len(), 1); |
| 2205 | + assert_eq!(rows[0].name.as_str(), "v1.0"); |
| 2206 | + assert_eq!(rows[0].commit.as_str(), "a".repeat(40)); |
| 2207 | + assert!(!rows[0].annotated); |
| 2208 | + assert_eq!(rows[0].message, None); |
| 2209 | + assert_eq!(rows[0].created_at, unix_time(1_700_000_000)); |
| 2210 | + } |
| 2211 | + |
| 2212 | + #[test] |
| 2213 | + fn nothing_is_parsed_from_an_empty_row_listing() { |
| 2214 | + assert_eq!(parse_branches(b"").expect("should parse"), Vec::new()); |
| 2215 | + assert_eq!(parse_tags(b"").expect("should parse"), Vec::new()); |
| 2216 | + } |
| 2217 | + |
| 2218 | + #[test] |
| 2219 | + fn a_record_with_the_wrong_number_of_fields_is_a_fault() { |
| 2220 | + // Skipping a ref Steid cannot link to is right; misreading git's output is not. |
| 2221 | + assert!(parse_branches(b"refs/heads/main\x00*\x00\n").is_err()); |
| 2222 | + } |
| 2223 | + |
| 1788 | 2224 | // --- helpers ------------------------------------------------------------------ |
| 1789 | 2225 | |
| 1790 | 2226 | #[tokio::test] |