ee237a75
Show a patch's whole diff, not just its head commit
a73x 2026-08-30 11:04
Commit message
src/patch.rs
| Old | New | ||
|---|---|---|---|
| @@ -1255,12 +1255,19 @@ fn recorded_base_tree<'a>(repo: &'a Repository, base: Option<Oid>) -> Option<git | |||
| 1255 | repo.find_commit(oid).ok()?.tree().ok() | 1255 | repo.find_commit(oid).ok()?.tree().ok() |
| 1256 | } | 1256 | } |
| 1257 | 1257 | ||
| 1258 | /// Generate a diff string from a patch's base and head using three-dot (merge-base) diff. | 1258 | /// The diff a patch *is*: its base to its head, three-dot (merge-base) when the |
| 1259 | pub fn generate_diff( | 1259 | /// revision recorded no base of its own. |
| 1260 | repo: &Repository, | 1260 | /// |
| 1261 | /// Public because the web server renders the same range. A second | ||
| 1262 | /// implementation there would be a second answer to "which trees does this | ||
| 1263 | /// patch compare", and the two would disagree the moment a patch lands — at | ||
| 1264 | /// which point the recomputed merge-base *is* the head and the diff comes out | ||
| 1265 | /// empty. There is one base resolution, and this is it. | ||
| 1266 | pub fn patch_diff<'r>( | ||
| 1267 | repo: &'r Repository, | ||
| 1261 | patch: &state::PatchState, | 1268 | patch: &state::PatchState, |
| 1262 | opts: &DiffOpts, | 1269 | opts: &DiffOpts, |
| 1263 | ) -> Result<String, Error> { | 1270 | ) -> Result<git2::Diff<'r>, Error> { |
| 1264 | let head_oid = patch.resolve_head(repo)?; | 1271 | let head_oid = patch.resolve_head(repo)?; |
| 1265 | let head_commit = repo | 1272 | let head_commit = repo |
| 1266 | .find_commit(head_oid) | 1273 | .find_commit(head_oid) |
| @@ -1271,12 +1278,20 @@ pub fn generate_diff( | |||
| 1271 | None => resolve_base_tree(repo, &patch.base_ref, head_oid)?, | 1278 | None => resolve_base_tree(repo, &patch.base_ref, head_oid)?, |
| 1272 | }; | 1279 | }; |
| 1273 | 1280 | ||
| 1274 | let git_diff = repo.diff_tree_to_tree( | 1281 | Ok(repo.diff_tree_to_tree( |
| 1275 | base_tree.as_ref(), | 1282 | base_tree.as_ref(), |
| 1276 | Some(&head_tree), | 1283 | Some(&head_tree), |
| 1277 | Some(&mut diff_options(opts)), | 1284 | Some(&mut diff_options(opts)), |
| 1278 | )?; | 1285 | )?) |
| 1279 | format_diff(&git_diff, opts) | 1286 | } |
| 1287 | |||
| 1288 | /// Generate a diff string from a patch's base and head using three-dot (merge-base) diff. | ||
| 1289 | pub fn generate_diff( | ||
| 1290 | repo: &Repository, | ||
| 1291 | patch: &state::PatchState, | ||
| 1292 | opts: &DiffOpts, | ||
| 1293 | ) -> Result<String, Error> { | ||
| 1294 | format_diff(&patch_diff(repo, patch, opts)?, opts) | ||
| 1280 | } | 1295 | } |
| 1281 | 1296 | ||
| 1282 | /// Generate a diff for a specific revision against the base branch (historical full diff). | 1297 | /// Generate a diff for a specific revision against the base branch (historical full diff). |
src/server/http/mod.rs
| Old | New | ||
|---|---|---|---|
| @@ -41,6 +41,10 @@ pub fn router(state: AppState) -> Router { | |||
| 41 | "/{repo_name}/patches/{id}", | 41 | "/{repo_name}/patches/{id}", |
| 42 | axum::routing::get(repo::patch_detail), | 42 | axum::routing::get(repo::patch_detail), |
| 43 | ) | 43 | ) |
| 44 | .route( | ||
| 45 | "/{repo_name}/patches/{id}/diff", | ||
| 46 | axum::routing::get(repo::patch_diff), | ||
| 47 | ) | ||
| 44 | .route("/{repo_name}/issues", axum::routing::get(repo::issues)) | 48 | .route("/{repo_name}/issues", axum::routing::get(repo::issues)) |
| 45 | .route( | 49 | .route( |
| 46 | "/{repo_name}/issues/{id}", | 50 | "/{repo_name}/issues/{id}", |
src/server/http/repo/diff.rs
| Old | New | ||
|---|---|---|---|
| @@ -258,6 +258,16 @@ fn compute_diff_files(repo: &git2::Repository, oid: git2::Oid) -> Vec<DiffFile> | |||
| 258 | Err(_) => return Vec::new(), | 258 | Err(_) => return Vec::new(), |
| 259 | }; | 259 | }; |
| 260 | 260 | ||
| 261 | collect_diff_files(&diff) | ||
| 262 | } | ||
| 263 | |||
| 264 | /// Walk any libgit2 diff into the side-by-side rows the templates render. | ||
| 265 | /// | ||
| 266 | /// Which trees were compared is the caller's question — a commit against its | ||
| 267 | /// parent here, a patch against its base in [`patch_diff`] — and the two must | ||
| 268 | /// not drift apart in how they *render*, which is why only the tree choice | ||
| 269 | /// differs between them. | ||
| 270 | pub fn collect_diff_files(diff: &git2::Diff<'_>) -> Vec<DiffFile> { | ||
| 261 | let collector = RefCell::new(DiffCollector::default()); | 271 | let collector = RefCell::new(DiffCollector::default()); |
| 262 | 272 | ||
| 263 | let mut file_cb = |delta: git2::DiffDelta<'_>, _progress: f32| { | 273 | let mut file_cb = |delta: git2::DiffDelta<'_>, _progress: f32| { |
src/server/http/repo/mod.rs
| Old | New | ||
|---|---|---|---|
| @@ -1,5 +1,6 @@ | |||
| 1 | mod commits; | 1 | mod commits; |
| 2 | mod diff; | 2 | mod diff; |
| 3 | mod patch_diff; | ||
| 3 | mod issues; | 4 | mod issues; |
| 4 | mod overview; | 5 | mod overview; |
| 5 | mod patches; | 6 | mod patches; |
| @@ -9,6 +10,7 @@ mod tree; | |||
| 9 | 10 | ||
| 10 | pub use commits::{commits, commits_ref}; | 11 | pub use commits::{commits, commits_ref}; |
| 11 | pub use diff::diff; | 12 | pub use diff::diff; |
| 13 | pub use patch_diff::patch_diff; | ||
| 12 | pub use issues::{issue_detail, issues}; | 14 | pub use issues::{issue_detail, issues}; |
| 13 | pub use overview::overview; | 15 | pub use overview::overview; |
| 14 | pub use patches::{patch_detail, patches}; | 16 | pub use patches::{patch_detail, patches}; |
src/server/http/repo/patch_diff.rs
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,79 @@ | |||
| 1 | //! The whole change a patch carries, base to head. | ||
| 2 | //! | ||
| 3 | //! Distinct from `/diff/{oid}`, which is one commit against its parent. A | ||
| 4 | //! patch of seven commits has seven such views and none of them is the patch, | ||
| 5 | //! which is the confusion this page exists to end. | ||
| 6 | |||
| 7 | use std::sync::Arc; | ||
| 8 | |||
| 9 | use axum::extract::{Path as AxumPath, State}; | ||
| 10 | use axum::response::{IntoResponse, Response}; | ||
| 11 | |||
| 12 | use super::diff::{collect_diff_files, DiffFile}; | ||
| 13 | use super::{collab_counts, internal_error, not_found, open_repo, AppState}; | ||
| 14 | |||
| 15 | #[derive(askama::Template, askama_web::WebTemplate)] | ||
| 16 | #[template(path = "patch_diff.html")] | ||
| 17 | pub struct PatchDiffTemplate { | ||
| 18 | pub site_title: String, | ||
| 19 | pub repo_name: String, | ||
| 20 | pub active_section: String, | ||
| 21 | pub open_patches: usize, | ||
| 22 | pub open_issues: usize, | ||
| 23 | pub patch_id: String, | ||
| 24 | pub short_id: String, | ||
| 25 | pub title: String, | ||
| 26 | pub branch: String, | ||
| 27 | pub base_ref: String, | ||
| 28 | pub file_count: usize, | ||
| 29 | pub diff_files: Vec<DiffFile>, | ||
| 30 | } | ||
| 31 | |||
| 32 | pub async fn patch_diff( | ||
| 33 | AxumPath((repo_name, patch_id)): AxumPath<(String, String)>, | ||
| 34 | State(state): State<Arc<AppState>>, | ||
| 35 | ) -> Response { | ||
| 36 | let (_entry, repo) = match open_repo(&state, &repo_name) { | ||
| 37 | Ok(r) => r, | ||
| 38 | Err(resp) => return resp, | ||
| 39 | }; | ||
| 40 | |||
| 41 | let (open_patches, open_issues) = collab_counts(&repo); | ||
| 42 | |||
| 43 | let (ref_name, full_id) = match git_collab::state::resolve_patch_ref(&repo, &patch_id) { | ||
| 44 | Ok(r) => r, | ||
| 45 | Err(_) => return not_found(&state, format!("Patch '{}' not found.", patch_id)), | ||
| 46 | }; | ||
| 47 | |||
| 48 | let ps = match git_collab::state::PatchState::from_ref(&repo, &ref_name, &full_id) { | ||
| 49 | Ok(s) => s, | ||
| 50 | Err(_) => return internal_error(&state, "Failed to load patch state."), | ||
| 51 | }; | ||
| 52 | |||
| 53 | // The CLI's own base resolution, not a second one — see `patch::patch_diff`. | ||
| 54 | let opts = git_collab::patch::DiffOpts::default(); | ||
| 55 | let diff_files = match git_collab::patch::patch_diff(&repo, &ps, &opts) { | ||
| 56 | Ok(diff) => collect_diff_files(&diff), | ||
| 57 | Err(_) => Vec::new(), | ||
| 58 | }; | ||
| 59 | |||
| 60 | let short_id = git_collab::abbrev::for_patches(&repo) | ||
| 61 | .of(&full_id) | ||
| 62 | .to_string(); | ||
| 63 | |||
| 64 | PatchDiffTemplate { | ||
| 65 | site_title: state.site_title.clone(), | ||
| 66 | repo_name, | ||
| 67 | active_section: "patches".to_string(), | ||
| 68 | open_patches, | ||
| 69 | open_issues, | ||
| 70 | patch_id: full_id, | ||
| 71 | short_id, | ||
| 72 | title: ps.title, | ||
| 73 | branch: ps.branch, | ||
| 74 | base_ref: ps.base_ref, | ||
| 75 | file_count: diff_files.len(), | ||
| 76 | diff_files, | ||
| 77 | } | ||
| 78 | .into_response() | ||
| 79 | } | ||
src/server/http/repo/patches.rs
| Old | New | ||
|---|---|---|---|
| @@ -150,6 +150,8 @@ pub struct InlineCommentView { | |||
| 150 | 150 | ||
| 151 | #[derive(Debug)] | 151 | #[derive(Debug)] |
| 152 | pub struct PatchDetailView { | 152 | pub struct PatchDetailView { |
| 153 | /// The full id, for addressing this patch's own sub-routes. | ||
| 154 | pub id: String, | ||
| 153 | pub title: String, | 155 | pub title: String, |
| 154 | pub body: String, | 156 | pub body: String, |
| 155 | pub status: String, | 157 | pub status: String, |
| @@ -307,6 +309,7 @@ pub async fn patch_detail( | |||
| 307 | }; | 309 | }; |
| 308 | 310 | ||
| 309 | let patch = PatchDetailView { | 311 | let patch = PatchDetailView { |
| 312 | id: full_id, | ||
| 310 | title: ps.title, | 313 | title: ps.title, |
| 311 | body: ps.body, | 314 | body: ps.body, |
| 312 | status: ps.status.as_str().to_string(), | 315 | status: ps.status.as_str().to_string(), |
src/server/http/templates/diff.html
| Old | New | ||
|---|---|---|---|
| @@ -23,45 +23,6 @@ | |||
| 23 | {% if diff_files.is_empty() %} | 23 | {% if diff_files.is_empty() %} |
| 24 | <p style="color: #666;">No diff available for this commit.</p> | 24 | <p style="color: #666;">No diff available for this commit.</p> |
| 25 | {% else %} | 25 | {% else %} |
| 26 | <div style="display: grid; gap: 16px;"> | 26 | {% include "diff_files.html" %} |
| 27 | {% for file in diff_files %} | ||
| 28 | <section class="diff-file-block"> | ||
| 29 | <div class="diff-file-heading mono">{{ file.path }}</div> | ||
| 30 | {% if file.hunks.is_empty() %} | ||
| 31 | <div style="padding: 12px; color: #666;">No textual changes available.</div> | ||
| 32 | {% else %} | ||
| 33 | <table class="side-by-side-diff"> | ||
| 34 | <colgroup> | ||
| 35 | <col class="diff-line-col"> | ||
| 36 | <col class="diff-code-col"> | ||
| 37 | <col class="diff-line-col"> | ||
| 38 | <col class="diff-code-col"> | ||
| 39 | </colgroup> | ||
| 40 | <thead> | ||
| 41 | <tr> | ||
| 42 | <th colspan="2">Old</th> | ||
| 43 | <th colspan="2">New</th> | ||
| 44 | </tr> | ||
| 45 | </thead> | ||
| 46 | <tbody> | ||
| 47 | {% for hunk in file.hunks %} | ||
| 48 | <tr> | ||
| 49 | <td class="diff-hunk" colspan="4">{{ hunk.header }}</td> | ||
| 50 | </tr> | ||
| 51 | {% for row in hunk.rows %} | ||
| 52 | <tr> | ||
| 53 | <td class="diff-line-no diff-{{ row.left.kind }}">{{ row.left.line_no }}</td> | ||
| 54 | <td class="diff-code diff-{{ row.left.kind }}">{{ row.left.text }}</td> | ||
| 55 | <td class="diff-line-no diff-{{ row.right.kind }}">{{ row.right.line_no }}</td> | ||
| 56 | <td class="diff-code diff-{{ row.right.kind }}">{{ row.right.text }}</td> | ||
| 57 | </tr> | ||
| 58 | {% endfor %} | ||
| 59 | {% endfor %} | ||
| 60 | </tbody> | ||
| 61 | </table> | ||
| 62 | {% endif %} | ||
| 63 | </section> | ||
| 64 | {% endfor %} | ||
| 65 | </div> | ||
| 66 | {% endif %} | 27 | {% endif %} |
| 67 | {% endblock %} | 28 | {% endblock %} |
src/server/http/templates/diff_files.html
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,42 @@ | |||
| 1 | {# The rendered files of a diff. Shared so that a commit's diff and a patch's | ||
| 2 | diff cannot drift apart in how they look — only in which trees they compare. #} | ||
| 3 | <div style="display: grid; gap: 16px;"> | ||
| 4 | {% for file in diff_files %} | ||
| 5 | <section class="diff-file-block"> | ||
| 6 | <div class="diff-file-heading mono">{{ file.path }}</div> | ||
| 7 | {% if file.hunks.is_empty() %} | ||
| 8 | <div style="padding: 12px; color: #666;">No textual changes available.</div> | ||
| 9 | {% else %} | ||
| 10 | <table class="side-by-side-diff"> | ||
| 11 | <colgroup> | ||
| 12 | <col class="diff-line-col"> | ||
| 13 | <col class="diff-code-col"> | ||
| 14 | <col class="diff-line-col"> | ||
| 15 | <col class="diff-code-col"> | ||
| 16 | </colgroup> | ||
| 17 | <thead> | ||
| 18 | <tr> | ||
| 19 | <th colspan="2">Old</th> | ||
| 20 | <th colspan="2">New</th> | ||
| 21 | </tr> | ||
| 22 | </thead> | ||
| 23 | <tbody> | ||
| 24 | {% for hunk in file.hunks %} | ||
| 25 | <tr> | ||
| 26 | <td class="diff-hunk" colspan="4">{{ hunk.header }}</td> | ||
| 27 | </tr> | ||
| 28 | {% for row in hunk.rows %} | ||
| 29 | <tr> | ||
| 30 | <td class="diff-line-no diff-{{ row.left.kind }}">{{ row.left.line_no }}</td> | ||
| 31 | <td class="diff-code diff-{{ row.left.kind }}">{{ row.left.text }}</td> | ||
| 32 | <td class="diff-line-no diff-{{ row.right.kind }}">{{ row.right.line_no }}</td> | ||
| 33 | <td class="diff-code diff-{{ row.right.kind }}">{{ row.right.text }}</td> | ||
| 34 | </tr> | ||
| 35 | {% endfor %} | ||
| 36 | {% endfor %} | ||
| 37 | </tbody> | ||
| 38 | </table> | ||
| 39 | {% endif %} | ||
| 40 | </section> | ||
| 41 | {% endfor %} | ||
| 42 | </div> | ||
src/server/http/templates/patch_detail.html
| Old | New | ||
|---|---|---|---|
| @@ -9,6 +9,7 @@ | |||
| 9 | by <strong>{{ patch.author }}</strong> | 9 | by <strong>{{ patch.author }}</strong> |
| 10 | <span class="mono" style="color: #666;">{{ patch.branch }} → {{ patch.base_ref }}</span> | 10 | <span class="mono" style="color: #666;">{{ patch.branch }} → {{ patch.base_ref }}</span> |
| 11 | </p> | 11 | </p> |
| 12 | <p><a href="/{{ repo_name }}/patches/{{ patch.id }}/diff">Files changed</a></p> | ||
| 12 | {% if let Some(merge_commit) = patch.merge_commit %} | 13 | {% if let Some(merge_commit) = patch.merge_commit %} |
| 13 | {# The link resolves the full oid; the text matches the Revisions table below. | 14 | {# The link resolves the full oid; the text matches the Revisions table below. |
| 14 | See issue b2a57996. #} | 15 | See issue b2a57996. #} |
src/server/http/templates/patch_diff.html
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,20 @@ | |||
| 1 | {% extends "repo_base.html" %} | ||
| 2 | |||
| 3 | {% block title %}{{ title }} — diff — {{ repo_name }} — {{ site_title }}{% endblock %} | ||
| 4 | |||
| 5 | {% block content %} | ||
| 6 | <h2>{{ title }}</h2> | ||
| 7 | <p> | ||
| 8 | <a class="mono" href="/{{ repo_name }}/patches/{{ patch_id }}">{{ short_id }}</a> | ||
| 9 | <span class="mono" style="color: #666;">{{ branch }} → {{ base_ref }}</span> | ||
| 10 | </p> | ||
| 11 | |||
| 12 | <hr style="margin: 16px 0;"> | ||
| 13 | |||
| 14 | {% if diff_files.is_empty() %} | ||
| 15 | <p style="color: #666;">No diff available for this patch. Its head commit may no longer be in this repository.</p> | ||
| 16 | {% else %} | ||
| 17 | <p style="color: #666;">{{ file_count }} file{% if file_count != 1 %}s{% endif %} changed</p> | ||
| 18 | {% include "diff_files.html" %} | ||
| 19 | {% endif %} | ||
| 20 | {% endblock %} | ||
tests/patch_diff_page_test.rs
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,127 @@ | |||
| 1 | //! The web view of a patch's *whole* change, not just its head commit. | ||
| 2 | //! | ||
| 3 | //! `/{repo}/diff/{oid}` is a single-commit view — it diffs a commit against its | ||
| 4 | //! first parent — and the patch page linked its head commit there. On a patch | ||
| 5 | //! of seven commits that renders one commit and one file while `patch diff | ||
| 6 | //! --stat` reports thirty-three, so the page silently disagreed with the record | ||
| 7 | //! it was displaying. The oracle here is that CLI: whatever `patch diff --stat` | ||
| 8 | //! names, the page must show. | ||
| 9 | |||
| 10 | mod common; | ||
| 11 | |||
| 12 | use common::ServerHarness; | ||
| 13 | |||
| 14 | /// A patch whose head commit touches *one* file while the patch touches three, | ||
| 15 | /// so a single-commit view and a range view cannot produce the same answer. | ||
| 16 | fn patch_of_three_commits(harness: &ServerHarness) -> String { | ||
| 17 | let repo = harness.work_repo(); | ||
| 18 | repo.git(&["checkout", "-b", "feature"]); | ||
| 19 | repo.commit_file("first.rs", "pub fn first() {}\n", "add first"); | ||
| 20 | repo.commit_file("second.rs", "pub fn second() {}\n", "add second"); | ||
| 21 | repo.commit_file("third.rs", "pub fn third() {}\n", "add third"); | ||
| 22 | let out = repo.run_ok(&["patch", "create", "-t", "Three commits", "-B", "feature"]); | ||
| 23 | repo.git(&["checkout", "main"]); | ||
| 24 | harness.push_head(); | ||
| 25 | repo.git(&["push", "origin", "feature"]); | ||
| 26 | harness.push_collab_refs(); | ||
| 27 | out.trim() | ||
| 28 | .strip_prefix("Created patch ") | ||
| 29 | .expect("patch create prints the id") | ||
| 30 | .to_string() | ||
| 31 | } | ||
| 32 | |||
| 33 | /// The file paths `patch diff --stat` names, which is what the page must match. | ||
| 34 | fn stat_paths(harness: &ServerHarness, id: &str) -> Vec<String> { | ||
| 35 | harness | ||
| 36 | .work_repo() | ||
| 37 | .run_ok(&["patch", "diff", id, "--stat"]) | ||
| 38 | .lines() | ||
| 39 | .filter_map(|line| line.split_once('|')) | ||
| 40 | .map(|(path, _)| path.trim().to_string()) | ||
| 41 | .collect() | ||
| 42 | } | ||
| 43 | |||
| 44 | #[test] | ||
| 45 | fn patch_diff_page_shows_every_file_the_patch_touches() { | ||
| 46 | let harness = ServerHarness::new("range"); | ||
| 47 | let id = patch_of_three_commits(&harness); | ||
| 48 | |||
| 49 | let paths = stat_paths(&harness, &id); | ||
| 50 | assert_eq!( | ||
| 51 | paths, | ||
| 52 | vec!["first.rs", "second.rs", "third.rs"], | ||
| 53 | "the CLI oracle itself must see all three files" | ||
| 54 | ); | ||
| 55 | |||
| 56 | let page = harness.get_ok(&format!("/range/patches/{id}/diff")); | ||
| 57 | for path in &paths { | ||
| 58 | assert!( | ||
| 59 | page.body.contains(path.as_str()), | ||
| 60 | "`patch diff --stat` names {path}, so the patch diff page must show it; \ | ||
| 61 | a page missing it is showing one commit instead of the patch" | ||
| 62 | ); | ||
| 63 | } | ||
| 64 | } | ||
| 65 | |||
| 66 | #[test] | ||
| 67 | fn patch_detail_links_to_the_range_diff() { | ||
| 68 | let harness = ServerHarness::new("range"); | ||
| 69 | let id = patch_of_three_commits(&harness); | ||
| 70 | |||
| 71 | let page = harness.get_ok(&format!("/range/patches/{id}")); | ||
| 72 | let href = page | ||
| 73 | .body | ||
| 74 | .split("href=\"") | ||
| 75 | .filter_map(|rest| rest.split_once('"')) | ||
| 76 | .map(|(href, _)| href) | ||
| 77 | .find(|href| href.contains("/patches/") && href.ends_with("/diff")) | ||
| 78 | .unwrap_or_else(|| { | ||
| 79 | panic!( | ||
| 80 | "the patch page must offer the patch's own diff; without the link the \ | ||
| 81 | only diff a reader can reach is one commit of it" | ||
| 82 | ) | ||
| 83 | }) | ||
| 84 | .to_string(); | ||
| 85 | |||
| 86 | let linked = harness.get_ok(&href); | ||
| 87 | assert!( | ||
| 88 | linked.body.contains("first.rs"), | ||
| 89 | "the link from the patch page must reach the patch's whole diff, not one commit \ | ||
| 90 | of it: {href} does not show the first commit's file" | ||
| 91 | ); | ||
| 92 | } | ||
| 93 | |||
| 94 | /// A patch whose branch was never pushed — the shape an agent worktree leaves | ||
| 95 | /// behind when its branch is deleted after `patch create`. The commits reach | ||
| 96 | /// the server only as revision refs, so a diff that resolved its head by | ||
| 97 | /// branch name would find nothing to show. | ||
| 98 | #[test] | ||
| 99 | fn patch_diff_page_works_without_the_head_branch() { | ||
| 100 | let harness = ServerHarness::new("range"); | ||
| 101 | let repo = harness.work_repo(); | ||
| 102 | repo.git(&["checkout", "-b", "gone"]); | ||
| 103 | repo.commit_file("orphan.rs", "pub fn orphan() {}\n", "add orphan"); | ||
| 104 | let out = repo.run_ok(&["patch", "create", "-t", "Never pushed", "-B", "gone"]); | ||
| 105 | repo.git(&["checkout", "main"]); | ||
| 106 | let id = out | ||
| 107 | .trim() | ||
| 108 | .strip_prefix("Created patch ") | ||
| 109 | .expect("patch create prints the id") | ||
| 110 | .to_string(); | ||
| 111 | harness.push_head(); | ||
| 112 | harness.push_collab_refs(); | ||
| 113 | assert!( | ||
| 114 | !harness | ||
| 115 | .work_repo() | ||
| 116 | .git(&["ls-remote", "--heads", "origin", "gone"]) | ||
| 117 | .contains("gone"), | ||
| 118 | "the branch must be absent on the server for this test to mean anything" | ||
| 119 | ); | ||
| 120 | |||
| 121 | let page = harness.get_ok(&format!("/range/patches/{id}/diff")); | ||
| 122 | assert!( | ||
| 123 | page.body.contains("orphan.rs"), | ||
| 124 | "the patch's commits reached the server as revision refs, so its diff must \ | ||
| 125 | render without the branch that once named them" | ||
| 126 | ); | ||
| 127 | } | ||