max / makenotwork
- Co-Authored-By
- Claude Opus 5 (1M context) <noreply@anthropic.com>
9 files changed,
+303 insertions,
-7 deletions
| @@ -7202,6 +7202,27 @@ | |||
| 7202 | 7202 | } | |
| 7203 | 7203 | .git-tag-meta a { color: var(--content); text-decoration: none; } | |
| 7204 | 7204 | .git-tag-meta a:hover { text-decoration: underline; } | |
| 7205 | + | /* refs/replace/*: object substitutions, listed but not applied while browsing */ | |
| 7206 | + | .git-replace-intro { padding-bottom: var(--gap-section); font-size: var(--text-note); } | |
| 7207 | + | .git-replace-intro p { margin: 0; } | |
| 7208 | + | .git-replace-notice { | |
| 7209 | + | font-size: var(--text-note); | |
| 7210 | + | border-left: 2px solid var(--border); | |
| 7211 | + | padding: var(--gap-peer) var(--gap-section); | |
| 7212 | + | margin: 0 0 var(--gap-section); | |
| 7213 | + | } | |
| 7214 | + | .git-replace-table { width: 100%; border-collapse: collapse; font-size: var(--text-note); } | |
| 7215 | + | .git-replace-table th { | |
| 7216 | + | text-align: left; | |
| 7217 | + | font-weight: 400; | |
| 7218 | + | opacity: 0.5; | |
| 7219 | + | font-size: var(--text-fine); | |
| 7220 | + | padding: var(--gap-bound) var(--gap-section) var(--gap-bound) 0; | |
| 7221 | + | border-bottom: 1px solid var(--border); | |
| 7222 | + | } | |
| 7223 | + | .git-replace-table td { padding: var(--gap-peer) var(--gap-section) var(--gap-peer) 0; border-bottom: 1px solid var(--border); } | |
| 7224 | + | .git-replace-table a { color: var(--content); text-decoration: none; } | |
| 7225 | + | .git-replace-table a:hover { text-decoration: underline; } | |
| 7205 | 7226 | /* Git notes (refs/notes/*) shown against the object they annotate */ | |
| 7206 | 7227 | .git-notes { margin-bottom: var(--gap-pane); } | |
| 7207 | 7228 | .git-note { |
| @@ -97,6 +97,18 @@ | |||
| 97 | 97 | pub message: Option<String>, | |
| 98 | 98 | } | |
| 99 | 99 | ||
| 100 | + | /// One `refs/replace/<source>` mapping: the object git would serve in place of | |
| 101 | + | /// another. | |
| 102 | + | pub struct Replacement { | |
| 103 | + | pub source: String, | |
| 104 | + | pub source_short: String, | |
| 105 | + | pub target: String, | |
| 106 | + | pub target_short: String, | |
| 107 | + | /// Kind of the replacement object, `None` if it is missing from the object | |
| 108 | + | /// database. A dangling replacement is worth showing rather than hiding. | |
| 109 | + | pub target_kind: Option<&'static str>, | |
| 110 | + | } | |
| 111 | + | ||
| 100 | 112 | /// Full commit detail for the commit page. | |
| 101 | 113 | pub struct CommitDetail { | |
| 102 | 114 | pub oid: String, | |
| @@ -293,6 +305,21 @@ | |||
| 293 | 305 | /// Opened with `isolated()` options, so no ambient git configuration (the | |
| 294 | 306 | /// service account's `~/.gitconfig`, `/etc/gitconfig`, `GIT_*` environment | |
| 295 | 307 | /// variables) can change how a visitor's repository renders. | |
| 308 | + | /// | |
| 309 | + | /// `refs/replace/*` is NOT applied here, so a visitor is shown the objects the | |
| 310 | + | /// repository actually stores rather than substitutes for them. That is what a | |
| 311 | + | /// forge should do, and it is also what gitoxide does on its own: with | |
| 312 | + | /// `core.useReplaceRefs` unset it loads no replacements at all, unlike git, | |
| 313 | + | /// which applies them by default. No config override is set, because the | |
| 314 | + | /// setting does not mean what its name suggests — in `gix` 0.86, | |
| 315 | + | /// `open::repository::replacement_objects_refs_prefix` treats an unset value as | |
| 316 | + | /// disabled and an explicit `false` as *enabled*, so writing the override that | |
| 317 | + | /// reads as "off" would turn substitution on. | |
| 318 | + | /// | |
| 319 | + | /// `replacements_are_listed_but_not_applied_while_browsing` locks the behaviour | |
| 320 | + | /// in, so a gitoxide release that changes it fails a test here rather than | |
| 321 | + | /// quietly rewriting what visitors read. The mappings are not hidden either | |
| 322 | + | /// way: [`list_replacements`] renders every one at `/git/{owner}/{repo}/replace`. | |
| 296 | 323 | pub(crate) fn open_gix_repo_at(repo_path: &Path) -> Result<gix::Repository, GitError> { | |
| 297 | 324 | gix::open_opts(repo_path, gix::open::Options::isolated()).map_err(|_| GitError::RepoNotFound) | |
| 298 | 325 | } | |
| @@ -1043,6 +1070,51 @@ | |||
| 1043 | 1070 | assert_eq!(tags[0].name, "v1.0.0"); | |
| 1044 | 1071 | } | |
| 1045 | 1072 | ||
| 1073 | + | #[test] | |
| 1074 | + | fn replacements_are_listed_but_not_applied_while_browsing() { | |
| 1075 | + | // The property that matters, and the reason it is pinned by a test: | |
| 1076 | + | // whether a replacement is applied is decided by the gitoxide version, | |
| 1077 | + | // not by anything in this file. Git applies them by default and gix | |
| 1078 | + | // does not; if that flips, a visitor starts reading substituted objects | |
| 1079 | + | // with nothing on the page saying so, and this fails first. | |
| 1080 | + | use gix::objs::tree::EntryKind; | |
| 1081 | + | ||
| 1082 | + | let (_tmp, bare_path, repo) = init_test_repo("replaced"); | |
| 1083 | + | ||
| 1084 | + | let original = repo.write_blob(b"the real content\n").unwrap().detach(); | |
| 1085 | + | let tree = write_tree(&repo, &[("x.txt", original, EntryKind::Blob)]); | |
| 1086 | + | let head = commit_main(&repo, "add", tree, Vec::new()); | |
| 1087 | + | ||
| 1088 | + | let substitute = repo.write_blob(b"the substitute\n").unwrap().detach(); | |
| 1089 | + | repo.reference( | |
| 1090 | + | format!("refs/replace/{original}"), | |
| 1091 | + | substitute, | |
| 1092 | + | gix::refs::transaction::PreviousValue::MustNotExist, | |
| 1093 | + | "replace the blob", | |
| 1094 | + | ) | |
| 1095 | + | .unwrap(); | |
| 1096 | + | ||
| 1097 | + | let repo = open_gix_repo_at(&bare_path).unwrap(); | |
| 1098 | + | ||
| 1099 | + | // Listed, with both ends and the kind of what would be served. | |
| 1100 | + | let replacements = list_replacements(&repo); | |
| 1101 | + | assert_eq!(replacements.len(), 1); | |
| 1102 | + | assert_eq!(replacements[0].source, original.to_string()); | |
| 1103 | + | assert_eq!(replacements[0].target, substitute.to_string()); | |
| 1104 | + | assert_eq!(replacements[0].target_kind, Some("blob")); | |
| 1105 | + | ||
| 1106 | + | // Not applied: the file view shows what the repository stores. | |
| 1107 | + | let file = read_file(&repo, head, "x.txt").unwrap(); | |
| 1108 | + | assert_eq!(file.content, "the real content\n"); | |
| 1109 | + | } | |
| 1110 | + | ||
| 1111 | + | #[test] | |
| 1112 | + | fn a_repository_without_replacements_lists_none() { | |
| 1113 | + | let (_tmp, bare_path) = make_test_repo(); | |
| 1114 | + | let repo = open_gix(&bare_path); | |
| 1115 | + | assert!(list_replacements(&repo).is_empty()); | |
| 1116 | + | } | |
| 1117 | + | ||
| 1046 | 1118 | #[test] | |
| 1047 | 1119 | fn syntax_highlighter_falls_back_to_plain() { | |
| 1048 | 1120 | let hl = SyntaxHighlighter::new(); |
| @@ -2,7 +2,12 @@ | |||
| 2 | 2 | ||
| 3 | 3 | use gix::{ObjectId, Repository, bstr::ByteSlice}; | |
| 4 | 4 | ||
| 5 | - | use super::{GitError, RefInfo, TagInfo}; | |
| 5 | + | use super::{GitError, RefInfo, Replacement, TagInfo}; | |
| 6 | + | ||
| 7 | + | /// Where git keeps object replacements. Configurable in git through | |
| 8 | + | /// `gitoxide.objects.replaceRefBase`, but the browser opens repositories | |
| 9 | + | /// isolated from ambient config, so the standard prefix is the only one. | |
| 10 | + | const REPLACE_PREFIX: &str = "refs/replace/"; | |
| 6 | 11 | ||
| 7 | 12 | /// Resolve a ref name to a commit OID. Tries branch, tag, raw OID, then revparse. | |
| 8 | 13 | pub fn resolve_ref(repo: &Repository, refname: &str) -> Result<ObjectId, GitError> { | |
| @@ -155,3 +160,63 @@ | |||
| 155 | 160 | }); | |
| 156 | 161 | out | |
| 157 | 162 | } | |
| 163 | + | ||
| 164 | + | /// List the repository's object replacements, `refs/replace/<source>` naming | |
| 165 | + | /// the object served in place of `<source>`. | |
| 166 | + | /// | |
| 167 | + | /// Nothing else renders these. They are a real git mechanism with no UI in any | |
| 168 | + | /// forge, and the reason that matters is that a replacement rewrites what every | |
| 169 | + | /// read of the source object returns: a commit page, a diff, a blame. Browsing | |
| 170 | + | /// does not apply them (see [`super::open_gix_repo_at`]), so a reader who clones | |
| 171 | + | /// gets a different object graph from the one these pages show, and this listing | |
| 172 | + | /// is the only place that difference is visible. | |
| 173 | + | pub fn list_replacements(repo: &Repository) -> Vec<Replacement> { | |
| 174 | + | let Ok(platform) = repo.references() else { | |
| 175 | + | return Vec::new(); | |
| 176 | + | }; | |
| 177 | + | let Ok(refs) = platform.prefixed(REPLACE_PREFIX) else { | |
| 178 | + | return Vec::new(); | |
| 179 | + | }; | |
| 180 | + | ||
| 181 | + | let mut out: Vec<Replacement> = Vec::new(); | |
| 182 | + | ||
| 183 | + | for reference in refs.flatten() { | |
| 184 | + | let full_name = reference.name().as_bstr().to_str_lossy().into_owned(); | |
| 185 | + | // The source object id is the ref name past the prefix, which is how | |
| 186 | + | // git addresses a replacement; it need not exist in the object database | |
| 187 | + | // any more, so it is never looked up. | |
| 188 | + | let Some(source) = full_name.strip_prefix(REPLACE_PREFIX) else { | |
| 189 | + | continue; | |
| 190 | + | }; | |
| 191 | + | let Some(target) = reference.target().try_id().map(ToOwned::to_owned) else { | |
| 192 | + | // A symbolic ref under refs/replace is malformed. Git ignores it and | |
| 193 | + | // so does the listing, rather than showing half a mapping. | |
| 194 | + | continue; | |
| 195 | + | }; | |
| 196 | + | ||
| 197 | + | let target = target.to_string(); | |
| 198 | + | out.push(Replacement { | |
| 199 | + | source_short: source.chars().take(7).collect(), | |
| 200 | + | source: source.to_string(), | |
| 201 | + | target_short: target.chars().take(7).collect(), | |
| 202 | + | target_kind: repo | |
| 203 | + | .find_object(reference.id()) | |
| 204 | + | .ok() | |
| 205 | + | .map(|o| object_kind_label(o.kind)), | |
| 206 | + | target, | |
| 207 | + | }); | |
| 208 | + | } | |
| 209 | + | ||
| 210 | + | out.sort_by(|a, b| a.source.cmp(&b.source)); | |
| 211 | + | out | |
| 212 | + | } | |
| 213 | + | ||
| 214 | + | /// Human label for an object kind, as the replacement listing prints it. | |
| 215 | + | fn object_kind_label(kind: gix::object::Kind) -> &'static str { | |
| 216 | + | match kind { | |
| 217 | + | gix::object::Kind::Commit => "commit", | |
| 218 | + | gix::object::Kind::Tree => "tree", | |
| 219 | + | gix::object::Kind::Blob => "blob", | |
| 220 | + | gix::object::Kind::Tag => "tag", | |
| 221 | + | } | |
| 222 | + | } |
| @@ -275,6 +275,7 @@ | |||
| 275 | 275 | GitCommitDetailTemplate, | |
| 276 | 276 | GitNotesTemplate, | |
| 277 | 277 | GitTagsTemplate, | |
| 278 | + | GitReplaceTemplate, | |
| 278 | 279 | GitBlameTemplate, | |
| 279 | 280 | GitUserReposTemplate, | |
| 280 | 281 | GitExploreTemplate, |
| @@ -21,8 +21,8 @@ | |||
| 21 | 21 | helpers::get_csrf_token, | |
| 22 | 22 | templates::{ | |
| 23 | 23 | GitBlameTemplate, GitCommitDetailTemplate, GitCommitsTemplate, GitExploreTemplate, | |
| 24 | - | GitFileLogTemplate, GitFileTemplate, GitNotesTemplate, GitRepoTemplate, GitTagsTemplate, | |
| 25 | - | GitTreeTemplate, GitUserReposTemplate, | |
| 24 | + | GitFileLogTemplate, GitFileTemplate, GitNotesTemplate, GitReplaceTemplate, GitRepoTemplate, | |
| 25 | + | GitTagsTemplate, GitTreeTemplate, GitUserReposTemplate, | |
| 26 | 26 | }, | |
| 27 | 27 | }; | |
| 28 | 28 | ||
| @@ -49,14 +49,15 @@ | |||
| 49 | 49 | ) | |
| 50 | 50 | .await?; | |
| 51 | 51 | let repo_name_c = repo_name.clone(); | |
| 52 | - | let (info, refs, tree_items, readme_html) = resolved | |
| 52 | + | let (info, refs, tree_items, readme_html, has_replacements) = resolved | |
| 53 | 53 | .with_repo(move |gix_repo| { | |
| 54 | 54 | let info = git::repo_info(gix_repo, &repo_name_c); | |
| 55 | 55 | let refs = git::list_refs(gix_repo); | |
| 56 | 56 | let commit_oid = git::resolve_ref(gix_repo, &info.default_branch)?; | |
| 57 | 57 | let tree_items = git::list_tree(gix_repo, commit_oid, "")?; | |
| 58 | 58 | let readme_html = git::find_readme(gix_repo, commit_oid); | |
| 59 | - | Ok((info, refs, tree_items, readme_html)) | |
| 59 | + | let has_replacements = !git::list_replacements(gix_repo).is_empty(); | |
| 60 | + | Ok((info, refs, tree_items, readme_html, has_replacements)) | |
| 60 | 61 | }) | |
| 61 | 62 | .await?; | |
| 62 | 63 | ||
| @@ -101,6 +102,7 @@ | |||
| 101 | 102 | linked_project, | |
| 102 | 103 | release_items, | |
| 103 | 104 | open_issue_count, | |
| 105 | + | has_replacements, | |
| 104 | 106 | is_owner, | |
| 105 | 107 | issues_muted, | |
| 106 | 108 | repo_id: resolved.db_repo.id.to_string(), | |
| @@ -126,13 +128,14 @@ | |||
| 126 | 128 | ) | |
| 127 | 129 | .await?; | |
| 128 | 130 | let git_ref_c = git_ref.clone(); | |
| 129 | - | let (refs, tree_items, readme_html) = resolved | |
| 131 | + | let (refs, tree_items, readme_html, has_replacements) = resolved | |
| 130 | 132 | .with_repo(move |gix_repo| { | |
| 131 | 133 | let refs = git::list_refs(gix_repo); | |
| 132 | 134 | let commit_oid = git::resolve_ref(gix_repo, &git_ref_c)?; | |
| 133 | 135 | let tree_items = git::list_tree(gix_repo, commit_oid, "")?; | |
| 134 | 136 | let readme_html = git::find_readme(gix_repo, commit_oid); | |
| 135 | - | Ok((refs, tree_items, readme_html)) | |
| 137 | + | let has_replacements = !git::list_replacements(gix_repo).is_empty(); | |
| 138 | + | Ok((refs, tree_items, readme_html, has_replacements)) | |
| 136 | 139 | }) | |
| 137 | 140 | .await?; | |
| 138 | 141 | ||
| @@ -179,6 +182,7 @@ | |||
| 179 | 182 | linked_project, | |
| 180 | 183 | release_items, | |
| 181 | 184 | open_issue_count, | |
| 185 | + | has_replacements, | |
| 182 | 186 | is_owner, | |
| 183 | 187 | issues_muted, | |
| 184 | 188 | repo_id: resolved.db_repo.id.to_string(), | |
| @@ -623,6 +627,54 @@ | |||
| 623 | 627 | }) | |
| 624 | 628 | } | |
| 625 | 629 | ||
| 630 | + | /// `GET /git/{owner}/{repo}/replace`: the repo's `refs/replace/*` mappings. | |
| 631 | + | #[tracing::instrument(skip_all, name = "git::replace_tab")] | |
| 632 | + | pub(super) async fn replace_tab( | |
| 633 | + | State(db): State<PgPool>, | |
| 634 | + | State(config): State<Config>, | |
| 635 | + | session: Session, | |
| 636 | + | MaybeUserVerified(maybe_user): MaybeUserVerified, | |
| 637 | + | Path((owner, repo_name)): Path<(String, String)>, | |
| 638 | + | ) -> Result<impl IntoResponse> { | |
| 639 | + | let resolved = resolve_repo( | |
| 640 | + | &db, | |
| 641 | + | &config, | |
| 642 | + | &owner, | |
| 643 | + | &repo_name, | |
| 644 | + | maybe_user.as_ref().map(|u| u.id), | |
| 645 | + | ) | |
| 646 | + | .await?; | |
| 647 | + | ||
| 648 | + | let repo_name_c = repo_name.clone(); | |
| 649 | + | let (refs, info, replacements) = resolved | |
| 650 | + | .with_repo(move |gix_repo| { | |
| 651 | + | let refs = git::list_refs(gix_repo); | |
| 652 | + | let info = git::repo_info(gix_repo, &repo_name_c); | |
| 653 | + | let replacements = git::list_replacements(gix_repo); | |
| 654 | + | Ok((refs, info, replacements)) | |
| 655 | + | }) | |
| 656 | + | .await?; | |
| 657 | + | ||
| 658 | + | let csrf_token = get_csrf_token(&session).await; | |
| 659 | + | let is_owner = maybe_user.as_ref().map(|u| u.id) == Some(resolved.db_user.id); | |
| 660 | + | let (open_issue_count, _) = db::issues::get_issue_counts(&db, resolved.db_repo.id) | |
| 661 | + | .await | |
| 662 | + | .unwrap_or((0, 0)); | |
| 663 | + | ||
| 664 | + | Ok(GitReplaceTemplate { | |
| 665 | + | csrf_token, | |
| 666 | + | session_user: maybe_user, | |
| 667 | + | owner, | |
| 668 | + | repo_name, | |
| 669 | + | current_ref: info.default_branch, | |
| 670 | + | refs, | |
| 671 | + | replacements, | |
| 672 | + | open_issue_count, | |
| 673 | + | is_owner, | |
| 674 | + | active_tab: "replace", | |
| 675 | + | }) | |
| 676 | + | } | |
| 677 | + | ||
| 626 | 678 | /// `GET /git/{owner}/{repo}/blame/{ref}/{*path}`: blame view. | |
| 627 | 679 | #[tracing::instrument(skip_all, name = "git::blame_view")] | |
| 628 | 680 | pub(super) async fn blame_view( |
| @@ -57,6 +57,7 @@ | |||
| 57 | 57 | get(browsing::blame_view), | |
| 58 | 58 | ) | |
| 59 | 59 | .route("/git/{owner}/{repo}/tags", get(browsing::tags_tab)) | |
| 60 | + | .route("/git/{owner}/{repo}/replace", get(browsing::replace_tab)) | |
| 60 | 61 | .route("/git/{owner}/{repo}/notes", get(browsing::notes_tab)) | |
| 61 | 62 | // A namespace is a ref path and carries slashes (`mnw/builds`), so the | |
| 62 | 63 | // segment is a wildcard capture like the tree routes use. |
| @@ -40,6 +40,10 @@ | |||
| 40 | 40 | pub release_items: Vec<ReleaseItem>, | |
| 41 | 41 | /// Number of open issues for the nav bar badge. | |
| 42 | 42 | pub open_issue_count: i64, | |
| 43 | + | /// Whether this repository has `refs/replace/*` mappings, which links the | |
| 44 | + | /// replace page from the overview. Almost always false, and shown here | |
| 45 | + | /// rather than in the nav so no repository grows a permanently empty tab. | |
| 46 | + | pub has_replacements: bool, | |
| 43 | 47 | /// Whether the current viewer is the repo owner (for settings link). | |
| 44 | 48 | pub is_owner: bool, | |
| 45 | 49 | /// Whether the viewer has muted this repo's issue notifications. `None` | |
| @@ -190,6 +194,26 @@ | |||
| 190 | 194 | pub active_tab: &'static str, | |
| 191 | 195 | } | |
| 192 | 196 | ||
| 197 | + | /// The repository's `refs/replace/*` mappings. | |
| 198 | + | /// | |
| 199 | + | /// Reachable by URL on every repository and linked from the overview only when | |
| 200 | + | /// there is something to see, because the overwhelming majority of repositories | |
| 201 | + | /// have no replacements and a permanently empty tab is noise. | |
| 202 | + | #[derive(Template)] | |
| 203 | + | #[template(path = "pages/git/replace.html")] | |
| 204 | + | pub struct GitReplaceTemplate { | |
| 205 | + | pub csrf_token: CsrfTokenOption, | |
| 206 | + | pub session_user: Option<SessionUser>, | |
| 207 | + | pub owner: String, | |
| 208 | + | pub repo_name: String, | |
| 209 | + | pub current_ref: String, | |
| 210 | + | pub refs: Vec<git::RefInfo>, | |
| 211 | + | pub replacements: Vec<git::Replacement>, | |
| 212 | + | pub open_issue_count: i64, | |
| 213 | + | pub is_owner: bool, | |
| 214 | + | pub active_tab: &'static str, | |
| 215 | + | } | |
| 216 | + | ||
| 193 | 217 | /// Blame view for a single file. | |
| 194 | 218 | #[derive(Template)] | |
| 195 | 219 | #[template(path = "pages/git/blame.html")] |
| @@ -47,6 +47,15 @@ | |||
| 47 | 47 | ||
| 48 | 48 | {% include "partials/git_nav.html" %} | |
| 49 | 49 | ||
| 50 | + | {# Only for the rare repository that has replacements. A clone applies them | |
| 51 | + | and these pages do not, so the difference is worth naming where somebody | |
| 52 | + | about to clone will see it. #} | |
| 53 | + | {% if has_replacements %} | |
| 54 | + | <p class="git-replace-notice"> | |
| 55 | + | This repository has <a href="/git/{{ owner }}/{{ repo_name }}/replace">object replacements</a>. A clone applies them; these pages show the objects as stored. | |
| 56 | + | </p> | |
| 57 | + | {% endif %} | |
| 58 | + | ||
| 50 | 59 | <table class="git-tree"> | |
| 51 | 60 | <thead> | |
| 52 | 61 | <tr> |
| @@ -1,0 +1,51 @@ | |||
| 1 | + | {% extends "base.html" %} | |
| 2 | + | {%- import "partials/_ui.html" as ui -%} | |
| 3 | + | ||
| 4 | + | {% block title %}Replacements - {{ repo_name }} - Git - Makenotwork{% endblock %} | |
| 5 | + | {% block body_attrs %} class="padded-page"{% endblock %} | |
| 6 | + | ||
| 7 | + | {% block content %} | |
| 8 | + | {% include "partials/site_header.html" %} | |
| 9 | + | ||
| 10 | + | <h1 class="git-repo-name"> | |
| 11 | + | <a href="/git/{{ owner }}">{{ owner }}</a> | |
| 12 | + | <span class="sep">/</span> | |
| 13 | + | <a href="/git/{{ owner }}/{{ repo_name }}">{{ repo_name }}</a> | |
| 14 | + | </h1> | |
| 15 | + | ||
| 16 | + | {% include "partials/git_nav.html" %} | |
| 17 | + | ||
| 18 | + | <div class="git-replace-intro"> | |
| 19 | + | <p>A replacement under <code>refs/replace/</code> makes git serve one object wherever another was asked for. A clone applies them; these pages do not, so everything you browse here is the object the repository actually stores.</p> | |
| 20 | + | </div> | |
| 21 | + | ||
| 22 | + | {% if replacements.is_empty() %} | |
| 23 | + | {% call ui::empty_state("", "No object replacements in this repository.") %}{% endcall %} | |
| 24 | + | {% else %} | |
| 25 | + | <table class="git-replace-table"> | |
| 26 | + | <thead> | |
| 27 | + | <tr><th>Replaced object</th><th>Served instead</th><th>Kind</th></tr> | |
| 28 | + | </thead> | |
| 29 | + | <tbody> | |
| 30 | + | {% for r in replacements %} | |
| 31 | + | <tr> | |
| 32 | + | <td class="git-commit-oid" title="{{ r.source }}">{{ r.source_short }}</td> | |
| 33 | + | <td class="git-commit-oid"> | |
| 34 | + | {# Only a commit has a page to link to; a replacement tree or | |
| 35 | + | blob is named and nothing more. #} | |
| 36 | + | {% if r.target_kind == Some("commit") %} | |
| 37 | + | <a href="/git/{{ owner }}/{{ repo_name }}/commit/{{ r.target }}" title="{{ r.target }}">{{ r.target_short }}</a> | |
| 38 | + | {% else %} | |
| 39 | + | <span title="{{ r.target }}">{{ r.target_short }}</span> | |
| 40 | + | {% endif %} | |
| 41 | + | </td> | |
| 42 | + | {# A replacement pointing at an object the database no longer has | |
| 43 | + | is broken, and saying so beats printing a blank cell. #} | |
| 44 | + | <td>{{ r.target_kind.unwrap_or("missing") }}</td> | |
| 45 | + | </tr> | |
| 46 | + | {% endfor %} | |
| 47 | + | </tbody> | |
| 48 | + | </table> | |
| 49 | + | {% endif %} | |
| 50 | + | ||
| 51 | + | {% endblock %} |