db21d0f6
Show repo descriptions and lay out web diffs side by side
a73x 2026-04-04 10:50
Commit message
.description
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1 @@ | |||
| 1 | A patch based git workflow | ||
src/server/http/repo/diff.rs
| Old | New | ||
|---|---|---|---|
| @@ -1,17 +1,39 @@ | |||
| 1 | use std::cell::RefCell; | ||
| 2 | use std::path::Path; | ||
| 1 | use std::sync::Arc; | 3 | use std::sync::Arc; |
| 2 | 4 | ||
| 3 | use axum::extract::{Path, State}; | 5 | use axum::extract::{Path as AxumPath, State}; |
| 4 | use axum::response::{IntoResponse, Response}; | 6 | use axum::response::{IntoResponse, Response}; |
| 5 | use chrono::{TimeZone, Utc}; | 7 | use chrono::{TimeZone, Utc}; |
| 8 | use git2::Delta; | ||
| 6 | 9 | ||
| 7 | use super::{AppState, collab_counts, not_found, open_repo}; | 10 | use super::{collab_counts, not_found, open_repo, AppState}; |
| 8 | 11 | ||
| 9 | #[derive(Debug)] | 12 | #[derive(Debug)] |
| 10 | pub struct DiffLine { | 13 | pub struct DiffCell { |
| 11 | pub kind: String, | 14 | pub kind: String, |
| 15 | pub line_no: String, | ||
| 12 | pub text: String, | 16 | pub text: String, |
| 13 | } | 17 | } |
| 14 | 18 | ||
| 19 | #[derive(Debug)] | ||
| 20 | pub struct DiffRow { | ||
| 21 | pub left: DiffCell, | ||
| 22 | pub right: DiffCell, | ||
| 23 | } | ||
| 24 | |||
| 25 | #[derive(Debug)] | ||
| 26 | pub struct DiffHunk { | ||
| 27 | pub header: String, | ||
| 28 | pub rows: Vec<DiffRow>, | ||
| 29 | } | ||
| 30 | |||
| 31 | #[derive(Debug)] | ||
| 32 | pub struct DiffFile { | ||
| 33 | pub path: String, | ||
| 34 | pub hunks: Vec<DiffHunk>, | ||
| 35 | } | ||
| 36 | |||
| 15 | #[derive(askama::Template, askama_web::WebTemplate)] | 37 | #[derive(askama::Template, askama_web::WebTemplate)] |
| 16 | #[template(path = "diff.html")] | 38 | #[template(path = "diff.html")] |
| 17 | pub struct DiffTemplate { | 39 | pub struct DiffTemplate { |
| @@ -22,13 +44,203 @@ pub struct DiffTemplate { | |||
| 22 | pub open_issues: usize, | 44 | pub open_issues: usize, |
| 23 | pub short_id: String, | 45 | pub short_id: String, |
| 24 | pub summary: String, | 46 | pub summary: String, |
| 25 | pub body: String, | 47 | pub full_message: String, |
| 26 | pub author: String, | 48 | pub author: String, |
| 27 | pub date: String, | 49 | pub date: String, |
| 28 | pub diff_lines: Vec<DiffLine>, | 50 | pub diff_files: Vec<DiffFile>, |
| 51 | } | ||
| 52 | |||
| 53 | fn empty_cell() -> DiffCell { | ||
| 54 | DiffCell { | ||
| 55 | kind: "empty".to_string(), | ||
| 56 | line_no: String::new(), | ||
| 57 | text: String::new(), | ||
| 58 | } | ||
| 59 | } | ||
| 60 | |||
| 61 | fn format_line_no(line_no: Option<u32>) -> String { | ||
| 62 | line_no.map(|value| value.to_string()).unwrap_or_default() | ||
| 63 | } | ||
| 64 | |||
| 65 | fn sanitize_line_text(text: &[u8]) -> String { | ||
| 66 | String::from_utf8_lossy(text) | ||
| 67 | .trim_end_matches(['\r', '\n']) | ||
| 68 | .to_string() | ||
| 69 | } | ||
| 70 | |||
| 71 | fn flush_pending_deletions(rows: &mut Vec<DiffRow>, pending_deletions: &mut Vec<DiffCell>) { | ||
| 72 | for deletion in pending_deletions.drain(..) { | ||
| 73 | rows.push(DiffRow { | ||
| 74 | left: deletion, | ||
| 75 | right: empty_cell(), | ||
| 76 | }); | ||
| 77 | } | ||
| 29 | } | 78 | } |
| 30 | 79 | ||
| 31 | fn compute_diff_lines(repo: &git2::Repository, oid: git2::Oid) -> Vec<DiffLine> { | 80 | #[derive(Debug, Default)] |
| 81 | struct DiffCollector { | ||
| 82 | files: Vec<DiffFile>, | ||
| 83 | current_file: Option<DiffFile>, | ||
| 84 | current_hunk: Option<DiffHunk>, | ||
| 85 | pending_deletions: Vec<DiffCell>, | ||
| 86 | } | ||
| 87 | |||
| 88 | impl DiffCollector { | ||
| 89 | fn ensure_current_file(&mut self, delta: git2::DiffDelta<'_>) { | ||
| 90 | if self.current_file.is_none() { | ||
| 91 | self.current_file = Some(DiffFile { | ||
| 92 | path: format_delta_path(delta), | ||
| 93 | hunks: Vec::new(), | ||
| 94 | }); | ||
| 95 | } | ||
| 96 | } | ||
| 97 | |||
| 98 | fn finish_hunk(&mut self) { | ||
| 99 | if let Some(hunk) = self.current_hunk.as_mut() { | ||
| 100 | flush_pending_deletions(&mut hunk.rows, &mut self.pending_deletions); | ||
| 101 | } | ||
| 102 | |||
| 103 | if let Some(hunk) = self.current_hunk.take() { | ||
| 104 | if let Some(file) = self.current_file.as_mut() { | ||
| 105 | file.hunks.push(hunk); | ||
| 106 | } | ||
| 107 | } | ||
| 108 | } | ||
| 109 | |||
| 110 | fn finish_file(&mut self) { | ||
| 111 | self.finish_hunk(); | ||
| 112 | if let Some(file) = self.current_file.take() { | ||
| 113 | self.files.push(file); | ||
| 114 | } | ||
| 115 | } | ||
| 116 | |||
| 117 | fn start_file(&mut self, delta: git2::DiffDelta<'_>) { | ||
| 118 | self.finish_file(); | ||
| 119 | self.current_file = Some(DiffFile { | ||
| 120 | path: format_delta_path(delta), | ||
| 121 | hunks: Vec::new(), | ||
| 122 | }); | ||
| 123 | } | ||
| 124 | |||
| 125 | fn push_binary_marker(&mut self, delta: git2::DiffDelta<'_>) { | ||
| 126 | self.ensure_current_file(delta); | ||
| 127 | self.finish_hunk(); | ||
| 128 | if let Some(file) = self.current_file.as_mut() { | ||
| 129 | file.hunks.push(DiffHunk { | ||
| 130 | header: "Binary file".to_string(), | ||
| 131 | rows: vec![DiffRow { | ||
| 132 | left: DiffCell { | ||
| 133 | kind: "ctx".to_string(), | ||
| 134 | line_no: String::new(), | ||
| 135 | text: "Binary files differ".to_string(), | ||
| 136 | }, | ||
| 137 | right: empty_cell(), | ||
| 138 | }], | ||
| 139 | }); | ||
| 140 | } | ||
| 141 | } | ||
| 142 | |||
| 143 | fn start_hunk(&mut self, delta: git2::DiffDelta<'_>, hunk: git2::DiffHunk<'_>) { | ||
| 144 | self.ensure_current_file(delta); | ||
| 145 | self.finish_hunk(); | ||
| 146 | self.current_hunk = Some(DiffHunk { | ||
| 147 | header: String::from_utf8_lossy(hunk.header()) | ||
| 148 | .trim_end_matches(['\r', '\n']) | ||
| 149 | .to_string(), | ||
| 150 | rows: Vec::new(), | ||
| 151 | }); | ||
| 152 | } | ||
| 153 | |||
| 154 | fn push_line(&mut self, line: git2::DiffLine<'_>) { | ||
| 155 | let Some(hunk) = self.current_hunk.as_mut() else { | ||
| 156 | return; | ||
| 157 | }; | ||
| 158 | |||
| 159 | match line.origin() { | ||
| 160 | '-' => self.pending_deletions.push(DiffCell { | ||
| 161 | kind: "del".to_string(), | ||
| 162 | line_no: format_line_no(line.old_lineno()), | ||
| 163 | text: sanitize_line_text(line.content()), | ||
| 164 | }), | ||
| 165 | '+' => { | ||
| 166 | let addition = DiffCell { | ||
| 167 | kind: "add".to_string(), | ||
| 168 | line_no: format_line_no(line.new_lineno()), | ||
| 169 | text: sanitize_line_text(line.content()), | ||
| 170 | }; | ||
| 171 | if self.pending_deletions.is_empty() { | ||
| 172 | hunk.rows.push(DiffRow { | ||
| 173 | left: empty_cell(), | ||
| 174 | right: addition, | ||
| 175 | }); | ||
| 176 | } else { | ||
| 177 | let deletion = self.pending_deletions.remove(0); | ||
| 178 | hunk.rows.push(DiffRow { | ||
| 179 | left: deletion, | ||
| 180 | right: addition, | ||
| 181 | }); | ||
| 182 | } | ||
| 183 | } | ||
| 184 | _ => { | ||
| 185 | flush_pending_deletions(&mut hunk.rows, &mut self.pending_deletions); | ||
| 186 | let text = sanitize_line_text(line.content()); | ||
| 187 | hunk.rows.push(DiffRow { | ||
| 188 | left: DiffCell { | ||
| 189 | kind: "ctx".to_string(), | ||
| 190 | line_no: format_line_no(line.old_lineno()), | ||
| 191 | text: text.clone(), | ||
| 192 | }, | ||
| 193 | right: DiffCell { | ||
| 194 | kind: "ctx".to_string(), | ||
| 195 | line_no: format_line_no(line.new_lineno()), | ||
| 196 | text, | ||
| 197 | }, | ||
| 198 | }); | ||
| 199 | } | ||
| 200 | } | ||
| 201 | } | ||
| 202 | |||
| 203 | fn into_files(mut self) -> Vec<DiffFile> { | ||
| 204 | self.finish_file(); | ||
| 205 | self.files | ||
| 206 | } | ||
| 207 | } | ||
| 208 | |||
| 209 | fn format_delta_path(delta: git2::DiffDelta<'_>) -> String { | ||
| 210 | let old_path = delta.old_file().path(); | ||
| 211 | let new_path = delta.new_file().path(); | ||
| 212 | |||
| 213 | match delta.status() { | ||
| 214 | Delta::Added => new_path | ||
| 215 | .or(old_path) | ||
| 216 | .map(path_to_string) | ||
| 217 | .unwrap_or_else(|| "(new file)".to_string()), | ||
| 218 | Delta::Deleted => old_path | ||
| 219 | .or(new_path) | ||
| 220 | .map(path_to_string) | ||
| 221 | .unwrap_or_else(|| "(deleted file)".to_string()), | ||
| 222 | Delta::Renamed => match (old_path, new_path) { | ||
| 223 | (Some(old_path), Some(new_path)) => { | ||
| 224 | format!( | ||
| 225 | "{} → {}", | ||
| 226 | path_to_string(old_path), | ||
| 227 | path_to_string(new_path) | ||
| 228 | ) | ||
| 229 | } | ||
| 230 | _ => "(renamed file)".to_string(), | ||
| 231 | }, | ||
| 232 | _ => new_path | ||
| 233 | .or(old_path) | ||
| 234 | .map(path_to_string) | ||
| 235 | .unwrap_or_else(|| "(unknown path)".to_string()), | ||
| 236 | } | ||
| 237 | } | ||
| 238 | |||
| 239 | fn path_to_string(path: &Path) -> String { | ||
| 240 | path.to_string_lossy().into_owned() | ||
| 241 | } | ||
| 242 | |||
| 243 | fn compute_diff_files(repo: &git2::Repository, oid: git2::Oid) -> Vec<DiffFile> { | ||
| 32 | let commit = match repo.find_commit(oid) { | 244 | let commit = match repo.find_commit(oid) { |
| 33 | Ok(c) => c, | 245 | Ok(c) => c, |
| 34 | Err(_) => return Vec::new(), | 246 | Err(_) => return Vec::new(), |
| @@ -39,37 +251,54 @@ fn compute_diff_lines(repo: &git2::Repository, oid: git2::Oid) -> Vec<DiffLine> | |||
| 39 | Err(_) => return Vec::new(), | 251 | Err(_) => return Vec::new(), |
| 40 | }; | 252 | }; |
| 41 | 253 | ||
| 42 | let old_tree = commit | 254 | let old_tree = commit.parent(0).ok().and_then(|parent| parent.tree().ok()); |
| 43 | .parent(0) | ||
| 44 | .ok() | ||
| 45 | .and_then(|p| p.tree().ok()); | ||
| 46 | 255 | ||
| 47 | let diff = match repo.diff_tree_to_tree(old_tree.as_ref(), Some(&new_tree), None) { | 256 | let diff = match repo.diff_tree_to_tree(old_tree.as_ref(), Some(&new_tree), None) { |
| 48 | Ok(d) => d, | 257 | Ok(d) => d, |
| 49 | Err(_) => return Vec::new(), | 258 | Err(_) => return Vec::new(), |
| 50 | }; | 259 | }; |
| 51 | 260 | ||
| 52 | let mut lines: Vec<DiffLine> = Vec::new(); | 261 | let collector = RefCell::new(DiffCollector::default()); |
| 53 | 262 | ||
| 54 | let _ = diff.print(git2::DiffFormat::Patch, |_delta, _hunk, line| { | 263 | let mut file_cb = |delta: git2::DiffDelta<'_>, _progress: f32| { |
| 55 | let text = String::from_utf8_lossy(line.content()).into_owned(); | 264 | collector.borrow_mut().start_file(delta); |
| 56 | let kind = match line.origin() { | 265 | true |
| 57 | '+' => "add", | 266 | }; |
| 58 | '-' => "del", | 267 | |
| 59 | '@' => "hunk", | 268 | let mut binary_cb = |delta: git2::DiffDelta<'_>, _binary: git2::DiffBinary<'_>| { |
| 60 | 'F' => "file", | 269 | collector.borrow_mut().push_binary_marker(delta); |
| 61 | _ => "ctx", | 270 | true |
| 62 | } | 271 | }; |
| 63 | .to_string(); | 272 | |
| 64 | lines.push(DiffLine { kind, text }); | 273 | let mut hunk_cb = |delta: git2::DiffDelta<'_>, hunk: git2::DiffHunk<'_>| { |
| 274 | collector.borrow_mut().start_hunk(delta, hunk); | ||
| 65 | true | 275 | true |
| 66 | }); | 276 | }; |
| 277 | |||
| 278 | let mut line_cb = |_delta: git2::DiffDelta<'_>, | ||
| 279 | _hunk: Option<git2::DiffHunk<'_>>, | ||
| 280 | line: git2::DiffLine<'_>| { | ||
| 281 | collector.borrow_mut().push_line(line); | ||
| 282 | true | ||
| 283 | }; | ||
| 67 | 284 | ||
| 68 | lines | 285 | if diff |
| 286 | .foreach( | ||
| 287 | &mut file_cb, | ||
| 288 | Some(&mut binary_cb), | ||
| 289 | Some(&mut hunk_cb), | ||
| 290 | Some(&mut line_cb), | ||
| 291 | ) | ||
| 292 | .is_err() | ||
| 293 | { | ||
| 294 | return Vec::new(); | ||
| 295 | } | ||
| 296 | |||
| 297 | collector.into_inner().into_files() | ||
| 69 | } | 298 | } |
| 70 | 299 | ||
| 71 | pub async fn diff( | 300 | pub async fn diff( |
| 72 | Path((repo_name, oid_str)): Path<(String, String)>, | 301 | AxumPath((repo_name, oid_str)): AxumPath<(String, String)>, |
| 73 | State(state): State<Arc<AppState>>, | 302 | State(state): State<Arc<AppState>>, |
| 74 | ) -> Response { | 303 | ) -> Response { |
| 75 | let (_entry, repo) = match open_repo(&state, &repo_name) { | 304 | let (_entry, repo) = match open_repo(&state, &repo_name) { |
| @@ -100,15 +329,8 @@ pub async fn diff( | |||
| 100 | .map(|dt| dt.format("%Y-%m-%d %H:%M").to_string()) | 329 | .map(|dt| dt.format("%Y-%m-%d %H:%M").to_string()) |
| 101 | .unwrap_or_default(); | 330 | .unwrap_or_default(); |
| 102 | 331 | ||
| 103 | let body = commit | 332 | let full_message = commit.message().unwrap_or("").trim_end().to_string(); |
| 104 | .message() | 333 | let diff_files = compute_diff_files(&repo, oid); |
| 105 | .map(|m| { | ||
| 106 | let lines: Vec<&str> = m.splitn(2, '\n').collect(); | ||
| 107 | if lines.len() > 1 { lines[1].trim_start_matches('\n').to_string() } else { String::new() } | ||
| 108 | }) | ||
| 109 | .unwrap_or_default(); | ||
| 110 | |||
| 111 | let diff_lines = compute_diff_lines(&repo, oid); | ||
| 112 | 334 | ||
| 113 | DiffTemplate { | 335 | DiffTemplate { |
| 114 | site_title: state.site_title.clone(), | 336 | site_title: state.site_title.clone(), |
| @@ -118,10 +340,10 @@ pub async fn diff( | |||
| 118 | open_issues, | 340 | open_issues, |
| 119 | short_id, | 341 | short_id, |
| 120 | summary, | 342 | summary, |
| 121 | body, | 343 | full_message, |
| 122 | author, | 344 | author, |
| 123 | date, | 345 | date, |
| 124 | diff_lines, | 346 | diff_files, |
| 125 | } | 347 | } |
| 126 | .into_response() | 348 | .into_response() |
| 127 | } | 349 | } |
src/server/http/repo_list.rs
| Old | New | ||
|---|---|---|---|
| @@ -35,8 +35,9 @@ fn build_repo_list(state: &AppState) -> Vec<RepoListItem> { | |||
| 35 | entries | 35 | entries |
| 36 | .into_iter() | 36 | .into_iter() |
| 37 | .map(|entry| { | 37 | .map(|entry| { |
| 38 | let description = read_description(&entry); | 38 | let repo = crate::repos::open(&entry).ok(); |
| 39 | let last_commit = read_last_commit(&entry); | 39 | let description = resolve_description(&entry, repo.as_ref()); |
| 40 | let last_commit = repo.as_ref().map(read_last_commit).unwrap_or_default(); | ||
| 40 | RepoListItem { | 41 | RepoListItem { |
| 41 | name: entry.name, | 42 | name: entry.name, |
| 42 | description, | 43 | description, |
| @@ -46,34 +47,68 @@ fn build_repo_list(state: &AppState) -> Vec<RepoListItem> { | |||
| 46 | .collect() | 47 | .collect() |
| 47 | } | 48 | } |
| 48 | 49 | ||
| 49 | fn read_description(entry: &crate::repos::RepoEntry) -> String { | 50 | fn normalize_description(description: &str) -> Option<String> { |
| 51 | let description = description.trim(); | ||
| 52 | if description.is_empty() || description.starts_with("Unnamed repository") { | ||
| 53 | None | ||
| 54 | } else { | ||
| 55 | Some(description.to_string()) | ||
| 56 | } | ||
| 57 | } | ||
| 58 | |||
| 59 | fn resolve_display_commit(repo: &git2::Repository) -> Option<git2::Commit<'_>> { | ||
| 60 | if let Ok(head) = repo.head() { | ||
| 61 | if let Ok(commit) = head.peel_to_commit() { | ||
| 62 | return Some(commit); | ||
| 63 | } | ||
| 64 | } | ||
| 65 | |||
| 66 | let branches = repo.branches(Some(git2::BranchType::Local)).ok()?; | ||
| 67 | for branch in branches.filter_map(Result::ok) { | ||
| 68 | let (branch, _) = branch; | ||
| 69 | if let Ok(commit) = branch.into_reference().peel_to_commit() { | ||
| 70 | return Some(commit); | ||
| 71 | } | ||
| 72 | } | ||
| 73 | |||
| 74 | None | ||
| 75 | } | ||
| 76 | |||
| 77 | fn read_tracked_description(repo: &git2::Repository) -> Option<String> { | ||
| 78 | let commit = resolve_display_commit(repo)?; | ||
| 79 | let tree = commit.tree().ok()?; | ||
| 80 | let entry = tree.get_path(std::path::Path::new(".description")).ok()?; | ||
| 81 | let blob = entry.to_object(repo).ok()?.into_blob().ok()?; | ||
| 82 | let description = std::str::from_utf8(blob.content()).ok()?; | ||
| 83 | normalize_description(description) | ||
| 84 | } | ||
| 85 | |||
| 86 | fn read_local_description(entry: &crate::repos::RepoEntry) -> Option<String> { | ||
| 50 | let desc_path = if entry.bare { | 87 | let desc_path = if entry.bare { |
| 51 | entry.path.join("description") | 88 | entry.path.join("description") |
| 52 | } else { | 89 | } else { |
| 53 | entry.path.join(".git").join("description") | 90 | entry.path.join(".git").join("description") |
| 54 | }; | 91 | }; |
| 55 | 92 | ||
| 56 | std::fs::read_to_string(&desc_path) | 93 | let description = std::fs::read_to_string(&desc_path).ok()?; |
| 57 | .ok() | 94 | normalize_description(&description) |
| 58 | .map(|s| s.trim().to_string()) | ||
| 59 | .filter(|s| !s.is_empty() && !s.starts_with("Unnamed repository")) | ||
| 60 | .unwrap_or_default() | ||
| 61 | } | 95 | } |
| 62 | 96 | ||
| 63 | fn read_last_commit(entry: &crate::repos::RepoEntry) -> String { | 97 | fn resolve_description(entry: &crate::repos::RepoEntry, repo: Option<&git2::Repository>) -> String { |
| 64 | let repo = match crate::repos::open(entry) { | 98 | repo.and_then(read_tracked_description) |
| 65 | Ok(r) => r, | 99 | .or_else(|| read_local_description(entry)) |
| 66 | Err(_) => return String::new(), | 100 | .unwrap_or_default() |
| 67 | }; | 101 | } |
| 68 | 102 | ||
| 69 | let head = match repo.head() { | 103 | fn read_description(entry: &crate::repos::RepoEntry) -> String { |
| 70 | Ok(h) => h, | 104 | let repo = crate::repos::open(entry).ok(); |
| 71 | Err(_) => return String::new(), | 105 | resolve_description(entry, repo.as_ref()) |
| 72 | }; | 106 | } |
| 73 | 107 | ||
| 74 | let commit = match head.peel_to_commit() { | 108 | fn read_last_commit(repo: &git2::Repository) -> String { |
| 75 | Ok(c) => c, | 109 | let commit = match resolve_display_commit(repo) { |
| 76 | Err(_) => return String::new(), | 110 | Some(commit) => commit, |
| 111 | None => return String::new(), | ||
| 77 | }; | 112 | }; |
| 78 | 113 | ||
| 79 | let time = commit.time(); | 114 | let time = commit.time(); |
| @@ -85,3 +120,77 @@ fn read_last_commit(entry: &crate::repos::RepoEntry) -> String { | |||
| 85 | .map(|dt| dt.format("%Y-%m-%d").to_string()) | 120 | .map(|dt| dt.format("%Y-%m-%d").to_string()) |
| 86 | .unwrap_or_default() | 121 | .unwrap_or_default() |
| 87 | } | 122 | } |
| 123 | |||
| 124 | #[cfg(test)] | ||
| 125 | mod tests { | ||
| 126 | use super::{read_description, read_local_description}; | ||
| 127 | use std::path::Path; | ||
| 128 | use std::process::Command; | ||
| 129 | use tempfile::TempDir; | ||
| 130 | |||
| 131 | fn git(cwd: &Path, args: &[&str]) { | ||
| 132 | let output = Command::new("git") | ||
| 133 | .args(args) | ||
| 134 | .current_dir(cwd) | ||
| 135 | .output() | ||
| 136 | .expect("git command should run"); | ||
| 137 | assert!( | ||
| 138 | output.status.success(), | ||
| 139 | "git {:?} failed: stdout={} stderr={}", | ||
| 140 | args, | ||
| 141 | String::from_utf8_lossy(&output.stdout), | ||
| 142 | String::from_utf8_lossy(&output.stderr) | ||
| 143 | ); | ||
| 144 | } | ||
| 145 | |||
| 146 | fn make_non_bare_entry(path: &Path) -> crate::repos::RepoEntry { | ||
| 147 | crate::repos::RepoEntry { | ||
| 148 | name: "repo".to_string(), | ||
| 149 | path: path.to_path_buf(), | ||
| 150 | bare: false, | ||
| 151 | } | ||
| 152 | } | ||
| 153 | |||
| 154 | fn make_bare_entry(path: &Path) -> crate::repos::RepoEntry { | ||
| 155 | crate::repos::RepoEntry { | ||
| 156 | name: "repo".to_string(), | ||
| 157 | path: path.to_path_buf(), | ||
| 158 | bare: true, | ||
| 159 | } | ||
| 160 | } | ||
| 161 | |||
| 162 | #[test] | ||
| 163 | fn read_description_prefers_tracked_description_for_bare_repo() { | ||
| 164 | let tmp = TempDir::new().unwrap(); | ||
| 165 | let bare_repo = tmp.path().join("repo.git"); | ||
| 166 | let work_repo = tmp.path().join("work"); | ||
| 167 | |||
| 168 | git(tmp.path(), &["init", "--bare", "repo.git"]); | ||
| 169 | git(tmp.path(), &["clone", bare_repo.to_str().unwrap(), "work"]); | ||
| 170 | git(&work_repo, &["config", "user.name", "Test User"]); | ||
| 171 | git(&work_repo, &["config", "user.email", "test@example.com"]); | ||
| 172 | git(&work_repo, &["checkout", "-b", "main"]); | ||
| 173 | std::fs::write(work_repo.join(".description"), "Tracked description\n").unwrap(); | ||
| 174 | git(&work_repo, &["add", ".description"]); | ||
| 175 | git(&work_repo, &["commit", "-m", "Add tracked description"]); | ||
| 176 | git(&work_repo, &["push", "-u", "origin", "main"]); | ||
| 177 | |||
| 178 | std::fs::write(bare_repo.join("description"), "Local bare description\n").unwrap(); | ||
| 179 | |||
| 180 | let entry = make_bare_entry(&bare_repo); | ||
| 181 | assert_eq!(read_description(&entry), "Tracked description"); | ||
| 182 | } | ||
| 183 | |||
| 184 | #[test] | ||
| 185 | fn read_local_description_falls_back_to_git_metadata_file() { | ||
| 186 | let tmp = TempDir::new().unwrap(); | ||
| 187 | let repo_path = tmp.path().join("repo"); | ||
| 188 | |||
| 189 | git(tmp.path(), &["init", "repo"]); | ||
| 190 | std::fs::write(repo_path.join(".git").join("description"), "Local repo description\n").unwrap(); | ||
| 191 | |||
| 192 | let entry = make_non_bare_entry(&repo_path); | ||
| 193 | assert_eq!(read_local_description(&entry), Some("Local repo description".to_string())); | ||
| 194 | assert_eq!(read_description(&entry), "Local repo description"); | ||
| 195 | } | ||
| 196 | } | ||
src/server/http/templates/base.html
| Old | New | ||
|---|---|---|---|
| @@ -6,7 +6,7 @@ | |||
| 6 | <title>{% block title %}{{ site_title }}{% endblock %}</title> | 6 | <title>{% block title %}{{ site_title }}{% endblock %}</title> |
| 7 | <style> | 7 | <style> |
| 8 | * { box-sizing: border-box; margin: 0; padding: 0; } | 8 | * { box-sizing: border-box; margin: 0; padding: 0; } |
| 9 | body { font-family: monospace; font-size: 14px; line-height: 1.6; } | 9 | body { font-family: monospace; font-size: 14px; line-height: 1.6; color: #24292f; background: #fff; } |
| 10 | a { color: #0366d6; } | 10 | a { color: #0366d6; } |
| 11 | table { border-collapse: collapse; width: 100%; } | 11 | table { border-collapse: collapse; width: 100%; } |
| 12 | th, td { text-align: left; padding: 4px 8px; } | 12 | th, td { text-align: left; padding: 4px 8px; } |
| @@ -16,16 +16,30 @@ | |||
| 16 | .badge { font-size: 11px; color: #666; } | 16 | .badge { font-size: 11px; color: #666; } |
| 17 | .status-open { color: green; } | 17 | .status-open { color: green; } |
| 18 | .status-closed { color: red; } | 18 | .status-closed { color: red; } |
| 19 | .diff-add { color: green; } | 19 | .diff-add { color: #1a7f37; background: #eaf6ec; } |
| 20 | .diff-del { color: red; } | 20 | .diff-del { color: #cf222e; background: #ffebe9; } |
| 21 | .diff-hunk { color: #666; } | 21 | .diff-hunk { color: #57606a; background: #f6f8fa; } |
| 22 | .diff-file { font-weight: bold; border-top: 1px solid #ccc; padding-top: 8px; } | 22 | .diff-file { font-weight: bold; border-top: 1px solid #ccc; padding-top: 8px; } |
| 23 | .diff-ctx { color: #24292f; background: #fff; } | ||
| 24 | .diff-empty { background: #f6f8fa; color: transparent; } | ||
| 23 | .header { padding: 8px 16px; border-bottom: 1px solid #ccc; display: flex; align-items: baseline; gap: 16px; } | 25 | .header { padding: 8px 16px; border-bottom: 1px solid #ccc; display: flex; align-items: baseline; gap: 16px; } |
| 24 | .header a { text-decoration: none; } | 26 | .header a { text-decoration: none; } |
| 25 | .header .site { font-weight: bold; } | 27 | .header .site { font-weight: bold; } |
| 26 | .header nav { display: flex; gap: 12px; } | 28 | .header nav { display: flex; gap: 12px; } |
| 27 | .header nav a.active { font-weight: bold; } | 29 | .header nav a.active { font-weight: bold; } |
| 28 | .content { padding: 16px; max-width: 1200px; margin: 0 auto; } | 30 | .content { padding: 16px; max-width: 1500px; margin: 0 auto; } |
| 31 | .commit-message { padding: 12px; border: 1px solid #d0d7de; border-radius: 6px; white-space: pre-wrap; } | ||
| 32 | .diff-file-block { border: 1px solid #d0d7de; border-radius: 6px; overflow: hidden; } | ||
| 33 | .diff-file-heading { padding: 10px 12px; background: #f6f8fa; border-bottom: 1px solid #d0d7de; } | ||
| 34 | .side-by-side-diff { table-layout: fixed; } | ||
| 35 | .side-by-side-diff col.diff-line-col { width: 4rem; } | ||
| 36 | .side-by-side-diff col.diff-code-col { width: auto; } | ||
| 37 | .side-by-side-diff thead th { background: #f6f8fa; border-bottom: 1px solid #d0d7de; } | ||
| 38 | .side-by-side-diff thead th:nth-child(1), .side-by-side-diff thead th:nth-child(2) { border-right: 1px solid #d8dee4; } | ||
| 39 | .side-by-side-diff td { border-top: 1px solid #d8dee4; vertical-align: top; padding: 2px 6px; } | ||
| 40 | .side-by-side-diff td:nth-child(2) { border-right: 1px solid #d8dee4; } | ||
| 41 | .diff-line-no { text-align: right; color: #57606a; user-select: none; padding-right: 10px; } | ||
| 42 | .diff-code { white-space: pre-wrap; word-break: break-word; } | ||
| 29 | </style> | 43 | </style> |
| 30 | </head> | 44 | </head> |
| 31 | <body> | 45 | <body> |
src/server/http/templates/diff.html
| Old | New | ||
|---|---|---|---|
| @@ -3,12 +3,65 @@ | |||
| 3 | {% block title %}{{ short_id }} — {{ repo_name }} — {{ site_title }}{% endblock %} | 3 | {% block title %}{{ short_id }} — {{ repo_name }} — {{ site_title }}{% endblock %} |
| 4 | 4 | ||
| 5 | {% block content %} | 5 | {% block content %} |
| 6 | <h2 class="mono">{{ short_id }}</h2> | 6 | <section style="display: grid; gap: 12px;"> |
| 7 | <p><strong>{{ summary }}</strong></p> | 7 | <div> |
| 8 | <p style="color: #666;">{{ author }} {{ date }}</p> | 8 | <h2 class="mono">{{ short_id }}</h2> |
| 9 | {% if !body.is_empty() %} | 9 | <p><strong>{{ summary }}</strong></p> |
| 10 | <pre style="background: #f8f8f8; padding: 12px; border-radius: 4px; white-space: pre-wrap;">{{ body }}</pre> | 10 | <p style="color: #666;">{{ author }} {{ date }}</p> |
| 11 | {% endif %} | 11 | </div> |
| 12 | |||
| 13 | {% if !full_message.is_empty() %} | ||
| 14 | <div> | ||
| 15 | <div style="font-size: 12px; color: #666; margin-bottom: 6px;">Commit message</div> | ||
| 16 | <pre class="commit-message">{{ full_message }}</pre> | ||
| 17 | </div> | ||
| 18 | {% endif %} | ||
| 19 | </section> | ||
| 20 | |||
| 12 | <hr style="margin: 16px 0;"> | 21 | <hr style="margin: 16px 0;"> |
| 13 | <pre class="diff">{% for line in diff_lines %}{% if line.kind == "add" %}<span class="diff-add">{{ line.text }}</span>{% elif line.kind == "del" %}<span class="diff-del">{{ line.text }}</span>{% elif line.kind == "hunk" %}<span class="diff-hunk">{{ line.text }}</span>{% elif line.kind == "file" %}<span class="diff-file">{{ line.text }}</span>{% else %}{{ line.text }}{% endif %}{% endfor %}</pre> | 22 | |
| 23 | {% if diff_files.is_empty() %} | ||
| 24 | <p style="color: #666;">No diff available for this commit.</p> | ||
| 25 | {% else %} | ||
| 26 | <div style="display: grid; gap: 16px;"> | ||
| 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 %} | ||
| 14 | {% endblock %} | 67 | {% endblock %} |