Skip to main content

max / makenotwork

server: render refs/replace/* P7b of the git meta-operations program. A replacement makes git serve one object wherever another was asked for, and no forge shows them. Listed at /git/{owner}/{repo}/replace: source, substitute, and the kind of the substitute, with a dangling one reported as missing rather than blank. Linked from the repo overview only when a repository has any, so nothing grows a permanently empty tab. Browsing does not apply replacements, which means a clone and these pages can disagree, and the page says so. That behaviour comes from gitoxide rather than from us: with core.useReplaceRefs unset gix loads none, unlike git, which applies them by default. No override is set, because in gix 0.86 the setting is inverted against its own name and writing the value that reads as "off" turns substitution on. A test pins the outcome so a gitoxide release that changes it fails here rather than quietly rewriting what visitors read.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-08-08 23:58 UTC
Commit: 8ec07bda932b6d6888e8da0c52774b44430be742
Parent: bba538f
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 %}